phase: 91_admin_theme_tab
All verification is complete — this pass needed no code changes. Final report: **Phase 91 — Admin Theme tab: final verification pass (all 6 tasks already in `complete/`)** - Verified pre-paint theming end-to-end: `ui_settings` store + resolver, admin `GET/PUT /api/ui-settings`, `CachingMiddleware` inline-`<style id="bor-theme">` injection before `</head>` (incl. `/shared/<token>` prefix branch, unit-pinned), CSP sha256 exemption for the inline tag, Theme tab shell + `theme.js` editor, CSS-file theming fully retired. - No defects found; zero changes made — working tree left exactly as the task executors left it. - Tests: `uv run pytest --cov=app` → 1841 passed, 0 failed (TOTAL coverage **99%**; theming/ui_settings/caching all 100%); `uv run pytest tests/e2e/test_admin_theme_tab.py -v --no-cov` → **5 passed** in isolation. - Lint/types: `uv run ruff check .` → All checks passed; `uv run pyright` → 0 errors, 0 warnings. - Criteria: (1) unset deployment byte-identical, no `#bor-theme` anywhere — ✓ (unit no-op test + E2E reset byte-compare); `rg "BOR_THEME|themes/"` → single hit is the permitted doc-history comment in `frontend/index.html`. (2) admin-only gate + 403s for anonymous and token users — ✓ (E2E test 3). (3) saved theme inline before `</head>` on every page incl. `/shared/<token>`, computed `--brand` on first paint for admin + anonymous — ✓ (E2E test 2 + unit). (4) reset → byte-identical; 5 contrast pairs warn <4.5:1, non-blocking — ✓ (E2E tests 4–5). (5) suite green, >90% coverage, lint clean — ✓. (6) commit deferred to harness per rules. - Notable: `.agents/PLAN.md` is absent from the repo — the phase overview's Design section was used as the binding spec; no deviation resulted. - Next pending phase: **none** — 91 is the last phase in `todo/`.
This commit is contained in:
@@ -104,8 +104,8 @@ def test_view_map_covers_the_shell_paths() -> None:
|
||||
"""The VIEW map is pathname → view name: the shell's own two URLs
|
||||
("/" and "/index.html") are the chat view, plus one entry per
|
||||
folded view (tasks 01–03: tuning, rag, git-sources, history;
|
||||
phase 79 task 06: tokens — all five non-chat navbar views are
|
||||
in)."""
|
||||
phase 79 task 06: tokens; phase 91 task 04: theme — all six
|
||||
non-chat navbar views are in)."""
|
||||
js = _js()
|
||||
view_start = js.find("const VIEW = {")
|
||||
assert view_start != -1, "the VIEW map must exist"
|
||||
@@ -123,8 +123,11 @@ def test_view_map_covers_the_shell_paths() -> None:
|
||||
assert '"/tokens.html": "tokens"' in view_body, (
|
||||
"phase 79 task 06 folds the Tokens view into the shell"
|
||||
)
|
||||
assert '"/theme.html": "theme"' in view_body, (
|
||||
"phase 91 task 04 folds the Theme view into the shell"
|
||||
)
|
||||
# The view names are the #view-<name> section slugs in index.html.
|
||||
for name in ("chat", "tuning", "history", "tokens"):
|
||||
for name in ("chat", "tuning", "history", "tokens", "theme"):
|
||||
assert f'id="view-{name}"' in _html(), f"missing the #view-{name} section"
|
||||
|
||||
|
||||
@@ -218,6 +221,9 @@ def test_only_non_chat_views_have_lazy_modules() -> None:
|
||||
assert 'tokens: () => import("./tokens.js")' in mods_body, (
|
||||
"the Tokens view module is lazy-imported on first show"
|
||||
)
|
||||
assert 'theme: () => import("./theme.js")' in mods_body, (
|
||||
"the Theme view module is lazy-imported on first show (phase 91)"
|
||||
)
|
||||
assert '"chat"' not in mods_body, "the chat view has no lazy module"
|
||||
assert 'import("./app.js")' not in js, "app.js must never be lazy-imported"
|
||||
|
||||
@@ -275,6 +281,11 @@ def test_router_writes_active_state_title_and_meta() -> None:
|
||||
assert "Saved chats — every conversation is saved automatically, one click back." in js
|
||||
assert 'tokens: "Access tokens · Brain of Reese"' in js
|
||||
assert "Generate and revoke the API tokens that let people use the app." in js
|
||||
assert 'theme: "Theme · Brain of Reese"' in js
|
||||
assert (
|
||||
"Set the palette and branding — the theme is baked into every served page, "
|
||||
"live on the first paint."
|
||||
) in js
|
||||
# The brand composition (phase 39's window.BOR_BRAND, read at
|
||||
# write time — never a hardcoded stamp).
|
||||
assert 'window.BOR_BRAND || "Brain of Reese"' in js
|
||||
@@ -351,6 +362,15 @@ def test_shell_markup_has_one_main_two_views_and_chat_only_active() -> None:
|
||||
tokens_link = tokens_match.group(0)
|
||||
assert "hidden" in tokens_link, "#nav-tokens ships hidden (admin-only)"
|
||||
assert "is-active" not in tokens_link, "no static active stamp on the Tokens link"
|
||||
# The Theme nav link (phase 91 task 04) ships hidden (admin-only)
|
||||
# and UNstamped too — the router is the single writer of the active
|
||||
# state, and a token user (role "user") must never see the link
|
||||
# (header.js reveals it for admin only).
|
||||
theme_match = re.search(r'<a[^>]*id="nav-theme"[^>]*>', html)
|
||||
assert theme_match, "the shell must carry the #nav-theme nav link"
|
||||
theme_link = theme_match.group(0)
|
||||
assert "hidden" in theme_link, "#nav-theme ships hidden (admin-only)"
|
||||
assert "is-active" not in theme_link, "no static active stamp on the Theme link"
|
||||
|
||||
|
||||
# ---------- phase 76 task 04: the header is shell-owned ----------
|
||||
@@ -869,3 +889,153 @@ def test_tokens_view_scaffold_in_the_shell() -> None:
|
||||
# The Actions column header is visually-hidden (the row buttons
|
||||
# carry their own aria-labels — the history-table convention).
|
||||
assert '<th scope="col"><span class="visually-hidden">Actions</span></th>' in body
|
||||
|
||||
|
||||
# ---------- phase 91 task 04: the Theme view (skeleton) ----------
|
||||
|
||||
|
||||
def test_theme_view_scaffold_in_the_shell() -> None:
|
||||
"""Phase 91 task 04: the shell carries the #view-theme section —
|
||||
hidden AND inert + focusable (the WCAG pair, AGENTS.md rule 5) —
|
||||
with the #theme-gate (the EXACT #sources-gate pattern, ship-hidden,
|
||||
its Sign in returning to the Theme view via ?next=/theme.html) and
|
||||
the ship-hidden #theme-content (the #git-sources-content pattern)
|
||||
holding the STATIC form skeleton: the page-head (h1 "Theme"), the
|
||||
#theme-form with the 3 labeled branding text inputs (maxlength=300
|
||||
— the server re-validates) + the 8 labeled type=color palette inputs
|
||||
(the 8 identity variables, in the theming.COLOR_FIELDS order), the
|
||||
#theme-save (primary) + #theme-reset (secondary) — BOTH type="button"
|
||||
(no real submit), and the three task-05 feedback lines: #theme-error
|
||||
(role=alert), #theme-result (role=status), #theme-contrast
|
||||
(role=alert) — all ship hidden. The editor behavior (populate,
|
||||
live preview, Save/Reset, the contrast warnings) lands in task 05;
|
||||
this pin keeps the E2E-stable skeleton from drifting."""
|
||||
html = _html()
|
||||
view = html.find('<section class="view" id="view-theme"')
|
||||
assert view != -1, "the #view-theme section must be in the shell"
|
||||
tag_end = html.find(">", view)
|
||||
tag = html[view:tag_end]
|
||||
assert "hidden" in tag and "inert" in tag, (
|
||||
"the folded view ships hidden AND inert"
|
||||
)
|
||||
assert 'tabindex="-1"' in tag, "the target view is focusable"
|
||||
main_end = html.find("</main>", view)
|
||||
assert view < main_end, "the view section lives inside the single main"
|
||||
body = html[view:main_end]
|
||||
# The gate: the exact #sources-gate pattern (class + ship-hidden +
|
||||
# its ?next= returning to the Theme view — the no-JS fallback).
|
||||
gate = re.search(r'<section[^>]*id="theme-gate"[^>]*>', body)
|
||||
assert gate and "hidden" in gate.group(0), "#theme-gate must ship hidden"
|
||||
assert 'class="sources-gate"' in gate.group(0), (
|
||||
"the gate reuses the .sources-gate visual language"
|
||||
)
|
||||
assert "<h2 id=\"theme-gate-title\">Sign in to change the theme</h2>" in body
|
||||
assert 'href="/login.html?next=/theme.html"' in body, (
|
||||
"the gate's Sign in returns to the Theme view (no-JS fallback)"
|
||||
)
|
||||
# The content ships hidden (theme.js reveals it for admin only —
|
||||
# the #git-sources-content pattern).
|
||||
content = re.search(r'<div[^>]*id="theme-content"[^>]*>', body)
|
||||
assert content and "hidden" in content.group(0), (
|
||||
"#theme-content must ship hidden (anonymous-safe)"
|
||||
)
|
||||
# The static form skeleton (the E2E-stable-selectors house
|
||||
# convention): the 3 labeled branding text inputs (maxlength=300)
|
||||
# and the 8 labeled type=color palette inputs (the 8 identity
|
||||
# variables — one per theming.COLOR_FIELDS field).
|
||||
assert re.search(r'<form[^>]*id="theme-form"[^>]*>', body), (
|
||||
"the #theme-form must be STATIC markup in the shell"
|
||||
)
|
||||
for field_id in ("theme-app-name", "theme-placeholder", "theme-footer"):
|
||||
assert re.search(
|
||||
rf'<label[^>]*for="{field_id}"[^>]*>', body
|
||||
), f"missing the visible label for #{field_id}"
|
||||
assert re.search(
|
||||
rf'<input[^>]*id="{field_id}"[^>]*maxlength="300"[^>]*>', body
|
||||
), f"#{field_id} must be a text input with maxlength=300"
|
||||
for field_id in (
|
||||
"theme-bg",
|
||||
"theme-surface",
|
||||
"theme-ink",
|
||||
"theme-ink-soft",
|
||||
"theme-line",
|
||||
"theme-brand",
|
||||
"theme-brand-soft",
|
||||
"theme-brand-ink",
|
||||
):
|
||||
assert re.search(
|
||||
rf'<label[^>]*for="{field_id}"[^>]*>', body
|
||||
), f"missing the visible label for #{field_id}"
|
||||
assert re.search(
|
||||
rf'<input[^>]*id="{field_id}"[^>]*type="color"[^>]*>', body
|
||||
), f"#{field_id} must be a type=color input"
|
||||
# Save (primary) + Reset (secondary) — BOTH type="button" (no real
|
||||
# submit; theme.js owns the onsubmit handling + the §7.4 lifecycle).
|
||||
save = re.search(r'<button[^>]*id="theme-save"[^>]*>', body)
|
||||
assert save and 'type="button"' in save.group(0), (
|
||||
"#theme-save must be a type=button (no real submit)"
|
||||
)
|
||||
reset = re.search(r'<button[^>]*id="theme-reset"[^>]*>', body)
|
||||
assert reset and 'type="button"' in reset.group(0), (
|
||||
"#theme-reset must be a type=button (no real submit)"
|
||||
)
|
||||
assert "Save theme" in body, "the Save button's label"
|
||||
assert "Reset to defaults" in body, "the Reset button's label"
|
||||
# The three task-05 feedback lines, all ship hidden.
|
||||
assert re.search(r'<[^>]*id="theme-error"[^>]*role="alert"[^>]*hidden', body)
|
||||
assert re.search(r'<[^>]*id="theme-result"[^>]*role="status"[^>]*hidden', body)
|
||||
assert re.search(r'<[^>]*id="theme-contrast"[^>]*role="alert"[^>]*hidden', body)
|
||||
|
||||
|
||||
def test_theme_nav_link_ships_on_every_page_header() -> None:
|
||||
"""Phase 91 task 04: the phase-34 one-bar contract — the SAME nav
|
||||
ships on every page (test_nav_consistency pins the header inventory
|
||||
PARITY across the shell pages, the document viewer, and the login
|
||||
page), so #nav-theme (ship-hidden, admin-only) must be in the
|
||||
#app-nav of EVERY header-bearing page: the shell + document.html +
|
||||
login.html + shared.html. The doc-edit flow page ships the reduced
|
||||
header (no admin links at all) and is out of the contract."""
|
||||
for page in (
|
||||
FRONTEND / "index.html",
|
||||
FRONTEND / "document.html",
|
||||
FRONTEND / "login.html",
|
||||
FRONTEND / "shared.html",
|
||||
):
|
||||
text = page.read_text(encoding="utf-8")
|
||||
match = re.search(r'<a[^>]*id="nav-theme"[^>]*>', text)
|
||||
assert match, f"{page.name} must carry the #nav-theme nav link (one-bar)"
|
||||
link = match.group(0)
|
||||
assert 'href="/theme.html"' in link, f"{page.name}: the Theme link's href"
|
||||
assert "hidden" in link, (
|
||||
f"{page.name}: #nav-theme ships hidden (admin-only)"
|
||||
)
|
||||
assert "is-active" not in link, (
|
||||
f"{page.name}: no static active stamp on the Theme link"
|
||||
)
|
||||
|
||||
|
||||
def test_header_js_reveals_the_theme_link_for_admin_only() -> None:
|
||||
"""Phase 91 task 04: header.js reveals #nav-theme for role admin —
|
||||
the same ship-hidden / reveal-for-admin contract as the other
|
||||
admin-only links: the two-line reveal (`hidden = !admin`) sits in
|
||||
initSharedHeader, null-safe (a page without the link is a no-op),
|
||||
and the gate is the `admin` flag (role === "admin") — a token user
|
||||
(role "user") never sees the link."""
|
||||
header_js = (ASSETS / "header.js").read_text(encoding="utf-8")
|
||||
fn = header_js.find("export async function initSharedHeader")
|
||||
assert fn != -1, "initSharedHeader must exist"
|
||||
body = header_js[fn:]
|
||||
lookup = body.find('document.querySelector("#nav-theme")')
|
||||
assert lookup != -1, "header.js must look up #nav-theme"
|
||||
reveal = body.find("navTheme.hidden = !admin")
|
||||
assert 0 <= lookup < reveal, (
|
||||
"the reveal must be the two-line pattern: null-safe lookup, "
|
||||
"then hidden = !admin (the admin flag — role === \"admin\")"
|
||||
)
|
||||
# The lookup + reveal sit AFTER the whoami resolution (the admin
|
||||
# flag exists only once fetchWhoami has settled).
|
||||
whoami = body.find("const whoami = await fetchWhoami()")
|
||||
admin_flag = body.find('const admin = whoami.role === "admin"')
|
||||
assert 0 <= whoami < admin_flag < lookup, (
|
||||
"the reveal keys off the resolved admin flag"
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user