+ ? `
Export new since last
`
@@ -80,7 +86,7 @@ export async function showProjects() {
Projects
${sinceBtnHtml}
-
+
Export all
@@ -140,6 +146,7 @@ export async function showProjects() {
loadingBar.done();
smoothSet(content, html);
+ bindProjectsExportButtons(content);
} catch (e) {
loadingBar.done();
smoothSet(content, ``);
diff --git a/static/js/search.js b/static/js/search.js
index 31020b2..8192c15 100644
--- a/static/js/search.js
+++ b/static/js/search.js
@@ -2,6 +2,7 @@
import { esc, smoothSet, setHamburgerVisible } from './shared/utils.js';
import { setWorkspaceMode } from './shared/theme.js';
+import { showProjects } from './projects.js';
// ==================== Search ====================
@@ -14,19 +15,26 @@ export function showSearchPage() {
const content = document.getElementById('content');
content.innerHTML = `
`;
+ document.getElementById('search-back-link')?.addEventListener('click', (e) => {
+ e.preventDefault();
+ showProjects();
+ });
+ document.getElementById('search-submit-btn')?.addEventListener('click', doSearch);
+ document.getElementById('search-input')?.addEventListener('keydown', (e) => {
+ if (e.key === 'Enter') doSearch();
+ });
document.getElementById('search-results').addEventListener('click', (e) => {
if (!(e.target instanceof Element)) return;
const result = e.target.closest('.search-result[data-project]');
@@ -36,7 +44,7 @@ export function showSearchPage() {
if (!project || !sessionId) return;
window.location.hash = `#project/${encodeURIComponent(project)}/${encodeURIComponent(sessionId)}`;
});
- document.getElementById('search-input').focus();
+ document.getElementById('search-input')?.focus();
}
export async function doSearch() {
diff --git a/static/js/sessions.js b/static/js/sessions.js
index a6821e5..33d5a8c 100644
--- a/static/js/sessions.js
+++ b/static/js/sessions.js
@@ -5,6 +5,7 @@ import { esc, formatDate, formatTs, smoothSet, loadingBar, showToast, closeSideb
import { renderMarkdown, cleanContent } from './shared/markdown.js';
import { setWorkspaceMode } from './shared/theme.js';
import { downloadSession } from './export.js';
+import { showProjects } from './projects.js';
import { renderToolUse, renderToolResult, toolResultHasBody } from './render/registry.js';
// ==================== Workspace (split layout) ====================
@@ -66,7 +67,7 @@ export async function showWorkspace(projectName, selectedSessionId) {
sidebar += ' ';
let html = `
-
+
Back to Projects
@@ -84,6 +85,10 @@ export async function showWorkspace(projectName, selectedSessionId) {
`;
smoothSet(content, html);
bindSidebarSessionClicks();
+ content.querySelector('#ws-back-link')?.addEventListener('click', (e) => {
+ e.preventDefault();
+ showProjects();
+ });
loadingBar.done();
if (selectedSessionId) {
@@ -175,7 +180,7 @@ export async function loadSession(projectName, sessionId) {
const wsActions = document.getElementById('ws-actions');
if (wsActions) {
wsActions.innerHTML = `
-
+
Copy All
@@ -185,6 +190,7 @@ export async function loadSession(projectName, sessionId) {
`;
bindWorkspaceDownloadClick(wsActions);
+ wsActions.querySelector('#btn-copy-all')?.addEventListener('click', copyAll);
}
html += `
diff --git a/static/js/theme-init.js b/static/js/theme-init.js
new file mode 100644
index 0000000..4250b3a
--- /dev/null
+++ b/static/js/theme-init.js
@@ -0,0 +1,7 @@
+/* Apply saved theme before first paint (avoids dark flash in light mode).
+ hljs URLs/integrity must match HLJS_THEME_SHEETS in static/js/shared/theme.js */
+(function () {
+ var t = localStorage.getItem('theme');
+ if (t !== 'light' && t !== 'dark') t = 'dark';
+ document.documentElement.setAttribute('data-theme', t);
+})();
diff --git a/static/js/theme-init.test.js b/static/js/theme-init.test.js
new file mode 100644
index 0000000..d57449e
--- /dev/null
+++ b/static/js/theme-init.test.js
@@ -0,0 +1,26 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+describe('theme-init.js', () => {
+ beforeEach(() => {
+ vi.resetModules();
+ localStorage.clear();
+ document.documentElement.removeAttribute('data-theme');
+ });
+
+ it('defaults to dark when localStorage has no theme', async () => {
+ await import('./theme-init.js');
+ expect(document.documentElement.getAttribute('data-theme')).toBe('dark');
+ });
+
+ it('applies saved light theme before paint', async () => {
+ localStorage.setItem('theme', 'light');
+ await import('./theme-init.js');
+ expect(document.documentElement.getAttribute('data-theme')).toBe('light');
+ });
+
+ it('ignores invalid stored theme values', async () => {
+ localStorage.setItem('theme', 'sepia');
+ await import('./theme-init.js');
+ expect(document.documentElement.getAttribute('data-theme')).toBe('dark');
+ });
+});
diff --git a/tests/test_api_integration.py b/tests/test_api_integration.py
index 7fd37cb..9500d55 100644
--- a/tests/test_api_integration.py
+++ b/tests/test_api_integration.py
@@ -9,8 +9,24 @@
from __future__ import annotations
+from app import CSP_POLICY
from tests.conftest import assert_error_response as _assert_error_shape
+# --- / (SPA shell) ---
+
+
+def test_root_sets_csp_header(client):
+ resp = client.get("/")
+ assert resp.status_code == 200
+ assert resp.headers.get("Content-Security-Policy") == CSP_POLICY
+
+
+def test_api_routes_set_csp_header(client):
+ resp = client.get("/api/projects")
+ assert resp.status_code == 200
+ assert resp.headers.get("Content-Security-Policy") == CSP_POLICY
+
+
# --- /api/projects ---
diff --git a/tests/test_hljs_theme_consistency.py b/tests/test_hljs_theme_consistency.py
index 5b80465..ef6c2c5 100644
--- a/tests/test_hljs_theme_consistency.py
+++ b/tests/test_hljs_theme_consistency.py
@@ -16,9 +16,10 @@
REPO_ROOT = Path(__file__).resolve().parent.parent
INDEX_HTML = REPO_ROOT / "static" / "index.html"
+HLJS_THEME_INIT_JS = REPO_ROOT / "static" / "js" / "hljs-theme-init.js"
# HLJS_THEME_SHEETS was extracted to shared/theme.js (Day 4 module split).
# app.js re-exports it, but the canonical source is theme.js.
-APP_JS = REPO_ROOT / "static" / "js" / "shared" / "theme.js"
+THEME_JS = REPO_ROOT / "static" / "js" / "shared" / "theme.js"
def _link_attr(html: str, link_id: str, attr: str) -> str:
@@ -37,7 +38,7 @@ def _link_attr(html: str, link_id: str, attr: str) -> str:
def _js_theme_entry(js: str, theme: str) -> dict:
"""Return {'href': ..., 'integrity': ...} from HLJS_THEME_SHEETS.."""
block = re.search(re.escape(theme) + r"\s*:\s*\{([^}]*)\}", js, re.DOTALL)
- assert block, f"HLJS_THEME_SHEETS.{theme} entry not found in app.js"
+ assert block, f"HLJS_THEME_SHEETS.{theme} entry not found in theme.js"
body = block.group(1)
out = {}
for key in ("href", "integrity"):
@@ -47,21 +48,52 @@ def _js_theme_entry(js: str, theme: str) -> dict:
return out
+def _js_string_assignments(js: str, keys: tuple[str, ...]) -> dict[str, str]:
+ """Return string literal assignments like ``link.href = '...'`` from classic JS."""
+ out: dict[str, str] = {}
+ for key in keys:
+ m = re.search(
+ r"link\." + re.escape(key) + r"\s*=\s*['\"]([^'\"]+)['\"]",
+ js,
+ )
+ assert m, f"hljs-theme-init.js has no link.{key} assignment"
+ out[key] = m.group(1)
+ return out
+
+
def test_dark_theme_url_and_hash_match_between_html_and_js():
html = INDEX_HTML.read_text(encoding="utf-8")
- js = APP_JS.read_text(encoding="utf-8")
+ js = THEME_JS.read_text(encoding="utf-8")
html_href = _link_attr(html, "hljs-theme", "href")
html_integrity = _link_attr(html, "hljs-theme", "integrity")
js_dark = _js_theme_entry(js, "dark")
assert html_href == js_dark["href"], (
- "highlight.js theme URL drifted between index.html and app.js — "
- f"html={html_href!r}, app.js HLJS_THEME_SHEETS.dark={js_dark['href']!r}. "
+ "highlight.js theme URL drifted between index.html and theme.js — "
+ f"html={html_href!r}, theme.js HLJS_THEME_SHEETS.dark={js_dark['href']!r}. "
"On a version bump both must update together (issue #19)."
)
assert html_integrity == js_dark["integrity"], (
- "highlight.js theme SRI hash drifted between index.html and app.js — "
+ "highlight.js theme SRI hash drifted between index.html and theme.js — "
f"html={html_integrity!r}, "
- f"app.js HLJS_THEME_SHEETS.dark={js_dark['integrity']!r}."
+ f"theme.js HLJS_THEME_SHEETS.dark={js_dark['integrity']!r}."
+ )
+
+
+def test_light_theme_url_and_hash_match_between_hljs_init_and_theme_js():
+ init_js = HLJS_THEME_INIT_JS.read_text(encoding="utf-8")
+ theme_js = THEME_JS.read_text(encoding="utf-8")
+
+ init = _js_string_assignments(init_js, ("integrity", "href"))
+ js_light = _js_theme_entry(theme_js, "light")
+
+ assert init["href"] == js_light["href"], (
+ "highlight.js light theme URL drifted between hljs-theme-init.js and theme.js — "
+ f"init={init['href']!r}, theme.js HLJS_THEME_SHEETS.light={js_light['href']!r}."
+ )
+ assert init["integrity"] == js_light["integrity"], (
+ "highlight.js light theme SRI hash drifted between hljs-theme-init.js and theme.js — "
+ f"init={init['integrity']!r}, "
+ f"theme.js HLJS_THEME_SHEETS.light={js_light['integrity']!r}."
)