From d22d260b8b0be59eee302614d892400fe9277a17 Mon Sep 17 00:00:00 2001 From: ducoterra Date: Wed, 9 Sep 2026 17:22:24 -0400 Subject: [PATCH] phase: 91_admin_theme_tab MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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-``` and inserted immediately +BEFORE the first ```` (:func:`app.core.theming.inject_theme`), +so a themed deployment paints its palette on the FIRST paint — no red +flash, no pop-in. An unset/defaults deployment gets ``tag == ""`` — +the identity no-op — and serves the EXACT pre-phase-91 rewrite-only +bytes (the byte-identical contract, B4); a DB blip (or a pre-migration +boot) is the same no-op, the page never breaks. The asset rewrite is +untouched, and ``/api/*`` / ``/assets/*`` still pass through +byte-identical. + +Phase 91 (task 05, defect fix) — the CSP extension: the phase-82 +policy (A1, ``default-src 'self'`` with no ``style-src``) BLOCKS the +inline tag in every real browser, so a themed HTML page's response +also carries ``style-src 'self' 'sha256-'`` appended to the A1 +string, where ```` is the CSP3 hash of the EXACT tag content +(:func:`app.core.theming.theme_csp_hash`) — the current theme is the +only inline style ever permitted (no ``'unsafe-inline'``; a different +palette or any other inline style is still blocked). The untagged +response keeps the plain A1 string (the outer +:class:`~app.core.security_headers.SecurityHeadersMiddleware` +preserves a CSP an inner layer has already set), and no non-HTML +response ever gets the extension. + The token is computed **once per process** (``functools.cache``, i.e. ``lru_cache(maxsize=None)``) — zero per-request git/file cost. It changes when a new commit lands (git path) or the frontend tree's mtimes/sizes @@ -61,6 +91,9 @@ from starlette.requests import Request from starlette.responses import Response from app.config import get_settings +from app.core import theming +from app.core.security_headers import CSP +from app.db import SessionLocal logger = logging.getLogger("app") @@ -143,6 +176,7 @@ HTML_PAGES: tuple[str, ...] = ( "/git-sources.html", # phase 35: the admin git sources page "/history.html", # phase 50: the admin saved-chats page "/tokens.html", # phase 79 task 06: the admin tokens page (shell route) + "/theme.html", # phase 91 task 04: the admin theme page (shell route) # phase 51: the shared page's STATIC path (the static mount serves # shared.html at /shared.html as well as the real route serves the # dynamic /shared/ — both must carry the no-cache + ?v= @@ -317,8 +351,35 @@ class CachingMiddleware(BaseHTTPMiddleware): response.headers["Cache-Control"] = HTML_CACHE_CONTROL return response + # Phase 91 (task 02): the pre-paint theme tag. One short-lived + # session per response (the sync-endpoint house pattern from + # app/db.py — the middleware world is sync); NO process cache — + # the theme changes at runtime from the admin tab, so the next + # request must see it without a restart. A DB blip (or a + # pre-migration boot) must never break the page: fall back to + # ``tag == ""`` (the built-in palette) and keep the no-cache + # contract (loadHealth house style). + tag = "" try: - new_body = rewrite_asset_refs(body.decode("utf-8"), token).encode("utf-8") + db = SessionLocal() + try: + effective = theming.effective_settings(db) + finally: + db.close() + tag = theming.theme_style_tag( + {key: effective[key] for key in theming.COLOR_FIELDS} + ) + except Exception: + logger.exception( + "cache busting: theme read failed for %s — serving without the theme tag", + path, + ) + tag = "" + + try: + new_body = theming.inject_theme( + rewrite_asset_refs(body.decode("utf-8"), token), tag + ).encode("utf-8") except Exception: # The body IS buffered — re-serve the ORIGINAL bytes so a # rewrite hiccup never loses the page. @@ -329,10 +390,22 @@ class CachingMiddleware(BaseHTTPMiddleware): headers=_no_cache_headers(response), ) + headers = _no_cache_headers(response) + if tag: + # Phase 91 (task 05): the inline tag needs a style-src + # exemption or the phase-82 CSP blocks it in the browser — + # the strictest one: a sha256 hash of the EXACT tag content + # (theming.theme_csp_hash), appended to the A1 string. The + # outer SecurityHeadersMiddleware preserves this (it only + # fills in a missing CSP); the untagged page keeps A1 + # verbatim — byte- AND header-identical to pre-phase-91. + headers["Content-Security-Policy"] = ( + f"{CSP}; style-src 'self' '{theming.theme_csp_hash(tag)}'" + ) return Response( content=new_body, status_code=response.status_code, - headers=_no_cache_headers(response), + headers=headers, ) diff --git a/app/core/security_headers.py b/app/core/security_headers.py index 142f3c5..c51b72d 100644 --- a/app/core/security_headers.py +++ b/app/core/security_headers.py @@ -55,7 +55,16 @@ class SecurityHeadersMiddleware: Adds exactly three headers to every HTTP response: * ``Content-Security-Policy``: the strict same-origin policy above - (``frame-ancestors 'none'`` → clickjacking closed, SEC-04); + (``frame-ancestors 'none'`` → clickjacking closed, SEC-04) — + EXCEPT when an inner layer has already set one: the phase-91 + (task 05) pre-paint theme tag is an inline `` + + Pure function of its input — :func:`inject_theme` places it before + the first ```` of every served HTML page (the phase-91 + pre-paint injection), so the themed deployment renders its palette + on the FIRST paint (no red flash, no pop-in). + """ + if all(colors[key] == BUILTIN_COLORS[key] for key in COLOR_FIELDS): + return "" + declarations = "".join( + f"--{key.replace('_', '-')}:{colors[key]};" for key in COLOR_FIELDS + ) + return f'' + + +def inject_theme(html: str, tag: str) -> str: + """Insert ``tag`` immediately BEFORE the first ```` of + ``html`` — the pure half of the phase-91 pre-paint injection. + + The :class:`~app.core.caching.CachingMiddleware` (task 02) calls + this on every known HTML page's rewritten body, so the helper stays + pure (no DB, no app) and unit-testable on its own. Identity rules — + the byte-identical contract (B4, owner-locked 2026-09-09): + + * ``tag == ""`` (an unset or "defaults saved" deployment — + :func:`theme_style_tag` returns exactly that) → ``html`` is + returned EXACTLY as passed in, byte for byte; + * no ```` occurrence → unchanged (nothing to anchor to); + * ``id="bor-theme"`` already present → unchanged (defensive + idempotence — the static files never contain the id, and one + body can never reach the helper twice, but the guarantee is free + for a pure function). + + Otherwise the tag is placed with a leading newline (readable HTML) + immediately before the FIRST ```` — the browser meets the + complete ``:root`` override before it applies any stylesheet, so + the palette is live on the first paint. + """ + if not tag or "" not in html or 'id="bor-theme"' in html: + return html + index = html.index("") + return html[:index] + "\n" + tag + html[index:] + + +def theme_csp_hash(tag: str) -> str: + """The CSP3 ``sha256-`` source expression for an inline theme tag. + + Phase 91 (task 05 defect fix): the phase-82 CSP (A1 — + ``default-src 'self'`` with no explicit ``style-src``) BLOCKS the + inline ``", 1)[0] + digest = hashlib.sha256(content.encode("utf-8")).digest() + return "sha256-" + base64.b64encode(digest).decode("ascii") diff --git a/app/main.py b/app/main.py index 40c1b04..faa5025 100644 --- a/app/main.py +++ b/app/main.py @@ -40,6 +40,7 @@ from app.api.steering import router as steering_router from app.api.suggestions import router as suggestions_router from app.api.sync import router as sync_router from app.api.tokens import router as tokens_router +from app.api.ui_settings import router as ui_settings_router from app.config import get_settings from app.core.auth import ensure_admin_configured from app.core.caching import configure_caching @@ -122,6 +123,10 @@ def create_app() -> FastAPI: # Phase 79: the admin token surface (create/list/revoke) — admin-only # (router-wide require_admin; a token USER stays 403 here, task 03). app.include_router(tokens_router, prefix="/api") + # Phase 91 (task 01): the admin UI-settings surface (GET/PUT the + # single ui_settings row — the Theme tab's persistence) — admin-only + # (router-wide require_admin; anonymous AND token users stay 403). + app.include_router(ui_settings_router, prefix="/api") # Phase 51: the anonymous shared-chat read — NO admin dependency. # /api/shared/ is the JSON snapshot; /shared/ (the # page route below, registered without a prefix) is the page. @@ -156,6 +161,7 @@ def create_app() -> FastAPI: "/git-sources.html", "/history.html", "/tokens.html", # phase 79 task 06: the Tokens view + "/theme.html", # phase 91 task 04: the Theme view (shell route) ), ) app.mount("/", StaticFiles(directory=static_dir, html=True), name="static") diff --git a/app/models.py b/app/models.py index fd07491..571e6e8 100644 --- a/app/models.py +++ b/app/models.py @@ -57,6 +57,12 @@ Data model — see ``.agents/PLAN.md`` §Data Model: on ``POST /api/token-auth`` (task 03 — the only request that presents the token; the in-app gate re-sends the cached token on every page load). +* ``ui_settings`` — single-row UI settings (phase 91): the admin + Theme tab's app name, input placeholder, footer + text and the 8 identity colors, one row + (``id = 1``); every column NULL = "use the + default" (env value for the strings, the built-in + palette for the colors — task 01). """ from __future__ import annotations @@ -384,3 +390,40 @@ class ApiToken(Base): #: (enforced immediately on the holder's next request); NULL while #: active. revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + + +class UiSettings(Base): + """Single-row UI settings (phase 91, task 01). + + The admin Theme tab (``/theme.html``, tasks 04/05) persists everything + the ``BOR_`` env vars and the retired custom-CSS theming supported in + ONE row (``id = 1`` — the single row is always id 1; ``GET`` creates + nothing, ``PUT`` upserts). The NULL = default rule (B1, owner-locked + 2026-09-09): every column is nullable, and a NULL (or empty) column + means "use the default" — the env value for the three strings + (``settings.app_name`` etc.), the built-in palette + (:data:`app.core.theming.BUILTIN_COLORS`) for the eight identity + colors (B1: no env fallback for colors). :func:`app.core.theming. + effective_settings` resolves the effective 11 values both the + ``GET /api/ui-settings`` and ``GET /api/config`` endpoints serve. + """ + + __tablename__ = "ui_settings" + + #: The single row is always id 1 (the ``kb_overview`` / ``sources_meta`` + #: id=1 precedent — Python-side default; the migration carries no + #: server default because the row is created only by the PUT upsert). + id: Mapped[int] = mapped_column(Integer, primary_key=True, default=1) + # --- Strings (NULL/empty = "use the env default" — B1) --- + app_name: Mapped[str | None] = mapped_column(String(300), nullable=True) + input_placeholder: Mapped[str | None] = mapped_column(String(300), nullable=True) + footer_text: Mapped[str | None] = mapped_column(String(300), nullable=True) + # --- The 8 identity colors (NULL = the built-in — B1), #rrggbb --- + bg: Mapped[str | None] = mapped_column(String(7), nullable=True) + surface: Mapped[str | None] = mapped_column(String(7), nullable=True) + ink: Mapped[str | None] = mapped_column(String(7), nullable=True) + ink_soft: Mapped[str | None] = mapped_column(String(7), nullable=True) + line: Mapped[str | None] = mapped_column(String(7), nullable=True) + brand: Mapped[str | None] = mapped_column(String(7), nullable=True) + brand_soft: Mapped[str | None] = mapped_column(String(7), nullable=True) + brand_ink: Mapped[str | None] = mapped_column(String(7), nullable=True) diff --git a/app/schemas.py b/app/schemas.py index 3a63337..0c01713 100644 --- a/app/schemas.py +++ b/app/schemas.py @@ -812,3 +812,56 @@ class TokenAuthRequest(BaseModel): """ token: str + + +class UiSettingsIn(BaseModel): + """``PUT /api/ui-settings`` body (phase 91, task 01): a FULL + replacement of the single ``ui_settings`` row. + + Every field is ``str | None`` — present = a new value (strings are + trimmed; empty after the trim is the CLEAR operation, stored as + NULL; colors must be ``#rrggbb`` and are lowercased on store), + ``null``/absent = "back to the default" (stored as NULL — the Reset + button's all-null PUT is exactly the "defaults" operation). The + API layer runs the trim/length/hex validation so the 422 details + name the offending field (the house fixed-detail style); the + built-in→NULL normalization (a color equal to its built-in is + stored as NULL — "save the defaults" must leave the row empty, the + no-op injection contract) happens there too, next to the palette + it normalizes against. + """ + + app_name: str | None = None + input_placeholder: str | None = None + footer_text: str | None = None + bg: str | None = None + surface: str | None = None + ink: str | None = None + ink_soft: str | None = None + line: str | None = None + brand: str | None = None + brand_soft: str | None = None + brand_ink: str | None = None + + +class UiSettingsOut(BaseModel): + """Effective UI settings (``GET``/``PUT /api/ui-settings`` response, + phase 91, task 01). + + All 11 values, all non-null strings: the resolver's + DB-over-env / DB-over-built-in merge (B1), so the tab always shows + the LIVE theme — a fresh (row-missing) deployment reports the env + strings and the built-in palette. + """ + + app_name: str + input_placeholder: str + footer_text: str + bg: str + surface: str + ink: str + ink_soft: str + line: str + brand: str + brand_soft: str + brand_ink: str diff --git a/frontend/assets/brand.js b/frontend/assets/brand.js index ca13094..2bcae32 100644 --- a/frontend/assets/brand.js +++ b/frontend/assets/brand.js @@ -32,9 +32,9 @@ * 4. an attribute pass — the aria-label / placeholder / meta * content attributes containing the literal (the #messages * aria-label, the input label, the meta descriptions); - * 5–7. Phase 62 (owner-locked 2026-09-01, TODO L3) — the SAME - * settled config also carries the three UI-customization - * keys, applied in this same .then, AFTER the app_name + * 5–6. Phase 62 (owner-locked 2026-09-01, TODO L3) — the SAME + * settled config also carries the two UI-customization + * string keys, applied in this same .then, AFTER the app_name * passes and INDEPENDENT of them (they apply even when the * name is the default/empty). Each empty value is a no-op — * an unset deployment stays byte-identical: @@ -43,30 +43,22 @@ * no-ops via the null guard); * 6. footer_text — non-empty → every .footer-text node's * textContent (all 9 pages, the phase-61 hook; an - * operator string can't inject markup via textContent); - * 7. theme — non-empty → a inserted - * IMMEDIATELY AFTER the styles.css link (the theme's - * :root overrides win by cascade order). The styles.css - * finder matches the RAW attribute path with any query/ - * fragment stripped — the phase-33/54 cache-busting - * middleware serves the HTML with the asset refs rewritten - * to "…/styles.css?v=", and el.href (the absolute - * URL) would never end with "styles.css" once versioned. - * The filename is validated server-side (a bare *.css - * name — no path can reach here via /api/config); a - * MISSING file degrades to the built-in theme (onerror → - * console.warn — A5, the page never breaks). Guarded by - * #theme-override: never inserted twice. + * operator string can't inject markup via textContent). + * Phase 91 (task 03): color theming is no longer a brand.js + * job — the retired CSS-file theme link (the old step 7) is + * deleted with its env var; the SERVER now injects the + * effective palette inline before first paint + * (app/core/theming.py), so this layer keeps the text swaps + * only. * • fetch failure / empty name → the default stays + console.warn * (the loadHealth house style: progressive enhancement, the page * never breaks). * * No-op property: with the customization env vars (BOR_APP_NAME, - * BOR_INPUT_PLACEHOLDER, BOR_FOOTER_TEXT, BOR_THEME) unset, /api/config - * answers with the template defaults themselves — the name IS the - * literal, the placeholder and footer are the phase-61 copy (re-setting - * them is invisible), the theme is empty (the link is skipped) — so an - * unset deployment renders byte-identical. + * BOR_INPUT_PLACEHOLDER, BOR_FOOTER_TEXT) unset, /api/config answers + * with the template defaults themselves — the name IS the literal, the + * placeholder and footer are the phase-61 copy (re-setting them is + * invisible) — so an unset deployment renders byte-identical. */ /* The synchronous default — set BEFORE any fetch, so module scripts @@ -187,11 +179,12 @@ function applyBrand() { } // Phase 62 (owner-locked 2026-09-01, TODO L3): the SAME settled - // config also carries the three customization keys — applied here, - // INDEPENDENT of the app_name block above (they apply even when - // the name is the default/empty). Each empty value is a no-op, so - // an unset deployment stays byte-identical (no attribute or - // element touched, no second network call). + // config also carries the two customization STRING keys — applied + // here, INDEPENDENT of the app_name block above (they apply even + // when the name is the default/empty). Each empty value is a + // no-op, so an unset deployment stays byte-identical (no attribute + // or element touched, no second network call). Color theming is + // server-side since phase 91 (task 03) — not this layer's job. const placeholder = typeof cfg?.input_placeholder === "string" ? cfg.input_placeholder : ""; if (placeholder) { @@ -214,43 +207,6 @@ function applyBrand() { el.textContent = footerText; }); } - - const themeName = typeof cfg?.theme === "string" ? cfg.theme : ""; - if (themeName) { - // 7. The theme stylesheet — a inserted IMMEDIATELY AFTER - // the existing styles.css link, so the theme's :root - // overrides win by cascade order. The filename is validated - // server-side (task 01: a bare *.css name) — no path input - // can reach here via /api/config. Guarded by #theme-override: - // never applied twice (the loadHealth house style — never - // break the page, never double-apply). A missing file - // degrades to the built-in theme (A5): the onerror warns, - // nothing else. - if (!document.getElementById("theme-override")) { - // Phase 33/54: the served HTML may carry the cache-bust query - // (?v=) on the asset ref — match on the RAW attribute - // path with query/fragment stripped, never on el.href (the - // absolute URL, which would include the token). - const stylesLink = Array.from( - document.querySelectorAll('link[rel="stylesheet"]'), - ).find((el) => { - const ref = (el.getAttribute("href") || "").split(/[?#]/)[0]; - return ref.endsWith("styles.css"); - }); - if (stylesLink) { - const link = document.createElement("link"); - link.rel = "stylesheet"; - link.href = "/assets/themes/" + themeName; - link.id = "theme-override"; - link.onerror = () => - console.warn( - "brand: theme " + themeName + - " did not load — the built-in theme stands.", - ); - stylesLink.insertAdjacentElement("afterend", link); - } - } - } }); } diff --git a/frontend/assets/header.js b/frontend/assets/header.js index 673cd19..83eca35 100644 --- a/frontend/assets/header.js +++ b/frontend/assets/header.js @@ -219,6 +219,13 @@ export async function initSharedHeader() { // pages) is a no-op. A token user (role "user") never sees it. const navTokens = document.querySelector("#nav-tokens"); if (navTokens) navTokens.hidden = !admin; + // Phase 91 (task 04): the Theme nav link (the shell's seventh view — + // the phase-34 one-bar contract ships it on every page's nav) — + // admin-only, the same ship-hidden / reveal-for-admin contract as + // the Tokens link above. Null-safe: a page without the link is a + // no-op. A token user (role "user") never sees it. + const navTheme = document.querySelector("#nav-theme"); + if (navTheme) navTheme.hidden = !admin; // Phase 34: the steering panel (phase 15) is module-owned. The // navbar #steering-toggle was removed at owner request (2026-08-28) // — the panel ships hidden and is only kept fresh. Admin: refresh diff --git a/frontend/assets/router.js b/frontend/assets/router.js index f358fca..ea3ee3d 100644 --- a/frontend/assets/router.js +++ b/frontend/assets/router.js @@ -1,7 +1,7 @@ /* Brain of Reese — shell router (phase 76, task 01). * - * The five navbar views are views of ONE HTML shell (index.html), not - * five documents: this module makes a navbar click a CLIENT-SIDE view + * The seven navbar views are views of ONE HTML shell (index.html), not + * seven documents: this module makes a navbar click a CLIENT-SIDE view * switch — history.pushState + show/hide — never a document load, so * the in-flight chat stream in the hidden view keeps streaming * through any switch and completes when the user returns to Chat. @@ -62,8 +62,10 @@ * (module — this file). No CDN, no framework, no bundler dependency: * a plain ES module whose dynamic imports (./tuning.js, task 01; * ./sources.js + ./git-sources.js, task 02; ./history.js in task 03; - * ./tokens.js in phase 79 task 06) resolve relatively in dev and are - * inlined by the Containerfile's esbuild stage in the image. + * ./tokens.js in phase 79 task 06; ./theme.js in phase 91 task 04 — + * the wiring lands there, the editor fills it in task 05) resolve + * relatively in dev and are inlined by the Containerfile's esbuild + * stage in the image. */ /* ---------- the view map (pathname → view name) ---------- @@ -79,6 +81,7 @@ const VIEW = { "/git-sources.html": "git-sources", // phase 76 task 02: the Sources view "/history.html": "history", // phase 76 task 03: the History view (saved chats) "/tokens.html": "tokens", // phase 79 task 06: the Tokens view (access tokens) + "/theme.html": "theme", // phase 91 task 04: the Theme view (admin palette + branding) }; /* The nav-link href the router stamps active for each view (the @@ -90,6 +93,7 @@ const VIEW_PATH = { "git-sources": "/git-sources.html", history: "/history.html", tokens: "/tokens.html", + theme: "/theme.html", // phase 91 task 04: the Theme view (admin-only) }; /* The lazy view modules — ONLY the non-chat views (chat needs no @@ -103,6 +107,7 @@ const VIEW_MODULES = { "git-sources": () => import("./git-sources.js"), // phase 76 task 02 history: () => import("./history.js"), // phase 76 task 03 tokens: () => import("./tokens.js"), // phase 79 task 06 + theme: () => import("./theme.js"), // phase 91 task 04 (wiring) + task 05 (editor) }; /* Per-view document.head values, carried over from the old pages' @@ -116,6 +121,7 @@ const TITLES = { "git-sources": "Git sources · Brain of Reese", // old git-sources.html history: "Saved chats · Brain of Reese", // old history.html <title> tokens: "Access tokens · Brain of Reese", + theme: "Theme · Brain of Reese", // phase 91 task 04: no old page — the view is new }; const DESCRIPTIONS = { chat: @@ -128,6 +134,7 @@ const DESCRIPTIONS = { history: "Saved chats — every conversation is saved automatically, one click back.", // old history.html meta tokens: "Generate and revoke the API tokens that let people use the app.", + theme: "Set the palette and branding — the theme is baked into every served page, live on the first paint.", }; /* The brand-resolved display name (phase 39 — brand.js is the single diff --git a/frontend/assets/styles.css b/frontend/assets/styles.css index c4a5e2a..fd1d590 100644 --- a/frontend/assets/styles.css +++ b/frontend/assets/styles.css @@ -2951,6 +2951,185 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; } font-style: italic; } +/* ---------- Theme view (phase 91, tasks 04 + 05) ---------- + The shell's seventh view (#view-theme): the admin palette + branding + editor (task 05). The centered 46rem column (the .tuning-shell + language — this is a form view, the tuning-view width pattern), the + form card (the #tune-form language: surface fill, --line hairline, + radius, shadow), the fieldset groups (Branding / Palette) with the + .theme-note sub-copy, the 3-column (desktop) / 1-column (mobile) + palette grid with the color swatch inputs sized for touch (44px — + the label above is the second tap affordance), and the Save/Reset + row (Save = the brand pill family, --bg ink on --brand 5.2:1 AA; + Reset = the ghost button family, --line border; the §7.4 + disabled-while-in-flight look is the :disabled pair below). The + feedback lines: error/result are the house role=status/alert line + languages; #theme-contrast (the WCAG warning) uses the --err-* + STATE family — states are semantic colors, never themed from the + tab (B3). Every pair reuses the Phase-08 AA palette; :focus-visible + via the global 3px outline rule. No CDN, system fonts. */ +.theme-shell { + max-width: 46rem; + margin-inline: auto; + display: flex; + flex-direction: column; + gap: 1.25rem; + flex: 1; +} +/* The static form — one card holding the two fieldset groups + the + actions row (the #tune-form card language). */ +#theme-form { + display: flex; + flex-direction: column; + gap: 1rem; + background: var(--surface); + border: 1px solid var(--line); + border-radius: var(--radius); + box-shadow: var(--shadow); + padding: 1rem 1.1rem 1.1rem; +} +/* The fieldset groups: reset the UA border (the card IS the group's + frame), the legend rides the group's title line (the + .tuning-panel-title language — ink, 1rem, 700). */ +.theme-group { + margin: 0; + padding: 0; + border: 0; + display: flex; + flex-direction: column; + gap: 0.6rem; +} +.theme-group-title { + padding: 0; + font-size: 1rem; + font-weight: 700; + color: var(--ink); +} +/* The branding text inputs: the #token-label language (surface is + already the card fill — transparent field, --line hairline, ≥44px + target, the global :focus-visible ring). */ +#theme-form fieldset label { + font-size: 0.88rem; + font-weight: 600; + color: var(--ink-soft); +} +#theme-form fieldset input[type="text"] { + width: 100%; + min-height: 44px; + padding: 0.35rem 0.7rem; + border: 1px solid var(--line); + border-radius: var(--radius-sm); + background: transparent; + color: var(--ink); + font: inherit; + font-size: 0.93rem; +} +/* The palette note under the branding legend (task 05): the strings + apply on the next page load (B4) — the live preview covers the + palette only. */ +.theme-note { + margin: 0; + font-size: 0.8rem; + line-height: 1.35; + color: var(--ink-soft); +} +/* The color-input grid (task 05): the 8 swatches in 3 columns + (3 + 3 + 2 rows) — the cell is a .theme-color (label above the + swatch); 1 column at the mobile breakpoint (below). */ +.theme-colors { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 0.6rem 0.9rem; +} +.theme-color { + display: flex; + flex-direction: column; + gap: 0.3rem; + min-width: 0; +} +.theme-color label { + font-size: 0.8rem; + font-weight: 600; + color: var(--ink-soft); + line-height: 1.25; +} +.theme-color input[type="color"] { + /* Touch-sized swatch (task 05): 44px tall (the WCAG 2.5.8 target — + the label above is the second tap affordance). */ + inline-size: 56px; + block-size: 44px; + padding: 3px; + border: 1px solid var(--line); + border-radius: var(--radius-sm); + background: var(--bg); + cursor: pointer; +} +/* Save (primary) + Reset (secondary): the pill + ghost families + (.history-refresh / .token-revoke languages — the global + :focus-visible ring applies, no button-scoped focus override). */ +.theme-actions { + display: flex; + gap: 0.6rem; + margin-top: 0.2rem; +} +#theme-save { + min-height: 44px; + padding: 0.4rem 1.2rem; + border: 0; + border-radius: 999px; + background: var(--brand); + color: var(--bg); /* dark ink on brand: 5.2:1 (never white on brand) */ + font: inherit; + font-weight: 700; + font-size: 0.9rem; + white-space: nowrap; + cursor: pointer; +} +#theme-save:hover:not(:disabled) { background: #f55a72; color: var(--bg); } +#theme-save:disabled { opacity: 0.6; cursor: wait; } +.theme-reset { + min-height: 44px; + padding: 0.4rem 1rem; + border: 1px solid var(--line); + border-radius: 999px; + background: transparent; + color: var(--ink-soft); + font: inherit; + font-weight: 600; + font-size: 0.9rem; + white-space: nowrap; + cursor: pointer; +} +.theme-reset:hover:not(:disabled) { background: var(--brand-soft); color: var(--brand-ink); border-color: var(--brand); } +.theme-reset:disabled { opacity: 0.6; cursor: wait; } +/* The three feedback lines (task 05): error (role=alert — the + server's 422 detail) and result (role=status) are the house line + languages; min-height holds the layout so a line never reflows the + form. #theme-contrast (the WCAG warning, task 05) takes the + .tuning-error box treatment in the --err-* STATE family — states + are semantic colors, never themed from the tab (B3): a failing + pair reads as a warning and the box is warning-only (Save is never + disabled by it). */ +.theme-error, +.theme-result, +.theme-contrast { + display: block; + margin: 0; + min-height: 1.2em; + font-family: var(--mono); + font-size: 0.8rem; + padding-block: 0.25rem; +} +.theme-error { color: var(--err-ink); } +.theme-result { color: var(--ok-ink); } +.theme-contrast { + background: var(--err-bg); + color: var(--err-ink); + border: 1px solid var(--err-line); + border-radius: var(--radius-sm); + padding: 0.45rem 0.8rem; +} + /* ---------- Shared page (phase 51, task 03) ---------- /shared/<token>: the anonymous read-only conversation (owner-locked 2026-08-29, TODO.md L6). The shell maps to the PLAN §7 centered @@ -3979,6 +4158,13 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; } #token-once-copy { width: 100%; } .tokens-actions-cell { white-space: normal; } .tokens-actions { flex-wrap: wrap; } + /* Phase 91 task 05: the Theme form squeezes — the color grid drops + to 1 column (the label + swatch pairs keep their ≥44px targets) + and the actions row stacks (Save full width, Reset under it). */ + .theme-colors { grid-template-columns: 1fr; } + .theme-actions { flex-direction: column; align-items: stretch; } + #theme-save { width: 100%; } + .theme-reset { width: 100%; } /* Phase 51: the shared page squeezes like the chat column — the title and the note step down (the empty-state-title family); the shell keeps its base 46rem column (the >=1500px 92rem override diff --git a/frontend/assets/theme.js b/frontend/assets/theme.js new file mode 100644 index 0000000..2f16f0e --- /dev/null +++ b/frontend/assets/theme.js @@ -0,0 +1,413 @@ +/* Brain of Reese — Theme view module (phase 91, task 05): the admin + * palette + branding editor. + * + * The phase-76 shell-view-module contract (the tuning.js / tokens.js + * shape): the router (assets/router.js) lazy-imports this module on + * FIRST show of #view-theme and calls mount(root) ONCE (mount-once, + * hide-forever — root is the view's <section id="view-theme">, every + * DOM lookup scoped to it). The initSharedHeader() call never happens + * here: in the shell the shared header boots exactly once, via the + * chat module (app.js) at shell boot. + * + * What the editor does: + * + * • gate — fetchIsAdmin() (the SAME cached /api/whoami promise + * header.js exports, zero extra requests): admin hides #theme-gate + * and reveals #theme-content; anonymous / token-user keeps the + * gate (the #nav-theme link is already hidden by header.js — the + * gate is the DIRECT-URL case, the #tokens-gate pattern). No + * /api/ui-settings request is ever made outside the admin branch. + * • load — GET /api/ui-settings → populate the 11 inputs with the + * EFFECTIVE values (the resolver's DB-over-env / DB-over-built-in + * merge): the tab always shows the live theme — env defaults when + * the row is empty. A failed fetch keeps the static form (the + * built-in values ship in the inputs) and shows #theme-error with + * a retry (the loadHealth house style — never a blanked panel). + * • live preview (colors only, B4) — on `input` of any of the 8 + * color pickers the value is written straight onto <html> as an + * inline custom property, so the WHOLE page repaints (every view, + * the header) while the owner is picking. Text fields have NO page + * effect: the 3 strings keep the brand.js runtime application + * (owner-locked B4) — they apply via the /api/config boot fetch on + * the NEXT page load, and the sub-copy says so. On every + * successful save, on Reset, and on a re-show refresh all 8 + * overrides are removed (removeProperty) so the page reflects the + * served (injected) theme, never stale preview state. + * • Save — the §7.4 never-stale lifecycle: disable + "Saving…" → + * PUT /api/ui-settings with the 11 form values (a cleared/empty + * text field → null; colors always their current hex — the + * server's built-in→NULL normalization keeps the row empty when + * the owner saves the defaults) → 200: #theme-result "Theme + * saved." (role=status), refetch + re-populate (canonical state), + * clear the preview overrides, re-check the contrast pairs → + * re-enable + restore the label (the finally — a click can never + * leave a button stuck). 422: #theme-error carries the SERVER + * detail (it names the offending field), the form is KEPT (the + * owner fixes + retries); any other non-2xx: the fixed error line; + * a network error: the "is the app reachable?" line. + * • Reset — the same lifecycle ("Resetting…") with all 11 values + * null (the API's documented "defaults" operation) → #theme-result + * "Reset to the built-in theme." → refetch + re-populate (the + * env/built-in defaults) + clear the preview overrides. + * • WCAG contrast (the 00_phase design's five pairs — the pairs the + * layout actually pairs, see app/core/theming.py's docstring): + * ink on bg, ink on surface, ink-soft on surface, bg on brand + * (the text on brand buttons is the dark background ink — never + * white on brand), brand-ink on surface. Evaluated on every color + * `input` and after every load/save over the CURRENT form values, + * via WCAG relative luminance (sRGB → linear → L). Any pair under + * 4.5:1 is listed in #theme-contrast (role=alert) as "--ink on + * --bg: 3.2:1 — needs 4.5:1"; all pass → the warning hides. + * WARNING-ONLY: it never disables Save (the owner's homelab + * palette — the built-in stays AA, so the default deployment is + * warning-free). + * • re-show — the phase-77 hook: a user-initiated re-show of this + * already-mounted view makes the router dispatch bor:view-refresh + * on the section — re-run the load then (the tab always shows the + * settled server state when re-shown) and clear the preview + * overrides (the page paints the served theme, not a stale pick). + * Armed only in the ADMIN branch, after the whoami gate passes: + * anonymous shows the gate and never fetches. + * + * Every value is rendered with textContent / input.value — this file + * never builds HTML (the XSS-safe-by-construction house rule). + */ + +import { fetchIsAdmin } from "./header.js"; + +export async function mount(root) { + /* ---------- view elements (the view's section, scoped to root) ---------- */ + const gateEl = root.querySelector("#theme-gate"); + const contentEl = root.querySelector("#theme-content"); + const saveBtn = root.querySelector("#theme-save"); + const resetBtn = root.querySelector("#theme-reset"); + const errorEl = root.querySelector("#theme-error"); + const resultEl = root.querySelector("#theme-result"); + const contrastEl = root.querySelector("#theme-contrast"); + + const SAVE_LABEL = "Save theme"; + const RESET_LABEL = "Reset to defaults"; + + /* The 11 form fields, in the form's order: `field` is the API key + (the input's name attribute), `id` the E2E-stable element id, + `kind` how the value is read for a PUT — a string field that is + empty after the trim sends null (the server stores NULL = "use + the default"); a color field always sends its current #rrggbb + (the server's built-in→NULL normalization keeps the row empty + when the owner saves the defaults). */ + const FIELDS = [ + { field: "app_name", id: "theme-app-name", kind: "string" }, + { field: "input_placeholder", id: "theme-placeholder", kind: "string" }, + { field: "footer_text", id: "theme-footer", kind: "string" }, + { field: "bg", id: "theme-bg", kind: "color" }, + { field: "surface", id: "theme-surface", kind: "color" }, + { field: "ink", id: "theme-ink", kind: "color" }, + { field: "ink_soft", id: "theme-ink-soft", kind: "color" }, + { field: "line", id: "theme-line", kind: "color" }, + { field: "brand", id: "theme-brand", kind: "color" }, + { field: "brand_soft", id: "theme-brand-soft", kind: "color" }, + { field: "brand_ink", id: "theme-brand-ink", kind: "color" }, + ]; + + const inputs = {}; + for (const f of FIELDS) inputs[f.field] = root.querySelector("#" + f.id); + + const isHex = (v) => typeof v === "string" && /^#[0-9a-fA-F]{6}$/.test(v); + const cssVar = (field) => "--" + field.replace(/_/g, "-"); + + /* ---------- WCAG contrast (the five pairs) ---------- + * Relative luminance per WCAG 2.1: each sRGB channel is linearized + * (the 0.04045 threshold) then weighted (0.2126 / 0.7152 / 0.0722); + * the ratio is (L_lighter + 0.05) / (L_darker + 0.05). The five + * pairs (foreground, background) are exactly the ones the layout + * pairs — app/core/theming.py's docstring is the authoritative + * table. */ + function channelLuminance(channel) { + const s = channel / 255; + return s <= 0.04045 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4); + } + + function relLuminance(hex) { + const r = parseInt(hex.slice(1, 3), 16); + const g = parseInt(hex.slice(3, 5), 16); + const b = parseInt(hex.slice(5, 7), 16); + return ( + 0.2126 * channelLuminance(r) + + 0.7152 * channelLuminance(g) + + 0.0722 * channelLuminance(b) + ); + } + + function contrastRatio(fgHex, bgHex) { + const a = relLuminance(fgHex); + const b = relLuminance(bgHex); + return (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05); + } + + const AA_MIN = 4.5; // WCAG 2.1 AA for normal-size text (AGENTS.md rule 5) + const PAIRS = [ + ["ink", "bg"], + ["ink", "surface"], + ["ink_soft", "surface"], + ["bg", "brand"], + ["brand_ink", "surface"], + ]; + + /* Re-evaluate the five pairs over the CURRENT form values. Any pair + under 4.5:1 is listed in #theme-contrast (one line per failing + pair, " · "-joined — textContent, never HTML); all pass → the + warning hides. A pair whose input is not a valid hex (defensive — + the color inputs always are) is skipped. WARNING-ONLY: this never + touches Save (the owner can still save a failing palette). */ + function updateContrast() { + if (!contrastEl) return; + const failures = []; + for (const [fg, bg] of PAIRS) { + const fgHex = inputs[fg] ? inputs[fg].value : ""; + const bgHex = inputs[bg] ? inputs[bg].value : ""; + if (!isHex(fgHex) || !isHex(bgHex)) continue; + const ratio = contrastRatio(fgHex, bgHex); + if (ratio < AA_MIN) { + failures.push( + `${cssVar(fg)} on ${cssVar(bg)}: ${ratio.toFixed(1)}:1 — needs 4.5:1`, + ); + } + } + if (failures.length) { + contrastEl.textContent = failures.join(" · "); + contrastEl.hidden = false; + } else { + contrastEl.textContent = ""; + contrastEl.hidden = true; + } + } + + /* ---------- feedback lines (never stale, §7.4) ---------- */ + function clearError() { + if (!errorEl) return; + errorEl.textContent = ""; + errorEl.hidden = true; + } + + function showError(message) { + if (!errorEl) return; + errorEl.textContent = message; // textContent — the server detail is data + errorEl.hidden = false; + } + + function showResult(message) { + if (!resultEl) return; + resultEl.textContent = message; // role=status announces it + resultEl.hidden = false; + } + + /* FastAPI error bodies: a string detail (the house 422s — the detail + names the offending field) or the validation-error array (the + first entry's msg). Same extraction as tuning.js. */ + async function apiDetail(r, fallback) { + try { + const data = await r.json(); + if (Array.isArray(data.detail) && data.detail[0] && data.detail[0].msg) { + return String(data.detail[0].msg); + } + if (typeof data.detail === "string" && data.detail) return data.detail; + } catch { + /* non-JSON error body */ + } + return fallback; + } + + /* ---------- live preview (colors only — B4) ---------- + * The value lands on <html> as an inline custom property: the + * inline style beats the served <style id="bor-theme"> :root, so + * the whole page repaints live while the owner is picking. An empty + * value removes the override (the page falls back to the served + * theme). Text fields write NOTHING here — the strings apply via + * brand.js on the next page load (the sub-copy says so). */ + function previewColor(field, value) { + if (value) { + document.documentElement.style.setProperty(cssVar(field), value); + } else { + document.documentElement.style.removeProperty(cssVar(field)); + } + } + + /* Drop all 8 preview overrides so the page paints the served + (injected) theme — the "never stale" half of the contract: after + a save / reset / re-show the page shows what the server serves, + not a pick that was never (or no longer) saved. */ + function clearPreview() { + for (const f of FIELDS) { + if (f.kind === "color") { + document.documentElement.style.removeProperty(cssVar(f.field)); + } + } + } + + /* ---------- load / populate (effective values) ---------- */ + + function populate(settings) { + for (const f of FIELDS) { + const input = inputs[f.field]; + const value = settings[f.field]; + if (input && typeof value === "string" && value) input.value = value; + } + } + + /* GET /api/ui-settings → populate the 11 inputs with the EFFECTIVE + values (the tab always shows the live theme — env defaults when + the row is empty) and re-check the five pairs (a SAVED palette + can itself fail AA — the warning then tracks it). A failed fetch + keeps the static form + shows #theme-error with a retry (the + loadHealth house style — never a blanked panel). Returns true + when the values are settled. */ + async function loadSettings() { + clearError(); + let r; + try { + r = await fetch("/api/ui-settings"); + } catch { + showError("Couldn't load the theme — is the app reachable?"); + return false; + } + if (!r.ok) { + showError("Couldn't load the theme — try again."); + return false; + } + let settings; + try { + settings = await r.json(); + } catch { + showError("Couldn't load the theme — try again."); + return false; + } + populate(settings); + updateContrast(); + return true; + } + + /* ---------- the PUT (Save + Reset share it) ---------- */ + + function collectBody() { + const body = {}; + for (const f of FIELDS) { + const input = inputs[f.field]; + const value = input ? input.value : ""; + if (f.kind === "string") { + body[f.field] = value.trim() || null; // cleared field → null + } else { + body[f.field] = isHex(value) ? value : null; // colors: their hex + } + } + return body; + } + + function setBusy(busy) { + if (saveBtn) saveBtn.disabled = busy; + if (resetBtn) resetBtn.disabled = busy; + } + + /* One action at a time: both buttons are disabled while either PUT + is in flight (a double-fire would race the row). */ + async function putSettings(body, busyLabel, button) { + setBusy(true); + if (button) button.textContent = busyLabel; + clearError(); + try { + const r = await fetch("/api/ui-settings", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + if (r.ok) { + return { ok: true }; + } + /* 422 → the server detail (it names the field); any other + non-2xx → the fixed line. The form is KEPT either way. */ + const detail = + r.status === 422 + ? await apiDetail(r, "Couldn't save the theme — try again.") + : "Couldn't save the theme — try again."; + return { ok: false, detail }; + } catch { + return { + ok: false, + detail: "Couldn't save the theme — is the app reachable?", + }; + } finally { + setBusy(false); + if (button) button.textContent = button === saveBtn ? SAVE_LABEL : RESET_LABEL; + } + } + + async function saveTheme() { + const outcome = await putSettings(collectBody(), "Saving…", saveBtn); + if (!outcome.ok) { + showError(outcome.detail); + return; + } + showResult("Theme saved."); // role=status + await loadSettings(); // refetch + re-populate (canonical state) + clearPreview(); // the page paints the served theme, not the pick + } + + async function resetTheme() { + const body = {}; + for (const f of FIELDS) body[f.field] = null; // all null = the defaults + const outcome = await putSettings(body, "Resetting…", resetBtn); + if (!outcome.ok) { + showError(outcome.detail); + return; + } + showResult("Reset to the built-in theme."); // role=status + await loadSettings(); // the env / built-in defaults, re-rendered + clearPreview(); // the page paints the served theme again + } + + /* ---------- view boot (phase 91 task 05) ---------- + * The shared header is NOT booted here — in the shell it runs + * exactly once, via the chat module (app.js) at shell boot. The + * gate reads fetchIsAdmin() — the SAME cached whoami promise the + * header uses (zero extra requests). Anonymous / token-user: the + * gate in, the content out — and NO /api/ui-settings request at all + * (the router 403s them — the #tokens-gate contract). */ + if (!(await fetchIsAdmin())) { + if (gateEl) gateEl.hidden = false; + if (contentEl) contentEl.hidden = true; + return; + } + if (gateEl) gateEl.hidden = true; + if (contentEl) contentEl.hidden = false; + + /* Bindings — armed BEFORE the first load: a fast owner can start + picking while the GET is still out; the preview writes are + idempotent and the settled load re-populates afterwards. Color + inputs drive the live preview + the contrast re-check; text + inputs drive neither (B4 — the strings apply on the next page + load, the sub-copy says so). */ + for (const f of FIELDS) { + const input = inputs[f.field]; + if (!input || f.kind !== "color") continue; + input.addEventListener("input", () => { + previewColor(f.field, input.value); + updateContrast(); + }); + } + if (saveBtn) saveBtn.addEventListener("click", () => void saveTheme()); + if (resetBtn) resetBtn.addEventListener("click", () => void resetTheme()); + + /* Phase 77 (the re-show refresh contract): a user-initiated re-show + of this already-mounted view makes the router dispatch + bor:view-refresh on the section — re-run the load then (the tab + always shows the settled server state when re-shown) and drop the + preview overrides (the page paints the served theme, not a pick + left behind from before the switch). Armed ONLY here, after the + whoami gate passed: anonymous shows the gate and never fetches. */ + root.addEventListener("bor:view-refresh", () => { + void loadSettings().then((settled) => { + if (settled) clearPreview(); + }); + }); + + await loadSettings(); // the effective values — the live theme +} diff --git a/frontend/assets/themes/README.md b/frontend/assets/themes/README.md deleted file mode 100644 index 4e6ab13..0000000 --- a/frontend/assets/themes/README.md +++ /dev/null @@ -1,77 +0,0 @@ -# Themes — authoring guide (phase 62) - -A theme is a small CSS file that overrides the `:root` palette variables. -That is the entire mechanism — no component CSS is theme-aware, every -color in the app reads a `--*` variable, so a later stylesheet wins by -cascade order. - -## How a theme loads - -1. Set `BOR_THEME=<file>` (a bare FILENAME, e.g. `BOR_THEME=indigo.css`). - `app/config.py` validates it at startup — anything not matching - `^[a-z0-9_-]+\.css$` (a path, `..`, uppercase, a missing extension) - refuses to boot, naming the value (the phase-56 fail-loud house - style). -2. The value rides the existing boot fetch: `GET /api/config` → - `frontend/assets/brand.js` inserts - `<link rel="stylesheet" href="/assets/themes/<file>">` IMMEDIATELY - AFTER the `styles.css` link — later wins the cascade. -3. A theme file MISSING at runtime (typo past the validator, or the file - deleted after the image was built) degrades to the built-in theme — - `brand.js` warns in the console, the page never breaks (the - loadHealth house style, A5). -4. UNSET (`BOR_THEME` empty) ⇒ no link is inserted at all — the - deployment renders byte-identical to the built-in dark-tech palette. - Loading is opt-in via the env var, never by directory scanning. - -## The variables - -A theme overrides the **8 identity variables** in a single `:root` block. -Built-in values (from `frontend/assets/styles.css`) for reference: - -| Variable | Built-in | Role | -| -------------- | ---------- | ----------------------------------------------------------- | -| `--bg` | `#0f0a0a` | page background (text on it: `--ink`) | -| `--surface` | `#1a0f0f` | cards, panels, code blocks (text on it: `--ink`) | -| `--ink` | `#f0e6e6` | primary text | -| `--ink-soft` | `#b8a8a8` | secondary text (5.1:1 on `--surface`) | -| `--line` | `#2d1a1a` | decorative 1px borders (no contrast obligation) | -| `--brand` | `#f43f5e` | brand accent — buttons, links (text ON it is `--bg`) | -| `--brand-soft` | `#2d0a0a` | brand-tinted surface (chips, hover washes) | -| `--brand-ink` | `#fca5a5` | brand-tinted text (9.0:1 on `--surface`) | - -The **semantic families are deliberately NOT identity** — do not -override them: `--accent-*` (deflection amber), `--ok-*` (success -green), `--err-*` (error red) encode *states*, and they are already AA -in the built-in theme. A theme that keeps them stays honest: your -indigo app still tells success from error. - -## Rules - -- **Filename:** `^[a-z0-9_-]+\.css$` — lowercase, bare filename, in this - directory. The server validator rejects anything else at startup - (naming the value), so keep the env var and the filename in lockstep. -- **One `:root` block.** No selectors, no `@media`, no other - declarations — the file overrides variables and nothing else (the - cascade does the rest). `indigo.css` is the reference shape. -- **Every text/background pair ≥ 4.5:1** (AGENTS.md rule 5, WCAG 2.1 - AA). The pairs that matter: `--ink` on `--bg` and on `--surface`, - `--ink-soft` on `--surface`, `--bg` on `--brand` (the text on brand - buttons is the DARK background ink — that is the pattern), and - `--brand-ink` on `--surface`. -- **Never white-on-brand.** The built-in documents the trap: white on - `#f43f5e` is 3.7:1 — it fails. Pick a `--brand` whose luminance - carries the dark `--bg` ink at ≥ 4.5:1 (indigo.css: 6.5:1). -- Keep `--line` close to `--surface` (a 1px step, not a wall) — the - layout reads by surfaces, not borders. - -## Deployment - -- **Dev:** works immediately — the file is served from the static dir - (`frontend/`, `BOR_STATIC_DIR`), so drop the file in, set - `BOR_THEME`, restart uvicorn. -- **Container:** rebuild the image. Stage 1 ships the WHOLE directory - (`cp -r ./assets/themes /out/assets/themes` — no per-file esbuild), so - a new or edited theme file needs **no Containerfile change** (A7): - whatever is in `frontend/assets/themes/` at build time is what the - image serves at `/assets/themes/…`. diff --git a/frontend/assets/themes/indigo.css b/frontend/assets/themes/indigo.css deleted file mode 100644 index a4ae752..0000000 --- a/frontend/assets/themes/indigo.css +++ /dev/null @@ -1,17 +0,0 @@ -/* Phase 62 example theme — dark indigo/slate. - Overrides the :root identity palette from styles.css; every - text/background pair meets WCAG 2.1 AA (>= 4.5:1): - ink on bg 15.8:1 · ink on surface 14.7:1 · ink-soft on surface 8.3:1 - dark bg ink on brand 6.5:1 · brand-ink on surface 12.0:1. - Semantic families (accent/ok/err) inherit the built-in theme. - Load with BOR_THEME=indigo.css (authoring guide: themes/README.md). */ -:root { - --bg: #0a0e1a; - --surface: #111726; - --ink: #e6e9f0; - --ink-soft: #a8b0c8; - --line: #232c44; - --brand: #818cf8; - --brand-soft: #1a1f38; - --brand-ink: #c7d2fe; -} diff --git a/frontend/document.html b/frontend/document.html index 1ed7474..cb5456f 100644 --- a/frontend/document.html +++ b/frontend/document.html @@ -65,6 +65,13 @@ page — test_nav_consistency pins the inventory parity). Null-safe: header.js is a no-op on a page without it. --> <a href="/tokens.html" class="nav-link" id="nav-tokens" hidden>Tokens</a> + <!-- Phase 91 (task 04): the Theme link is admin-only — hidden + by default, header.js reveals it once whoami says admin, + exactly like the Tokens link above (the phase-34 one-bar + contract: the SAME nav ships on every page — + test_nav_consistency pins the inventory parity). + Null-safe: header.js is a no-op on a page without it. --> + <a href="/theme.html" class="nav-link" id="nav-theme" hidden>Theme</a> <!-- Phase 46 (mobile dropdown copy: sign-in — desktop bar copy is outside the nav; see styles.css .sign-in-mobile rules). --> <a href="/login.html?next=/" class="auth-link sign-in-link sign-in-mobile" id="sign-in-link-mobile" hidden> diff --git a/frontend/index.html b/frontend/index.html index 68f3aff..a37d9d0 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -54,6 +54,12 @@ view). No mobile dropdown copy is needed: the link lives in the SAME #app-nav element the hamburger opens. --> <a href="/tokens.html" class="nav-link" id="nav-tokens" hidden>Tokens</a> + <!-- Phase 91 (task 04): the Theme link is admin-only — hidden + by default, header.js reveals it once whoami says admin, + exactly like the Tokens link above (the shell's seventh + view). No mobile dropdown copy is needed: the link lives + in the SAME #app-nav element the hamburger opens. --> + <a href="/theme.html" class="nav-link" id="nav-theme" hidden>Theme</a> <!-- Phase 46 (mobile dropdown copy: sign-in — desktop bar copy is outside the nav; see styles.css .sign-in-mobile rules). --> <a href="/login.html?next=/" class="auth-link sign-in-link sign-in-mobile" id="sign-in-link-mobile" hidden> @@ -943,6 +949,177 @@ </div> </section> + <!-- Phase 91 (task 04): the Theme view — the shell's seventh + folded view (the phase-76 fold pattern, the phase-79 Tokens + view as the most recent precedent): the admin-only palette + + branding editor. /theme.html serves THIS document (the shell + route in app/main.py); the router shows this section for that + pathname. The CSS-file theming (BOR_THEME, phase 62) is + retired (task 03): the effective theme is injected into every + served page's <head> server-side (app/core/caching.py + + app/core/theming.py), so it paints on the FIRST paint — no + red flash, no pop-in. SHIPS hidden (anonymous-safe — the gate + is what anonymous sees; theme.js reveals the content for + admin only). The hidden + inert pair is the WCAG contract: + a hidden view must not receive focus or keyboard traversal + (AGENTS.md rule 5). Mounted lazily — assets/router.js imports + theme.js on first show only (mount-once, hide-forever; the + editor lands in task 05). The form is STATIC markup (the + E2E-stable-selectors house convention) — the onsubmit + binding + live preview + Save/Reset lifecycle land in task 05 + (theme.js); there is no real submit (every button is + type="button"). --> + <section class="view" id="view-theme" hidden inert aria-label="Theme" tabindex="-1"> + <div class="container theme-shell"> + <!-- Phase 91 (task 04): anonymous sign-in gate — the EXACT + #sources-gate pattern (phase 16) and the same .sources-gate + visual language: the palette + branding is what the login + locks (B5, owner-locked 2026-09-09). Visible for + anonymous, hidden for the admin (theme.js). The gate's + Sign in returns to the Theme view (the header's ?next= + convention; the static href is the no-JS fallback). --> + <section class="sources-gate" id="theme-gate" aria-labelledby="theme-gate-title" hidden> + <div class="sources-gate-glyph" aria-hidden="true"> + <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="10" width="16" height="10" rx="2"/><path d="M8 10V7a4 4 0 0 1 8 0v3"/><circle cx="12" cy="14.5" r="1.4" fill="currentColor" stroke="none"/><path d="M12 16v2"/></svg> + </div> + <h2 id="theme-gate-title">Sign in to change the theme</h2> + <p class="sources-gate-sub"> + The palette and branding are admin-only. Chat — and any + document an answer cites — stays open to everyone. + </p> + <a class="sources-gate-link" href="/login.html?next=/theme.html">Sign in</a> + </section> + + <!-- Phase 91 (task 04): the editor — SHIPS hidden + (anonymous-safe; the gate is what anonymous sees). theme.js + reveals it once the cached whoami says admin (the + #git-sources-content pattern). --> + <div id="theme-content" hidden> + <div class="page-head"> + <h1>Theme</h1> + <p class="page-sub"> + Changes preview live as you pick; + <strong>Save theme</strong> bakes the palette into every + page it is served on — it applies on the first paint, no + pop-in. + </p> + </div> + + <!-- Static form skeleton (E2E-stable selectors; task 05 wires + the bindings — effective-value populate, the live preview, + the §7.4 Save/Reset lifecycle, the WCAG contrast warnings + in #theme-contrast). No real submit: both buttons are + type="button"; maxlength=300 mirrors the server's 300-char + limit (the server re-validates — 422 naming the field). + The color inputs ship the BUILT-IN values (app/core/ + theming.py BUILTIN_COLORS) — task 05 re-populates them + with the EFFECTIVE values on mount. --> + <form id="theme-form" novalidate> + <fieldset class="theme-group"> + <legend class="theme-group-title">Branding</legend> + <!-- B4 (owner-locked): the strings keep the brand.js + runtime application — they apply via the /api/config + boot fetch on the NEXT page load; the live preview + covers the palette only. An empty field restores the + default (the env value). --> + <p class="theme-note"> + These strings apply on the next page load — the live + preview covers the palette only. Leaving a field empty + restores its default. + </p> + <label for="theme-app-name">App name</label> + <input + id="theme-app-name" + name="app_name" + type="text" + maxlength="300" + autocomplete="off" + > + <label for="theme-placeholder">Chat input placeholder</label> + <input + id="theme-placeholder" + name="input_placeholder" + type="text" + maxlength="300" + autocomplete="off" + > + <label for="theme-footer">Footer line</label> + <input + id="theme-footer" + name="footer_text" + type="text" + maxlength="300" + autocomplete="off" + > + </fieldset> + + <fieldset class="theme-group"> + <!-- The five text/background pairs are checked against + WCAG 2.1 AA (4.5:1) as the owner picks (theme.js — + the app/core/theming.py docstring is the authoritative + pair table); failures list in #theme-contrast as a + warning and never block a save. --> + <legend class="theme-group-title"> + Palette — five pairs checked against WCAG 2.1 AA (4.5:1) + </legend> + <div class="theme-colors"> + <div class="theme-color"> + <label for="theme-bg">Background (--bg)</label> + <input id="theme-bg" name="bg" type="color" value="#0f0a0a"> + </div> + <div class="theme-color"> + <label for="theme-surface">Surface (--surface)</label> + <input id="theme-surface" name="surface" type="color" value="#1a0f0f"> + </div> + <div class="theme-color"> + <label for="theme-ink">Text (--ink)</label> + <input id="theme-ink" name="ink" type="color" value="#f0e6e6"> + </div> + <div class="theme-color"> + <label for="theme-ink-soft">Secondary text (--ink-soft)</label> + <input id="theme-ink-soft" name="ink_soft" type="color" value="#b8a8a8"> + </div> + <div class="theme-color"> + <label for="theme-line">Border (--line)</label> + <input id="theme-line" name="line" type="color" value="#2d1a1a"> + </div> + <div class="theme-color"> + <label for="theme-brand">Brand accent (--brand) — buttons, links</label> + <input id="theme-brand" name="brand" type="color" value="#f43f5e"> + </div> + <div class="theme-color"> + <label for="theme-brand-soft">Brand tint (--brand-soft)</label> + <input id="theme-brand-soft" name="brand_soft" type="color" value="#2d0a0a"> + </div> + <div class="theme-color"> + <label for="theme-brand-ink">Brand text (--brand-ink)</label> + <input id="theme-brand-ink" name="brand_ink" type="color" value="#fca5a5"> + </div> + </div> + </fieldset> + + <!-- Save (primary) + Reset to defaults (secondary). Both + type="button" (no real submit); task 05 runs the §7.4 + never-stale lifecycle ("Saving…" while the PUT is out, + re-enabled on success AND failure). --> + <div class="theme-actions"> + <button type="button" id="theme-save">Save theme</button> + <button type="button" class="theme-reset" id="theme-reset">Reset to defaults</button> + </div> + </form> + + <!-- task 05 owns all three lines: #theme-error (the server's + 422 detail — the fields are kept on failure), #theme-result + ("Theme saved." / "Reset to the built-in theme."), + #theme-contrast (the WCAG warnings for the five pairs — + warning-only, the owner can still save). --> + <p class="theme-error" id="theme-error" role="alert" hidden></p> + <p class="theme-result" id="theme-result" role="status" aria-live="polite" hidden></p> + <p class="theme-contrast" id="theme-contrast" role="alert" hidden></p> + </div> + </div> + </section> + </main> <!-- Phase 79 (task 05): the in-app token gate — a body-level diff --git a/frontend/login.html b/frontend/login.html index d3a4170..9c3fd38 100644 --- a/frontend/login.html +++ b/frontend/login.html @@ -58,6 +58,13 @@ page — test_nav_consistency pins the inventory parity). Null-safe: header.js is a no-op on a page without it. --> <a href="/tokens.html" class="nav-link" id="nav-tokens" hidden>Tokens</a> + <!-- Phase 91 (task 04): the Theme link is admin-only — hidden + by default, header.js reveals it once whoami says admin, + exactly like the Tokens link above (the phase-34 one-bar + contract: the SAME nav ships on every page — + test_nav_consistency pins the inventory parity). + Null-safe: header.js is a no-op on a page without it. --> + <a href="/theme.html" class="nav-link" id="nav-theme" hidden>Theme</a> <!-- Phase 46 (mobile dropdown copy: sign-in — desktop bar copy is outside the nav; see styles.css .sign-in-mobile rules). --> <a href="/login.html?next=/" class="auth-link sign-in-link sign-in-mobile" id="sign-in-link-mobile" hidden> diff --git a/frontend/shared.html b/frontend/shared.html index 44937a1..251de4e 100644 --- a/frontend/shared.html +++ b/frontend/shared.html @@ -59,6 +59,13 @@ page — test_nav_consistency pins the inventory parity). Null-safe: header.js is a no-op on a page without it. --> <a href="/tokens.html" class="nav-link" id="nav-tokens" hidden>Tokens</a> + <!-- Phase 91 (task 04): the Theme link is admin-only — hidden + by default, header.js reveals it once whoami says admin, + exactly like the Tokens link above (the phase-34 one-bar + contract: the SAME nav ships on every page — + test_nav_consistency pins the inventory parity). + Null-safe: header.js is a no-op on a page without it. --> + <a href="/theme.html" class="nav-link" id="nav-theme" hidden>Theme</a> <!-- Phase 46 (mobile dropdown copy: sign-in — desktop bar copy is outside the nav; see styles.css .sign-in-mobile rules). --> <a href="/login.html?next=/" class="auth-link sign-in-link sign-in-mobile" id="sign-in-link-mobile" hidden> diff --git a/tests/conftest.py b/tests/conftest.py index 7f22eb4..d31abbd 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -40,12 +40,13 @@ os.environ["BOR_SUGGESTIONS"] = json.dumps(_Settings.model_fields["suggestions"] # Phase 62: the same leak class for the new UI customization settings — # an operator's local ``.env`` may legitimately carry -# ``BOR_INPUT_PLACEHOLDER`` / ``BOR_FOOTER_TEXT`` / ``BOR_THEME``, and -# the default-metadata pins must see the code defaults (derived from +# ``BOR_INPUT_PLACEHOLDER`` / ``BOR_FOOTER_TEXT``, and the +# default-metadata pins must see the code defaults (derived from # the class fields, same pattern as the suggestions line above). +# (Phase 91, task 03: the retired CSS-file theme env var no longer +# exists — nothing to pin.) os.environ["BOR_INPUT_PLACEHOLDER"] = _Settings.model_fields["input_placeholder"].default os.environ["BOR_FOOTER_TEXT"] = _Settings.model_fields["footer_text"].default -os.environ["BOR_THEME"] = _Settings.model_fields["theme"].default from app.db import SessionLocal, db_available # noqa: E402 from app.main import app as fastapi_app # noqa: E402 diff --git a/tests/e2e/conftest.py b/tests/e2e/conftest.py index 4b825d2..6cf0f94 100644 --- a/tests/e2e/conftest.py +++ b/tests/e2e/conftest.py @@ -123,14 +123,13 @@ def app_server(mock_llm: int) -> Iterator[str]: ) # Phase 62: the same leak class for the new UI customization # settings — an operator's local (gitignored) ``.env`` may - # legitimately carry ``BOR_INPUT_PLACEHOLDER`` / ``BOR_FOOTER_TEXT`` - # / ``BOR_THEME``, and the byte-identical default contract (task - # 05's ``test_default_server_is_byte_identical``) must see the code - # defaults (derived from the class fields, never drifts from - # ``app/config.py``). + # legitimately carry ``BOR_INPUT_PLACEHOLDER`` / + # ``BOR_FOOTER_TEXT``, and the byte-identical default contract + # must see the code defaults (derived from the class fields, never + # drifts from ``app/config.py``). (Phase 91, task 03: the retired + # CSS-file theme env var no longer exists — nothing to pin.) env["BOR_INPUT_PLACEHOLDER"] = _Settings.model_fields["input_placeholder"].default env["BOR_FOOTER_TEXT"] = _Settings.model_fields["footer_text"].default - env["BOR_THEME"] = _Settings.model_fields["theme"].default proc = subprocess.Popen( [sys.executable, "-m", "uvicorn", "app.main:app", "--host", "127.0.0.1", "--port", str(APP_PORT), "--log-level", "warning"], diff --git a/tests/e2e/test_admin_theme_tab.py b/tests/e2e/test_admin_theme_tab.py new file mode 100644 index 0000000..d4e1cb7 --- /dev/null +++ b/tests/e2e/test_admin_theme_tab.py @@ -0,0 +1,733 @@ +"""Phase 91 E2E (Playwright): the admin Theme tab — the pickers and +fields, the pre-paint theme, the admin gate, and the reset. + +Source: ``TODO.md`` L4 — "Custom theming isn't really working. The +page loads red first and then the theme 'pops' into view, replacing +words and colors in an obvious way. Remove the custom css file +theming. Create a new admin tab that allows the user to change +everything the env var and custom css currently supports but with +buttons and color pickers. Theme should load immediately, not pop in +after the page load." + +Run in isolation (DB must be up: ``podman compose up -d db``): + + uv run pytest tests/e2e/test_admin_theme_tab.py -v --no-cov + +Test → contract mapping (one story, one phase, one isolated file): + +1. ``test_theme_tab_admin_save`` — "buttons and color pickers": the + admin sees the "Theme" nav link and the form (gate hidden); the + 11 inputs show the effective defaults (the 3 template strings + + the 8 built-in hexes parsed out of ``styles.css``'s ``:root`` + IN-TEST — the suite can never drift from the stylesheet); Save + runs the §7.4 lifecycle (disabled + "Saving…" while the PUT is + held, then restored) and lands the role=status "Theme saved."; + the inputs re-populate to the saved values; the persisted row is + the one saved; and the saved non-AA palette lists its failing + pair in ``#theme-contrast`` without blocking the save + (warning-only). +2. ``test_saved_theme_is_pre_paint_for_everyone`` — "the theme + should load immediately, not pop in": after a save, the RAW + served HTML of ``/`` carries exactly one ``<style + id="bor-theme">`` with all 8 vars = the saved hexes, placed + IMMEDIATELY before ``</head>`` — for the admin AND a fresh + anonymous context — and the computed ``:root`` custom properties + equal the saved hexes at load (the inline tag precedes every + stylesheet application). The 3 strings stay on the brand.js boot + fetch (the B4 split: colors pre-paint, strings via the fetch). +3. ``test_anonymous_and_token_user_are_walled`` — the admin gate: + anonymous meets ``#theme-gate`` (sign-in link + ``?next=/theme.html``) with ``#theme-content`` hidden and the + nav link hidden, and ``PUT /api/ui-settings`` 403s; a token user + (the phase-79 gate login) 403s the PUT too and never sees the + nav link (B5: admin-only, like Tuning/Tokens). +4. ``test_reset_restores_the_builtin_byte_identical`` — "reset": + Reset to defaults runs the §7.4 lifecycle ("Resetting…"), lands + the role=status "Reset to the built-in theme.", re-populates the + 11 defaults, serves NO theme tag, and the served bytes equal a + row-less deployment byte for byte (the no-op injection + contract). +5. ``test_contrast_warning_does_not_block`` — the WCAG warnings: + ``--ink`` set within 0.1 ratio of ``--bg`` lists the failing + pair(s) with the ratio in ``#theme-contrast`` (role=alert) as + soon as the picker moves; Save still succeeds (warning-only); + Reset restores the AA built-ins and hides the warning (the + suite's final state is clean). + +DB isolation: the shared e2e Postgres keeps ``ui_settings`` (the +single row the caching middleware reads for EVERY served page — a +leftover themed row would repaint other suites' pages) and +``api_tokens`` rows across suites. An autouse fixture truncates +``ui_settings`` and deletes the ``e2e-``-labeled tokens before AND +after every test (never a TRUNCATE on ``api_tokens`` — the shared +DB may hold the owner's real tokens). + +Per-module app env (the tuning/tokens/archive-upload pattern): the +module-scoped ``app_server`` override boots the same env block as +the shared conftest server with the branding vars pinned to the +CODE defaults (an operator's local ``.env`` may carry the owner's +name/placeholder/footer, and "the effective strings start at the +template defaults" must hold regardless — the phase-61/62 +leak-guard pattern, extended to ``BOR_APP_NAME``) and +``BOR_GIT_SOURCES`` forced empty (the dev ``.env``'s git repo must +not render as env rows in this suite's app). +""" +from __future__ import annotations + +import json +import os +import re +import subprocess +import sys +import time +from collections.abc import Iterator + +import httpx +import pytest +from playwright.sync_api import Browser, BrowserContext, Page, Route, expect +from sqlalchemy import text + +from app.config import Settings +from app.core.theming import COLOR_FIELDS +from app.db import SessionLocal +from app.models import UiSettings +from e2e.auth_helpers import login, login_with_token +from e2e.conftest import ( + ADMIN_PASSWORD, + APP_PORT, + REPO, + SESSION_SECRET, + USE_REAL_LLM, + _wait_http, +) + +APP_URL = f"http://127.0.0.1:{APP_PORT}" + +# The distinct E2E palette (task 06): a full non-built-in indigo set — +# every value differs from its built-in, so the tag is non-empty and +# every saved color is stored as-is (no built-in→NULL collapse). +PALETTE: dict[str, str] = { + "bg": "#0b1020", + "surface": "#111730", + "ink": "#e6e9f5", + "ink_soft": "#a8b0d0", + "line": "#232a4a", + "brand": "#4f46e5", + "brand_soft": "#1e2447", + "brand_ink": "#c7d2fe", +} +APP_NAME = "Theme E2E" +PLACEHOLDER = "Ask the themed brain…" +FOOTER = "E2E footer" +SAVED_STRINGS: dict[str, str] = { + "app_name": APP_NAME, + "input_placeholder": PLACEHOLDER, + "footer_text": FOOTER, +} + +#: The failing-pair leg (test 5): --ink set within 0.1 ratio of --bg +#: (the deterministic near-identical pick — 1.0:1 on both dark pairs). +FAILING_INK = "#101010" + +#: The E2E-stable color-input ids, in COLOR_FIELDS order (the form's +#: own markup — the static E2E-stable-selectors house convention). +COLOR_INPUT_IDS: dict[str, str] = { + field: f"#theme-{field.replace('_', '-')}" for field in COLOR_FIELDS +} + + +# --------------------------------------------------------------------------- +# In-test constants (single sources of truth — never duplicated) +# --------------------------------------------------------------------------- + + +def _builtin_colors() -> dict[str, str]: + """The 8 built-in identity hexes parsed OUT of + ``frontend/assets/styles.css``'s ``:root`` in-test — the single + source of truth, so the suite can't drift from the stylesheet it + asserts on.""" + css = (REPO / "frontend" / "assets" / "styles.css").read_text(encoding="utf-8") + root = re.search(r":root\s*\{([^}]*)\}", css, re.DOTALL) + assert root is not None, "styles.css must open with its :root block" + colors: dict[str, str] = {} + for name in COLOR_FIELDS: + match = re.search( + rf"--{name.replace('_', '-')}\s*:\s*(#[0-9a-fA-F]{{6}})", root.group(1) + ) + assert match is not None, f"--{name} missing from styles.css :root" + colors[name] = match.group(1).lower() + return colors + + +def _template_defaults() -> dict[str, str]: + """The 3 template strings from the CODE defaults (derived from + the class fields — never drifts from ``app/config.py``; the + module server pins the same values, so the effective strings + start exactly here).""" + return { + "app_name": Settings.model_fields["app_name"].default, + "input_placeholder": Settings.model_fields["input_placeholder"].default, + "footer_text": Settings.model_fields["footer_text"].default, + } + + +def _expected_tag(colors: dict[str, str]) -> str: + """The EXACT inline tag ``theme_style_tag`` renders for + ``colors``: one ``:root`` override, all 8 vars in COLOR_FIELDS + order, no whitespace (the byte the middleware injects).""" + declarations = "".join(f"--{k.replace('_', '-')}:{colors[k]};" for k in COLOR_FIELDS) + return f'<style id="bor-theme">:root{{{declarations}}}</style>' + + +# --------------------------------------------------------------------------- +# Per-module app env (the tuning/tokens/archive-upload pattern) +# --------------------------------------------------------------------------- + + +@pytest.fixture(scope="module") +def app_server(mock_llm: int) -> Iterator[str]: + """The real app under test — per-module env: the branding vars + are pinned to the CODE defaults (the effective strings start at + the template defaults regardless of an operator's local + ``.env`` — the phase-61/62 leak-guard pattern the shared conftest + server applies to its two string vars; this one pins all three, + including ``BOR_APP_NAME``, which the shared server leaves to the + process) and ``BOR_GIT_SOURCES`` is forced empty (the dev + ``.env``'s git repo must not render as env rows in this + suite's app).""" + env = dict(os.environ) + env.pop("DEBUGPY", None) + env["BOR_ENVIRONMENT"] = "e2e" + env["BOR_STATIC_DIR"] = str(REPO / "frontend") + env["BOR_LLM_BASE_URL"] = ( + "https://aipi.reeseapps.com/v1" + if USE_REAL_LLM + else f"http://127.0.0.1:{mock_llm}/v1" + ) + # Mock-calibrated threshold (conftest pattern) — no chat turn is + # ever sent in this suite, but the app boots with the same shape. + env["BOR_RELEVANCE_THRESHOLD"] = "0.30" + env["BOR_LLM_RETRY_DELAY"] = "0" + env["BOR_LLM_RETRIES"] = str(Settings.model_fields["llm_retries"].default) + env.setdefault( + "BOR_DATABASE_URL", + "postgresql+psycopg://reese:reese@localhost:5432/brain_of_reese", + ) + # Phase 16: admin auth must be set or create_app() refuses to boot. + env["BOR_ADMIN_PASSWORD"] = ADMIN_PASSWORD + env["BOR_SESSION_SECRET"] = SESSION_SECRET + env["BOR_DOCS_REPO"] = "" + env["BOR_SUGGESTIONS"] = json.dumps( + Settings.model_fields["suggestions"].default + ) + # The branding vars: "unset" = the template defaults (the code + # defaults, derived from the class fields — the local ``.env`` may + # carry the owner's values, and this suite's assertions need the + # TEMPLATE defaults, not the owner's). + env["BOR_APP_NAME"] = Settings.model_fields["app_name"].default + env["BOR_INPUT_PLACEHOLDER"] = ( + Settings.model_fields["input_placeholder"].default + ) + env["BOR_FOOTER_TEXT"] = Settings.model_fields["footer_text"].default + env["BOR_GIT_SOURCES"] = "" + proc = subprocess.Popen( + [sys.executable, "-m", "uvicorn", "app.main:app", + "--host", "127.0.0.1", "--port", str(APP_PORT), "--log-level", "warning"], + cwd=REPO, + env=env, + ) + try: + _wait_http(f"{APP_URL}/api/health") + yield APP_URL + finally: + proc.terminate() + try: + proc.wait(timeout=10) + except subprocess.TimeoutExpired: + proc.kill() + + +@pytest.fixture(scope="module") +def app_url(app_server: str) -> str: + return app_server + + +# --------------------------------------------------------------------------- +# DB isolation + helpers +# --------------------------------------------------------------------------- + + +def _clean_ui_state() -> None: + """Fresh theme + token state per test: truncate the single-row + ``ui_settings`` (the middleware reads it for EVERY page — a + leftover themed row would repaint other suites' pages) and + delete this suite's issued tokens (label-scoped on ``e2e-`` — + never a TRUNCATE: the shared DB may hold the owner's real + tokens).""" + with SessionLocal() as db: + db.execute(text("TRUNCATE ui_settings")) + db.execute(text("DELETE FROM api_tokens WHERE label LIKE 'e2e-%'")) + db.commit() + + +@pytest.fixture(autouse=True) +def _clean(db_ready: None) -> Iterator[None]: + _clean_ui_state() + yield + _clean_ui_state() + + +def _cookies(page: Page) -> dict[str, str]: + """The session cookies the browser context holds (the test's API + side sees exactly what that browser sees).""" + return { + c["name"]: c["value"] + for c in page.context.cookies() + if "name" in c and "value" in c + } + + +def _seed_theme_via_api(app_url: str, cookies: dict[str, str]) -> None: + """Admin ``PUT /api/ui-settings`` with the full theme (the API + seed — the UI save itself is test 1's job).""" + body = {**PALETTE, **SAVED_STRINGS} + r = httpx.put(f"{app_url}/api/ui-settings", json=body, cookies=cookies, timeout=10) + assert r.status_code == 200, r.text + assert r.json() == body, "the PUT must echo the new effective values" + + +def _hold_theme_puts(page: Page, hold_s: float = 0.6) -> None: + """Intercept ``PUT /api/ui-settings`` and hold it for + ``hold_s`` seconds (the archive-upload suite's §7.4 pattern): + while it is held, the page's fetch is guaranteed pending, so the + in-flight state (disabled buttons, the "Saving…" / "Resetting…" + labels) is observable deterministically — a localhost PUT + settles in milliseconds, so without the hold the window is a + race. GETs (the load + the save's refetch) pass straight + through.""" + + def handle(route: Route) -> None: + if route.request.method == "PUT": + time.sleep(hold_s) + route.continue_() + + page.route("**/api/ui-settings", handle) + + +def _release_theme_puts(page: Page) -> None: + page.unroute("**/api/ui-settings") + + +def _fill_theme_form( + page: Page, + palette: dict[str, str], + strings: dict[str, str] | None = None, +) -> None: + """Fill the 11 inputs: the 3 text fields (``strings``, default + the E2E set) + the 8 color pickers (``palette``).""" + text_values = strings if strings is not None else SAVED_STRINGS + page.fill("#theme-app-name", text_values["app_name"]) + page.fill("#theme-placeholder", text_values["input_placeholder"]) + page.fill("#theme-footer", text_values["footer_text"]) + for field, value in palette.items(): + page.fill(COLOR_INPUT_IDS[field], value) + + +def _expect_form_values(page: Page, strings: dict[str, str], colors: dict[str, str]) -> None: + """Assert all 11 inputs show the given effective values.""" + expect(page.locator("#theme-app-name")).to_have_value(strings["app_name"]) + expect(page.locator("#theme-placeholder")).to_have_value(strings["input_placeholder"]) + expect(page.locator("#theme-footer")).to_have_value(strings["footer_text"]) + for field in COLOR_FIELDS: + expect(page.locator(COLOR_INPUT_IDS[field])).to_have_value(colors[field]) + + +def _assert_raw_tag(raw: str, colors: dict[str, str]) -> None: + """The RAW served HTML carries exactly one inline theme tag, with + all 8 vars = the given hexes, placed IMMEDIATELY before + ``</head>`` (``inject_theme``'s exact placement: the tag ends + exactly where ``</head>`` begins and carries the injector's + single leading newline).""" + tag = _expected_tag(colors) + assert raw.count(tag) == 1, f"expected exactly one theme tag:\n{tag}" + start = raw.index(tag) + head = raw.index("</head>") + assert start + len(tag) == head, "the tag must end exactly where </head> begins" + assert raw[start - 1] == "\n", "the tag must carry the injector's leading newline" + + +def _wait_theme_computed(page: Page, colors: dict[str, str], timeout: int = 15_000) -> None: + """The first-paint proof: all 8 computed ``:root`` custom + properties equal the given hexes. The inline tag precedes every + stylesheet application, so a themed deployment resolves them + from the first style pass — no red flash, no pop-in (custom + properties return the specified token, so the string compare is + stable — the ``.trim()`` rides out any token whitespace).""" + expected = {f"--{k.replace('_', '-')}": v for k, v in colors.items()} + page.wait_for_function( + """(expected) => { + const cs = getComputedStyle(document.documentElement); + return Object.entries(expected).every( + ([k, v]) => cs.getPropertyValue(k).trim() === v + ); + }""", + arg=expected, + timeout=timeout, + ) + + +# --------------------------------------------------------------------------- +# 1. The tab (admin): the form, the effective defaults, the §7.4 save +# --------------------------------------------------------------------------- + + +def test_theme_tab_admin_save(page: Page, app_url: str, db_ready: None) -> None: + defaults = _template_defaults() + builtin = _builtin_colors() + page.set_default_timeout(30_000) + login(page, app_url, next="/theme.html") + + # The admin header contract on this page: the ship-hidden "Theme" + # nav link is revealed (header.js, role === "admin") and marks + # the current page (the router's single-writer nav stamp). + expect(page.locator("#nav-theme")).to_be_visible(timeout=15_000) + expect(page.locator("#nav-theme")).to_have_attribute("aria-current", "page") + expect(page.locator("#sign-out-btn")).to_be_visible() + + # The gate is hidden for the admin and the form is revealed + # (theme.js's whoami branch — the #git-sources-content pattern). + expect(page.locator("#theme-gate")).to_be_hidden() + expect(page.locator("#theme-content")).to_be_visible(timeout=15_000) + + # The 11 inputs show the EFFECTIVE defaults: the 3 template + # strings + the 8 built-in hexes parsed straight out of + # styles.css's :root (the resolver's missing-row branch). + _expect_form_values(page, defaults, builtin) + + # Set a distinct palette + the 3 strings, then Save through the + # real form — the PUT held so the §7.4 in-flight state is + # observable deterministically. + _fill_theme_form(page, PALETTE) + _hold_theme_puts(page) + try: + page.click("#theme-save") + # In-flight: BOTH buttons disabled (one action at a time), + # the primary relabeled "Saving…" (never stale). + expect(page.locator("#theme-save")).to_be_disabled() + expect(page.locator("#theme-save")).to_have_text("Saving…") + expect(page.locator("#theme-reset")).to_be_disabled() + # Settled: the role=status confirmation + the restored + # lifecycle (re-enabled, original label). + expect(page.locator("#theme-result")).to_have_text( + "Theme saved.", timeout=30_000 + ) + expect(page.locator("#theme-result")).to_have_attribute("role", "status") + expect(page.locator("#theme-save")).to_have_text("Save theme") + expect(page.locator("#theme-save")).to_be_enabled() + expect(page.locator("#theme-reset")).to_be_enabled() + finally: + _release_theme_puts(page) + + # The inputs re-populate to the SAVED (effective) values (the + # save's refetch is the canonical state). + _expect_form_values(page, SAVED_STRINGS, PALETTE) + + # The row landed in Postgres (the id-1 single row, all 11 values + # — every palette color differs from its built-in, so nothing + # collapsed to NULL). + with SessionLocal() as db: + row = db.get(UiSettings, 1) + assert row is not None, "the PUT must upsert the id-1 row" + assert row.app_name == APP_NAME + assert row.input_placeholder == PLACEHOLDER + assert row.footer_text == FOOTER + for field in COLOR_FIELDS: + assert getattr(row, field) == PALETTE[field] + + # The saved palette fails ONE of the five pairs — --bg on + # --brand (the button-ink pair: 3.0:1 < 4.5:1) — and the + # warning lists it. Save was NEVER blocked (the warning-only + # contract: the owner's homelab palette; the built-in stays AA). + contrast = page.locator("#theme-contrast") + expect(contrast).to_have_attribute("role", "alert") + expect(contrast).to_be_visible() + expect(contrast).to_have_text("--bg on --brand: 3.0:1 — needs 4.5:1") + + +# --------------------------------------------------------------------------- +# 2. Pre-paint, for everyone: the inline :root in the RAW served HTML +# + the computed palette at load (the no-pop-in proof), the B4 +# strings via the boot fetch +# --------------------------------------------------------------------------- + + +def test_saved_theme_is_pre_paint_for_everyone( + page: Page, browser: Browser, app_url: str, db_ready: None +) -> None: + page.set_default_timeout(30_000) + login(page, app_url, next="/") + + # The admin saves the theme (the API seed — test 1 owns the UI + # save path). + _seed_theme_via_api(app_url, _cookies(page)) + + # The RAW served HTML (httpx — no JS at all, the server's own + # bytes): exactly one inline theme tag, all 8 vars = the saved + # hexes, immediately before </head> (the pre-paint mechanism the + # middleware unit tests pin — this is its observable + # consequence). + r = httpx.get(app_url + "/", timeout=10) + assert r.status_code == 200 + _assert_raw_tag(r.text, PALETTE) + # The phase-91 CSP extension: the inline tag is permitted in a + # real browser only via the strict sha256 source expression + # (style-src 'self' 'sha256-…' appended to the A1 string — no + # 'unsafe-inline'). + csp = r.headers.get("content-security-policy", "") + assert "style-src 'self' 'sha256-" in csp, csp + + # The admin's browser: the same tag in the served document, and + # the computed custom properties equal the saved hexes at load + # (the inline tag precedes every stylesheet application — the + # first paint IS the themed paint). + page.goto(app_url + "/") + _assert_raw_tag(page.content(), PALETTE) + _wait_theme_computed(page, PALETTE) + + # A FRESH anonymous context (no auth anywhere): the same inline + # tag + computed values — the theme is for EVERYONE, not just + # the admin who set it. + anon_ctx: BrowserContext | None = None + try: + anon_ctx = browser.new_context() + anon = anon_ctx.new_page() + anon.set_default_timeout(30_000) + anon.goto(app_url + "/") + _assert_raw_tag(anon.content(), PALETTE) + _wait_theme_computed(anon, PALETTE) + # The B4 split: the 3 strings are NOT pre-paint — they apply + # post-fetch via the /api/config boot fetch (brand.js) on the + # anonymous page too: the name (header brand + window + # global), the placeholder, and the footer line. + expect(anon.locator(".brand-text")).to_have_text(APP_NAME, timeout=15_000) + assert anon.evaluate("() => window.BOR_BRAND") == APP_NAME + expect(anon.locator("#message-input")).to_have_attribute( + "placeholder", PLACEHOLDER + ) + expect(anon.locator(".footer-text").first).to_have_text(FOOTER) + finally: + if anon_ctx is not None: + anon_ctx.close() + + +# --------------------------------------------------------------------------- +# 3. The gate + the 403s: anonymous sees the gate (never the form), +# the API 403s anonymous AND token users, the nav link is admin-only +# --------------------------------------------------------------------------- + + +def test_anonymous_and_token_user_are_walled( + page: Page, browser: Browser, app_url: str, db_ready: None +) -> None: + page.set_default_timeout(30_000) + + # --- anonymous: the gate, the hidden form, the hidden nav link --- + page.goto(app_url + "/theme.html") + # Phase 79: an anonymous visitor meets the in-app token gate on + # the shell — #main is inert behind it… + expect(page.locator("#auth-gate")).to_be_visible(timeout=30_000) + assert page.evaluate("() => document.getElementById('main').inert") is True + # …and the Theme view's OWN gate (the exact #sources-gate + # pattern) is the view's visible surface: the sign-in link + # returns to the Theme view (?next=/theme.html)… + expect(page.locator("#theme-gate")).to_be_visible(timeout=15_000) + expect(page.locator("#theme-gate a.sources-gate-link")).to_have_attribute( + "href", "/login.html?next=/theme.html" + ) + # …while the form stays locked away (theme.js's non-admin + # branch) and the admin-only nav link is hidden. + expect(page.locator("#theme-content")).to_be_hidden() + expect(page.locator("#nav-theme")).to_be_hidden() + expect(page.locator("#sign-in-link")).to_be_visible() + + # The API agrees from the context's own (empty) cookies: GET AND + # PUT are 403 "admin only" (the whole router sits behind + # require_admin — anonymous first). + anon_put = page.evaluate( + """async () => (await fetch('/api/ui-settings', { + method: 'PUT', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({brand: '#4f46e5'}), + })).status""" + ) + assert anon_put == 403, f"anonymous PUT /api/ui-settings → {anon_put}" + anon_get = page.evaluate( + "() => fetch('/api/ui-settings').then((r) => r.status)" + ) + assert anon_get == 403, f"anonymous GET /api/ui-settings → {anon_get}" + + # --- a token user: the SAME wall (B5: admin-only, like + # Tuning/Tokens) --- + login(page, app_url, next="/") + r = httpx.post( + f"{app_url}/api/tokens", + json={"label": "e2e-theme-wall"}, + cookies=_cookies(page), + timeout=10, + ) + assert r.status_code == 201, r.text + token = r.json()["token"] + + user_ctx: BrowserContext | None = None + try: + user_ctx = browser.new_context() + user = user_ctx.new_page() + user.set_default_timeout(30_000) + login_with_token(user, app_url, token) + # The nav link is hidden on their shell (role "user" — the + # header reveals the admin links only for role === "admin")… + expect(user.locator("#nav-theme")).to_be_hidden() + # …and the API 403s their own session (authenticated, just + # not an admin — 403, never 401). + put_status = user.evaluate( + """async () => (await fetch('/api/ui-settings', { + method: 'PUT', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({brand: '#4f46e5'}), + })).status""" + ) + assert put_status == 403, f"token-user PUT /api/ui-settings → {put_status}" + finally: + if user_ctx is not None: + user_ctx.close() + + +# --------------------------------------------------------------------------- +# 4. Reset: the §7.4 lifecycle, the 11 defaults, NO theme tag, and +# byte-identical served HTML (the no-op injection contract) +# --------------------------------------------------------------------------- + + +def test_reset_restores_the_builtin_byte_identical( + page: Page, app_url: str, db_ready: None +) -> None: + defaults = _template_defaults() + builtin = _builtin_colors() + page.set_default_timeout(30_000) + login(page, app_url, next="/theme.html") + expect(page.locator("#theme-content")).to_be_visible(timeout=15_000) + # The form settles on the effective defaults (the row-less + # state — the autouse clean truncated the row). + _expect_form_values(page, defaults, builtin) + + # Save a distinct theme through the UI (the reset must undo a + # REAL save)… + _fill_theme_form(page, PALETTE) + _hold_theme_puts(page) + try: + page.click("#theme-save") + expect(page.locator("#theme-result")).to_have_text( + "Theme saved.", timeout=30_000 + ) + finally: + _release_theme_puts(page) + # …the theme is live server-side (the pre-reset baseline): + assert "bor-theme" in httpx.get(app_url + "/", timeout=10).text + + # Reset to defaults: the §7.4 lifecycle again, with the all-null + # PUT (the API's documented "defaults" operation). + _hold_theme_puts(page) + try: + page.click("#theme-reset") + expect(page.locator("#theme-reset")).to_be_disabled() + expect(page.locator("#theme-reset")).to_have_text("Resetting…") + expect(page.locator("#theme-save")).to_be_disabled() + expect(page.locator("#theme-result")).to_have_text( + "Reset to the built-in theme.", timeout=30_000 + ) + expect(page.locator("#theme-reset")).to_have_text("Reset to defaults") + expect(page.locator("#theme-reset")).to_be_enabled() + finally: + _release_theme_puts(page) + + # The form re-populates to the 11 defaults (the env/built-in + # merge, re-rendered from the refetch)… + _expect_form_values(page, defaults, builtin) + # …and the WCAG warning is gone (the built-in palette passes all + # five pairs). + expect(page.locator("#theme-contrast")).to_be_hidden() + + # The served HTML is back to the built-in: NO theme tag anywhere + # (the all-NULL row is the no-op)… + r = httpx.get(app_url + "/", timeout=10) + assert "bor-theme" not in r.text + # …and the computed --brand is the stylesheet's built-in again. + page.goto(app_url + "/") + _wait_theme_computed(page, builtin) + + # The byte-identical contract, proven end to end: the served + # bytes of the reset (all-NULL row) deployment equal the served + # bytes of a ROW-LESS deployment (the middleware's no-op path — + # no tag, plain A1 CSP, identical ?v= rewrite). + with_row = httpx.get(app_url + "/", timeout=10).content + with SessionLocal() as db: + db.execute(text("TRUNCATE ui_settings")) + db.commit() + without_row = httpx.get(app_url + "/", timeout=10).content + assert with_row == without_row, ( + "a defaults-saved row must serve byte-identical HTML" + ) + + +# --------------------------------------------------------------------------- +# 5. The WCAG contrast warning: listed with the ratio on the picker's +# input event, never blocks the save, hidden again after the reset +# --------------------------------------------------------------------------- + + +def test_contrast_warning_does_not_block(page: Page, app_url: str, db_ready: None) -> None: + builtin = _builtin_colors() + page.set_default_timeout(30_000) + login(page, app_url, next="/theme.html") + expect(page.locator("#theme-content")).to_be_visible(timeout=15_000) + # The form settles on the built-in defaults — the warning is + # hidden (the built-in palette passes all five pairs). + expect(page.locator("#theme-ink")).to_have_value(builtin["ink"]) + expect(page.locator("#theme-contrast")).to_be_hidden() + + # Set ONLY --ink to a color within 0.1 ratio of --bg: the + # picker's input event previews it live AND re-runs the five + # pairs — --ink on --bg (and --ink on --surface, the ink is now + # the darker side of that pair too) fail, and each failing pair + # is listed with its ratio in the role=alert line. + page.fill("#theme-ink", FAILING_INK) + contrast = page.locator("#theme-contrast") + expect(contrast).to_have_attribute("role", "alert") + expect(contrast).to_be_visible(timeout=15_000) + expect(contrast).to_contain_text("--ink on --bg: 1.0:1 — needs 4.5:1") + expect(contrast).to_contain_text("--ink on --surface: 1.0:1 — needs 4.5:1") + + # WARNING-ONLY: Save is never disabled by the warning (the + # owner's homelab palette — the built-in stays AA, so the + # default deployment is warning-free). + assert page.locator("#theme-save").is_enabled() + _hold_theme_puts(page) + try: + page.click("#theme-save") + expect(page.locator("#theme-result")).to_have_text( + "Theme saved.", timeout=30_000 + ) + # The saved palette still fails the pairs — the warning + # tracks the SAVED state (the save's refetch re-checks it). + expect(contrast).to_be_visible() + finally: + _release_theme_puts(page) + + # Restore: Reset clears the failing pick (the suite's final + # state is clean) and the warning hides with the AA built-ins. + page.click("#theme-reset") + expect(page.locator("#theme-result")).to_have_text( + "Reset to the built-in theme.", timeout=30_000 + ) + expect(contrast).to_be_hidden() + expect(page.locator("#theme-ink")).to_have_value(builtin["ink"]) diff --git a/tests/e2e/test_big_read_progress.py b/tests/e2e/test_big_read_progress.py index 03a823e..5003f9c 100644 --- a/tests/e2e/test_big_read_progress.py +++ b/tests/e2e/test_big_read_progress.py @@ -331,7 +331,6 @@ def app_server(mock_llm: int, slow_llm: int) -> Iterator[str]: ) env["BOR_INPUT_PLACEHOLDER"] = _Settings.model_fields["input_placeholder"].default env["BOR_FOOTER_TEXT"] = _Settings.model_fields["footer_text"].default - env["BOR_THEME"] = _Settings.model_fields["theme"].default proc = subprocess.Popen( [sys.executable, "-m", "uvicorn", "app.main:app", "--host", "127.0.0.1", "--port", str(APP_PORT), "--log-level", "warning"], diff --git a/tests/e2e/test_configurable_brand.py b/tests/e2e/test_configurable_brand.py index b4c8746..f501f2a 100644 --- a/tests/e2e/test_configurable_brand.py +++ b/tests/e2e/test_configurable_brand.py @@ -148,25 +148,28 @@ def test_api_config_serves_both_names(testy_server: str, app_server: str) -> Non # Phase 59 (task 05): the third key is the docs-push flag — the # "Save as doc" gating; both instances run with BOR_DOCS_REPO # empty, so it is the inert false here. Phase 62 (task 01): the - # endpoint grew to six keys — this suite's instances carry no - # UI-customization overrides, so the three new keys are their - # defaults. + # endpoint grew with the UI-customization keys; phase 91 + # (task 03) deleted the retired CSS-file theming's ``theme`` key — + # the five keys below are the entire contract (this suite's + # instances carry no UI-customization overrides, so the string + # keys are their defaults). assert set(body) == { "app_name", "version", "docs_repo_configured", - "input_placeholder", "footer_text", "theme", + "input_placeholder", "footer_text", } assert body["app_name"] == TESTY_NAME assert body["docs_repo_configured"] is False # The shared conftest instance keeps the default (the other # suites' title/label contract rides on it) — and its key set - # grew with the endpoint (phase 62). + # tracks the endpoint contract (five keys after phase 91, + # task 03). r2 = httpx.get(f"{app_server}/api/config", timeout=5) assert r2.status_code == 200 r2_body = r2.json() assert set(r2_body) == { "app_name", "version", "docs_repo_configured", - "input_placeholder", "footer_text", "theme", + "input_placeholder", "footer_text", } assert r2_body["app_name"] == DEFAULT_NAME diff --git a/tests/e2e/test_ui_customization.py b/tests/e2e/test_ui_customization.py index dca44e2..4c03ba0 100644 --- a/tests/e2e/test_ui_customization.py +++ b/tests/e2e/test_ui_customization.py @@ -1,50 +1,53 @@ -"""Phase 62 E2E (Playwright): UI customization — placeholder, footer, theme. +"""Phase 62 E2E (Playwright): UI customization — placeholder + footer. Source: ``TODO.md`` L3 — "Allow UI customization. This is brain of reese, but I want anyone to be able to deploy it with their name… custom message-input placeholder, custom footer-inner text, custom color themes…" (owner-locked 2026-09-01: ``BOR_INPUT_PLACEHOLDER``, -``BOR_FOOTER_TEXT``, ``BOR_THEME`` — A4/A5). +``BOR_FOOTER_TEXT`` — A4). Phase 91 (task 03) retired the story's +color-theming half — the CSS-file theme env var and its brand.js +``<link>`` insertion are gone; the admin Theme tab (phases 91, +tasks 04–06) is the only theming surface now, with its own dedicated +E2E suite. + Run in isolation (DB must be up: ``podman compose up -d db``): uv run pytest tests/e2e/test_ui_customization.py -v --no-cov Contract under test: -* an instance booted with ALL THREE customization vars set shows the - custom look end-to-end: the ``GET /api/config`` overrides, the chat - composer placeholder (``#message-input``), the footer line on multiple - pages (``.footer-text``), and the computed ``:root --brand`` from the - inserted ``<link id="theme-override" href="/assets/themes/indigo.css">`` - (the indigo example theme, ``--brand: #818cf8``); +* an instance booted with BOTH customization vars set shows the custom + look end-to-end: the ``GET /api/config`` overrides (now the five-key + set — the retired theming's ``theme`` key is gone), the chat composer + placeholder (``#message-input``), and the footer line on multiple + pages (``.footer-text``); * with NOTHING set the shared conftest server is byte-identical to the phase-39/61 no-op contract: the default placeholder, the default - footer, NO theme link, the built-in ``--brand: #f43f5e``; -* a malformed ``BOR_THEME`` (``../evil.css``) refuses startup loudly, - naming the value — the phase-56 fail-loud style, proven end-to-end - via a real boot attempt, not just the validator unit test. + footer, the built-in ``--brand: #f43f5e``; +* the app NAME stays the default on the custom instance (this suite + does not re-test ``BOR_APP_NAME`` — that is the phase-39 suite's + job); the response's ``app_name`` key is the EFFECTIVE value (phase + 91: DB-over-env — for an env-only deployment, the env string itself). Determinism note: this story needs a SECOND app instance — the shared conftest server keeps the defaults (every other suite's placeholder/footer/palette assertions depend on it), so ``custom_server`` boots the same env block the phase-39 brand suite's ``testy_server`` boots (same DB, the mock-LLM base URL, the admin auth, the static dir, -the mock-calibrated threshold) with exactly three changes: port +the mock-calibrated threshold) with exactly two changes: port ``APP_PORT + 2`` (the brand suite owns ``APP_PORT + 1`` — do not -collide) and the three env overrides. Every assertion is settled-state: +collide) and the two env overrides. Every assertion is settled-state: Playwright's ``expect`` retries ride out the brand.js ``/api/config`` -fetch (the three keys are applied asynchronously, in the SAME fetch's -settled ``.then`` — no second network call). The one absence assertion -(no ``#theme-override`` on the default server) first waits for -``window.BOR_CONFIG_PROMISE`` to settle, so it cannot race the fetch. +fetch (the two string keys are applied asynchronously, in the SAME +fetch's settled ``.then`` — no second network call). Test → contract mapping (Playwright Mapping Rule): 1. ``test_config_serves_the_overrides`` -2. ``test_chat_page_shows_custom_placeholder_footer_theme`` +2. ``test_chat_page_shows_custom_placeholder_and_footer`` 3. ``test_footer_text_applies_on_other_pages`` 4. ``test_default_server_is_byte_identical`` -5. ``test_malformed_theme_refuses_startup`` """ + from __future__ import annotations import os @@ -67,13 +70,10 @@ from e2e.conftest import ( CUSTOM_PORT = APP_PORT + 2 # the brand suite owns APP_PORT + 1 — no collision CUSTOM_URL = f"http://127.0.0.1:{CUSTOM_PORT}" -MALFORMED_PORT = APP_PORT + 3 # the refused boot never starts listening -# The three overrides (task 05) — the whole story: +# The two overrides (phase 62, task 05) — the surviving story legs: CUSTOM_PLACEHOLDER = "Ask the archive…" CUSTOM_FOOTER = "Custom footer line" -CUSTOM_THEME = "indigo.css" -INDIGO_BRAND = "#818cf8" # indigo.css's --brand (the computed token) # The phase-39/61 no-op contract on the shared default server: DEFAULT_NAME = "Brain of Reese" @@ -84,7 +84,7 @@ BUILTIN_BRAND = "#f43f5e" # styles.css's built-in --brand @pytest.fixture(scope="session") def custom_server(mock_llm: int) -> Iterator[str]: - """A SECOND app instance, booted with all three customization + """A SECOND app instance, booted with both customization string overrides. The shared conftest ``app_server`` keeps the defaults (every other @@ -92,9 +92,9 @@ def custom_server(mock_llm: int) -> Iterator[str]: this fixture copies the phase-39 brand suite's ``testy_server`` env block verbatim (same DB, the mock-LLM base URL, ``BOR_ADMIN_PASSWORD``/``BOR_SESSION_SECRET``, ``BOR_STATIC_DIR``, - ``BOR_RELEVANCE_THRESHOLD``) with exactly three changes: port + ``BOR_RELEVANCE_THRESHOLD``) with exactly two changes: port ``APP_PORT + 2`` (the brand suite owns ``APP_PORT + 1``) and the - three env overrides below. Started after ``mock_llm`` is available + two env overrides below. Started after ``mock_llm`` is available (its fixture dependency). """ env = dict(os.environ) @@ -117,10 +117,9 @@ def custom_server(mock_llm: int) -> Iterator[str]: # Phase 16: admin auth must be set or create_app() refuses to boot. env["BOR_ADMIN_PASSWORD"] = ADMIN_PASSWORD env["BOR_SESSION_SECRET"] = SESSION_SECRET - # Phase 62 (owner-locked 2026-09-01, TODO L3) — the whole story: + # Phase 62 (owner-locked 2026-09-01, TODO L3) — the surviving legs: env["BOR_INPUT_PLACEHOLDER"] = CUSTOM_PLACEHOLDER env["BOR_FOOTER_TEXT"] = CUSTOM_FOOTER - env["BOR_THEME"] = CUSTOM_THEME proc = subprocess.Popen( [sys.executable, "-m", "uvicorn", "app.main:app", "--host", "127.0.0.1", "--port", str(CUSTOM_PORT), "--log-level", "warning"], @@ -138,28 +137,13 @@ def custom_server(mock_llm: int) -> Iterator[str]: proc.kill() -def wait_for_brand_settled(page: Page, timeout: int = 15_000) -> None: - """Wait for the brand layer's boot fetch to settle. - - The three customization keys are applied asynchronously, in the - settled ``/api/config`` promise's ``.then`` — absence assertions - (no ``#theme-override``) must not race that fetch. The promise - NEVER rejects (the brand.js contract), so its resolution means the - DOM pass has already run: ``applyBrand`` registered its callback on - the same promise at page load, before this wait's callback, and - promise callbacks run in registration order.""" - page.wait_for_function( - "() => window.BOR_CONFIG_PROMISE.then(() => true)", - timeout=timeout, - ) - - def expect_brand_var(page: Page, expected: str, timeout: int = 15_000) -> None: """Retrying computed ``:root --brand`` equality. Custom properties return the SPECIFIED token from ``getComputedStyle`` (no color - normalization), so the string compare is stable: ``#818cf8`` is - exactly what indigo.css declares, ``#f43f5e`` exactly what - styles.css declares (the built-in).""" + normalization), so the string compare is stable: ``#f43f5e`` is + exactly what styles.css declares (the built-in — the page the + shared default server serves, with no ui_settings row, carries no + inline theme tag and the stylesheet value stands).""" page.wait_for_function( """(expected) => getComputedStyle(document.documentElement) @@ -171,8 +155,8 @@ def expect_brand_var(page: Page, expected: str, timeout: int = 15_000) -> None: # --------------------------------------------------------------------------- -# 1. The endpoint the brand layer reads — the three overrides, the -# six-key set, and the theme file served from the dev static dir +# 1. The endpoint the brand layer reads — the two overrides, the +# five-key set (the retired theming's theme key is gone) # --------------------------------------------------------------------------- @@ -180,33 +164,27 @@ def test_config_serves_the_overrides(custom_server: str) -> None: r = httpx.get(f"{CUSTOM_URL}/api/config", timeout=5) assert r.status_code == 200 body = r.json() - # The six-key set (the phase-39/59/62 endpoint contract) with the - # three customization overrides — the app NAME stays the default - # (this suite does not re-test BOR_APP_NAME; that is the phase-39 - # suite's job). + # The five-key set (the phase-39/59/62 endpoint contract, phase 91 + # task 03: the retired CSS-file theming's ``theme`` key is gone) + # with the two customization overrides — the app NAME stays the + # default (this suite does not re-test BOR_APP_NAME; that is the + # phase-39 suite's job). assert set(body) == { "app_name", "version", "docs_repo_configured", - "input_placeholder", "footer_text", "theme", + "input_placeholder", "footer_text", } assert body["app_name"] == DEFAULT_NAME assert body["input_placeholder"] == CUSTOM_PLACEHOLDER assert body["footer_text"] == CUSTOM_FOOTER - assert body["theme"] == CUSTOM_THEME - - # Served in dev from the static dir (the no-CDN rule): the theme - # file the boot fetch names is reachable at its served path, and - # it is the indigo example (its --brand is the E2E's theme proof). - r2 = httpx.get(f"{CUSTOM_URL}/assets/themes/{CUSTOM_THEME}", timeout=5) - assert r2.status_code == 200 - assert f"--brand: {INDIGO_BRAND}" in r2.text # --------------------------------------------------------------------------- -# 2. The chat page — placeholder, footer, the theme link + effect +# 2. The chat page — placeholder + footer (the theme legs are retired: +# colors are injected pre-paint server-side, phase 91 task 02) # --------------------------------------------------------------------------- -def test_chat_page_shows_custom_placeholder_footer_theme( +def test_chat_page_shows_custom_placeholder_and_footer( page: Page, custom_server: str ) -> None: page.goto(custom_server + "/") @@ -219,13 +197,6 @@ def test_chat_page_shows_custom_placeholder_footer_theme( expect(page.locator(".footer-text").first).to_have_text( CUSTOM_FOOTER, timeout=15_000 ) - # 7. The theme link in <head> — rel=stylesheet, the served path. - expect(page.locator('head link#theme-override[rel="stylesheet"]')).to_have_attribute( - "href", f"/assets/themes/{CUSTOM_THEME}", timeout=15_000 - ) - # And it takes effect: the computed :root --brand is the indigo - # value (the built-in #f43f5e means the theme never loaded). - expect_brand_var(page, INDIGO_BRAND) # --------------------------------------------------------------------------- @@ -243,10 +214,6 @@ def test_footer_text_applies_on_other_pages(page: Page, custom_server: str) -> N expect(page.locator(".footer-text").first).to_have_text( CUSTOM_FOOTER, timeout=15_000 ) - # The theme link rides in <head> on every page too. - expect(page.locator('head link#theme-override[rel="stylesheet"]')).to_have_attribute( - "href", f"/assets/themes/{CUSTOM_THEME}", timeout=15_000 - ) # --------------------------------------------------------------------------- @@ -257,75 +224,14 @@ def test_footer_text_applies_on_other_pages(page: Page, custom_server: str) -> N def test_default_server_is_byte_identical(page: Page, app_server: str) -> None: page.goto(app_server + "/") - # Settle the boot fetch BEFORE the absence assertion — it must not - # race the (absent) theme-link insertion. - wait_for_brand_settled(page) - # The phase-39/61 no-op contract: the template defaults stand. + # The phase-39/61 no-op contract: the template defaults stand + # (positive assertions on the static HTML — the brand layer's + # no-op paths touch nothing when the env vars are unset). expect(page.locator("#message-input")).to_have_attribute( "placeholder", DEFAULT_PLACEHOLDER ) expect(page.locator(".footer-text").first).to_have_text(DEFAULT_FOOTER) - # With BOR_THEME unset the brand layer inserts NO theme link: - assert page.locator("#theme-override").count() == 0, ( - "with BOR_THEME unset the brand layer must NOT insert a theme " - "link (the byte-identical no-op contract)" - ) - # The built-in dark-tech palette stands. + # The built-in dark-tech palette stands: with no ui_settings row + # the server injects no inline theme tag (phase 91 task 02's + # no-op), so the stylesheet's --brand is the computed value. expect_brand_var(page, BUILTIN_BRAND) - - -# --------------------------------------------------------------------------- -# 5. The fail-loud boot check — a malformed BOR_THEME refuses startup -# --------------------------------------------------------------------------- - - -def test_malformed_theme_refuses_startup() -> None: - """A malformed ``BOR_THEME`` (``../evil.css`` — a path, exactly the - shape the A5 lock names as illegal) kills startup with the value - NAMED on stderr (the phase-56 fail-loud house style), proven - end-to-end via a real uvicorn boot attempt: the process exits - non-zero within the timeout without ever starting to listen. - - ``app.main`` builds its settings at import time - (``settings = get_settings()``), so the validator fires during the - ASGI app import — before admin auth, before the port binds.""" - env = dict(os.environ) - env.pop("DEBUGPY", None) - env["BOR_ENVIRONMENT"] = "e2e" - env["BOR_STATIC_DIR"] = str(REPO / "frontend") - env["BOR_RELEVANCE_THRESHOLD"] = "0.30" - env.setdefault( - "BOR_DATABASE_URL", - "postgresql+psycopg://reese:reese@localhost:5432/brain_of_reese", - ) - env["BOR_ADMIN_PASSWORD"] = ADMIN_PASSWORD - env["BOR_SESSION_SECRET"] = SESSION_SECRET - # The whole point: a malformed theme value. - env["BOR_THEME"] = "../evil.css" - proc = subprocess.Popen( - [sys.executable, "-m", "uvicorn", "app.main:app", - "--host", "127.0.0.1", "--port", str(MALFORMED_PORT), "--log-level", "warning"], - cwd=REPO, - env=env, - stdout=subprocess.DEVNULL, - stderr=subprocess.PIPE, - text=True, - ) - try: - proc.wait(timeout=60) - except subprocess.TimeoutExpired: - proc.kill() - proc.wait() - pytest.fail( - "the app kept running with BOR_THEME='../evil.css' — a " - "malformed theme must refuse startup, not silently 404" - ) - assert proc.returncode != 0, ( - "the malformed BOR_THEME must make uvicorn exit non-zero" - ) - stderr = proc.stderr.read() if proc.stderr else "" - # Fail-loud names the offending value (phase-56 house style): - assert "'../evil.css'" in stderr, ( - f"stderr must name the offending value, got tail: {stderr[-2000:]}" - ) - assert "theme must be a bare .css filename" in stderr diff --git a/tests/integration/test_api.py b/tests/integration/test_api.py index 6f46c5b..9f7fb4e 100644 --- a/tests/integration/test_api.py +++ b/tests/integration/test_api.py @@ -9,6 +9,14 @@ Phase 80 note: the suggestions pins are the exception — the chips are the last 3 questions asked once any are saved, so the env-override pin (the override is the SEED) needs an empty ``saved_chats``; the full state matrix lives in ``test_suggestions_api.py``. + +Phase 91 (task 01) note: the ``/api/config`` pins are now DB-backed — +the three UI strings are the EFFECTIVE values (the ``ui_settings`` row +over the env values, B1), resolved through a short-lived session, so +the pins take the ``db`` fixture (skip when the stack is down) and +start from an empty ``ui_settings`` table (the env-only-deployment +state; the DB-over-env behaviour itself is pinned in +test_ui_settings_api.py). """ from __future__ import annotations @@ -22,6 +30,14 @@ from app.config import get_settings from tests.conftest import ADMIN_PASSWORD +def _clear_ui_settings(db: Session) -> None: + """The env-only-deployment state for the /api/config pins: no + ui_settings row, so the effective strings are the env values + (phase 91, task 01).""" + db.execute(text("DELETE FROM ui_settings")) + db.commit() + + def test_health_reports_ok(client) -> None: r = client.get("/api/health") assert r.status_code == 200 @@ -31,34 +47,41 @@ def test_health_reports_ok(client) -> None: assert body["version"] -def test_config_returns_default_app_metadata(client) -> None: - """GET /api/config is public (anonymous) and returns exactly six +def test_config_returns_default_app_metadata(client, db: Session) -> None: + """GET /api/config is public (anonymous) and returns exactly five keys — the phase-39 app metadata, the phase-59 docs flag (inert false while BOR_DOCS_REPO is empty — the "Save as doc" gating), and the phase-62 UI customization strings (composer placeholder, - footer line, theme file name).""" + footer line). Phase 91: with an empty ui_settings table the + effective strings are the env defaults (B1 — DB-over-env, the row + absent here); the retired CSS-file theming's ``theme`` key is gone + (task 03 — the five keys are the entire contract).""" + _clear_ui_settings(db) r = client.get("/api/config") assert r.status_code == 200 body = r.json() assert set(body) == { "app_name", "version", "docs_repo_configured", - "input_placeholder", "footer_text", "theme", + "input_placeholder", "footer_text", } assert body["app_name"] == "Brain of Reese" assert body["version"] == get_settings().app_version assert body["docs_repo_configured"] is False # Phase 62: UNSET => the phase-61 neutral copy stands (the - # byte-identical contract); an empty theme = the built-in palette. + # byte-identical contract). assert body["input_placeholder"] == "Ask me anything…" assert body["footer_text"] == "Powered by self-hosted models" - assert body["theme"] == "" -def test_config_follows_overridden_app_name(client) -> None: - """GET /api/config reflects a Settings override (e.g. BOR_APP_NAME).""" +def test_config_follows_overridden_app_name(client, db: Session) -> None: + """GET /api/config reflects a Settings override (e.g. BOR_APP_NAME). + Phase 91: the override is the ENV side of the DB-over-env resolver — + with an empty ui_settings row the effective app_name is the + overridden env value.""" from app.config import Settings from app.main import app as fastapi_app + _clear_ui_settings(db) fastapi_app.dependency_overrides[get_settings] = lambda: Settings( app_name="Brain of Testy" ) @@ -68,7 +91,7 @@ def test_config_follows_overridden_app_name(client) -> None: body = r.json() assert set(body) == { "app_name", "version", "docs_repo_configured", - "input_placeholder", "footer_text", "theme", + "input_placeholder", "footer_text", } assert body["app_name"] == "Brain of Testy" assert body["version"] == "0.1.0" @@ -77,18 +100,21 @@ def test_config_follows_overridden_app_name(client) -> None: fastapi_app.dependency_overrides.clear() -def test_config_serves_ui_customization_overrides(client) -> None: - """Phase 62: the three UI customization keys mirror Settings - overrides (``BOR_INPUT_PLACEHOLDER`` / ``BOR_FOOTER_TEXT`` / - ``BOR_THEME``) verbatim — the values the frontend brand layer - applies at boot, so this dict is the whole contract.""" +def test_config_serves_ui_customization_overrides(client, db: Session) -> None: + """Phase 62: the UI customization string keys mirror Settings + overrides (``BOR_INPUT_PLACEHOLDER`` / ``BOR_FOOTER_TEXT``) — the + values the frontend brand layer applies at boot, so this dict is + the whole contract. Phase 91: placeholder + footer are the + EFFECTIVE strings — the env overrides win with an empty + ui_settings row (B1); the retired theming's ``theme`` key is gone + (task 03).""" from app.config import Settings from app.main import app as fastapi_app + _clear_ui_settings(db) fastapi_app.dependency_overrides[get_settings] = lambda: Settings( input_placeholder="Ask the vault…", footer_text="Powered by my own models", - theme="indigo.css", ) try: r = client.get("/api/config") @@ -96,16 +122,15 @@ def test_config_serves_ui_customization_overrides(client) -> None: body = r.json() assert set(body) == { "app_name", "version", "docs_repo_configured", - "input_placeholder", "footer_text", "theme", + "input_placeholder", "footer_text", } assert body["input_placeholder"] == "Ask the vault…" assert body["footer_text"] == "Powered by my own models" - assert body["theme"] == "indigo.css" finally: fastapi_app.dependency_overrides.clear() -def test_config_docs_flag_tracks_settings(client) -> None: +def test_config_docs_flag_tracks_settings(client, db: Session) -> None: """Phase 59 (task 05): ``docs_repo_configured`` mirrors ``settings.docs_configured`` — a real bool (never a truthy string) that flips true the moment BOR_DOCS_REPO is non-empty: that flag is @@ -113,6 +138,7 @@ def test_config_docs_flag_tracks_settings(client) -> None: from app.config import Settings from app.main import app as fastapi_app + _clear_ui_settings(db) fastapi_app.dependency_overrides[get_settings] = lambda: Settings( app_name="Brain of Testy", docs_repo="/srv/docs-repo", @@ -206,6 +232,10 @@ def test_suggestions_honors_bor_suggestions_env_override( # shell-body marker (the Tokens view section is inside the # shell; the per-view title is client-side now). ("/tokens.html", 'id="view-tokens"'), # phase 79: shell route + # Phase 91 (task 04): /theme.html is a SHELL route too — the + # shell-body marker (the Theme view section is inside the + # shell; the per-view title is client-side now). + ("/theme.html", 'id="view-theme"'), # phase 91: shell route ("/shared.html", "Shared conversation"), # phase 51: anonymous shared page ], ) @@ -246,6 +276,7 @@ def test_index_page_no_cache_with_versioned_asset_refs(client) -> None: ["/sources.html", "/document.html", "/login.html", "/tuning.html", "/git-sources.html", "/history.html", # phase 50: + History (shell route, task 03) "/tokens.html", # phase 79 task 06: + Tokens (shell route) + "/theme.html", # phase 91 task 04: + Theme (shell route) "/shared.html"], # phase 51: + the anonymous shared page ) def test_html_pages_no_cache_with_versioned_refs(client, path: str) -> None: @@ -271,6 +302,11 @@ def test_html_pages_no_cache_with_versioned_refs(client, path: str) -> None: # client-side title: the pin asserts the shell never carries # the per-view title statically (the router writes it). ("/tokens.html", 'id="view-tokens"', "Access tokens · Brain of Reese"), # phase 79 task 06 + # phase 91 task 04: the seventh view — there was never a + # standalone theme.html, so "old_title" is the router's + # client-side title: the pin asserts the shell never carries + # the per-view title statically (the router writes it). + ("/theme.html", 'id="view-theme"', "Theme · Brain of Reese"), # phase 91 task 04 ], ) def test_shell_routes_serve_the_shell_no_cache_versioned( diff --git a/tests/integration/test_caching_revalidation.py b/tests/integration/test_caching_revalidation.py index 3dcec69..8f971c3 100644 --- a/tests/integration/test_caching_revalidation.py +++ b/tests/integration/test_caching_revalidation.py @@ -87,6 +87,7 @@ SHELL_BACKED_PAGES = { "/git-sources.html": "index.html", # phase 76 task 02 "/history.html": "index.html", # phase 76 task 03 "/tokens.html": "index.html", # phase 79 task 06 + "/theme.html": "index.html", # phase 91 task 04 } diff --git a/tests/integration/test_migration_0014.py b/tests/integration/test_migration_0014.py new file mode 100644 index 0000000..debdf32 --- /dev/null +++ b/tests/integration/test_migration_0014.py @@ -0,0 +1,193 @@ +"""Integration: migration 0014 (ui_settings) schema contract. + +Drives the **real Alembic engine** against the live dev database +(``podman compose up -d db``), mirroring the house pattern of +``test_migration_0012.py`` (information_schema assertions on the state +the migration must leave). The tests target revision ``0014`` +explicitly so later migrations cannot break them: + +* upgrade 0013 → 0014 → the ``ui_settings`` table exists with the full + column contract (``id`` INTEGER PK; the 3 strings VARCHAR(300) NULL; + the 8 identity colors VARCHAR(7) NULL — NULL = default, B1); no + server defaults anywhere (a missing row means "defaults"); +* an inserted id-1 row round-trips its values (the PUT upsert's shape); +* downgrade to 0013 → the table is gone (A13 — reversible), the rest of + the schema (e.g. ``api_tokens.token_hash``) survives; +* upgrade back to 0014 → the table is back (round-trip). + +The ``alembic`` fixture guarantees the DB ends at head even if a test +fails or the process is interrupted. +""" +from __future__ import annotations + +from collections.abc import Iterator +from typing import Any + +import pytest +from alembic.config import Config +from sqlalchemy import text +from sqlalchemy.orm import Session + +from alembic import command +from app.db import db_available + + +@pytest.fixture() +def alembic(db: Session) -> Iterator[Config]: + """Real Alembic config bound to the dev DB (URL from app settings). + + Starts at head (repairs an interrupted earlier run); teardown upgrades + to head no matter what happened, so the dev DB is never left below + head. + """ + if not db_available(): + pytest.skip("Postgres not reachable — run `podman compose up -d db` first") + cfg = Config() # no alembic.ini file — env.py gets the URL from app config + cfg.set_main_option("script_location", "alembic") + command.upgrade(cfg, "head") + try: + yield cfg + finally: + command.upgrade(cfg, "head") + + +def _version(db: Session) -> str | None: + return db.execute(text("SELECT version_num FROM alembic_version")).scalar() + + +def _table_exists(db: Session, table: str) -> bool: + count: Any = db.execute( + text( + "SELECT count(*) FROM information_schema.tables" + " WHERE table_schema = 'public' AND table_name = :t" + ), + {"t": table}, + ).scalar() + assert count is not None, "information_schema count must be an int" + return int(count) == 1 + + +def _column(db: Session, table: str, column: str) -> tuple[Any, ...] | None: + """(data_type, is_nullable, column_default, character_maximum_length) + for one table column.""" + row = db.execute( + text( + "SELECT data_type, is_nullable, column_default, character_maximum_length" + " FROM information_schema.columns" + " WHERE table_name = :t AND column_name = :c" + ), + {"t": table, "c": column}, + ).fetchone() + return tuple(row) if row is not None else None + + +def _insert_row(db: Session, *, app_name: str | None, brand: str | None) -> None: + """Insert the single row (the PUT upsert's shape) with two values set + and the rest NULL — the NULL = default state the resolver merges.""" + db.execute( + text( + "INSERT INTO ui_settings (id, app_name, brand) VALUES (1, :n, :b)" + ), + {"n": app_name, "b": brand}, + ) + db.commit() + + +def _delete_row(db: Session) -> None: + db.execute(text("DELETE FROM ui_settings WHERE id = 1")) + db.commit() + + +def test_upgrade_to_0014_adds_ui_settings(db: Session, alembic: Config) -> None: + """Upgrade 0013 → 0014: the table exists with the full column + contract (the Integer PK, the 3 strings VARCHAR(300) NULL, the 8 + colors VARCHAR(7) NULL — no server defaults anywhere: a missing row + means "defaults"); the table is absent at 0013.""" + command.downgrade(alembic, "0013") # start from the pre-0014 state + assert _version(db) == "0013" + assert not _table_exists(db, "ui_settings"), "ui_settings must be absent at 0013" + + command.upgrade(alembic, "0014") + assert _version(db) == "0014", "alembic_version must be at 0014" + assert _table_exists(db, "ui_settings"), "ui_settings must exist at 0014" + + id_col = _column(db, "ui_settings", "id") + assert id_col is not None, "ui_settings.id is missing" + assert id_col[0] == "integer", "ui_settings.id must be INTEGER" + assert id_col[1] == "NO", "ui_settings.id must be NOT NULL (PK)" + + for name in ("app_name", "input_placeholder", "footer_text"): + col = _column(db, "ui_settings", name) + assert col is not None, f"ui_settings.{name} is missing" + assert col[0] == "character varying", f"ui_settings.{name} must be VARCHAR" + assert col[1] == "YES", f"ui_settings.{name} must be NULL (env default, B1)" + assert col[2] is None, f"ui_settings.{name} must have no server default" + assert col[3] == 300, f"ui_settings.{name} must be String(300)" + + for name in ("bg", "surface", "ink", "ink_soft", "line", + "brand", "brand_soft", "brand_ink"): + col = _column(db, "ui_settings", name) + assert col is not None, f"ui_settings.{name} is missing" + assert col[0] == "character varying", f"ui_settings.{name} must be VARCHAR" + assert col[1] == "YES", f"ui_settings.{name} must be NULL (the built-in, B1)" + assert col[2] is None, f"ui_settings.{name} must have no server default" + assert col[3] == 7, f"ui_settings.{name} must be String(7) — #rrggbb" + + +def test_inserted_id_1_row_round_trips_values(db: Session, alembic: Config) -> None: + """At 0014, the single row (id 1, the PUT upsert's shape) round-trips + its set values verbatim and keeps the unset columns NULL.""" + command.upgrade(alembic, "head") + _insert_row(db, app_name="Brain of Testy", brand="#818cf8") + try: + row = db.execute( + text( + "SELECT id, app_name, input_placeholder, footer_text, brand" + " FROM ui_settings WHERE id = 1" + ) + ).fetchone() + assert row is not None, "the ui_settings row must exist" + assert row[0] == 1, "the single row is always id 1" + assert row[1] == "Brain of Testy", "app_name must round-trip verbatim" + assert row[2] is None, "input_placeholder must stay NULL (the default)" + assert row[3] is None, "footer_text must stay NULL (the default)" + assert row[4] == "#818cf8", "brand must round-trip verbatim" + finally: + _delete_row(db) + + +def test_downgrade_to_0013_drops_the_table(db: Session, alembic: Config) -> None: + """Downgrade to 0013: the table is gone (A13 — reversible) while the + rest of the schema survives.""" + command.downgrade(alembic, "0013") + assert _version(db) == "0013" + assert not _table_exists(db, "ui_settings"), "ui_settings must be dropped" + + token_col = _column(db, "api_tokens", "token_hash") + assert token_col is not None and token_col[0] == "character varying", ( + "api_tokens.token_hash must survive the downgrade" + ) + ignore_col = _column(db, "git_sources", "ignore_paths") + assert ignore_col is not None and ignore_col[0] == "jsonb", ( + "git_sources.ignore_paths must survive the downgrade" + ) + + +def test_upgrade_round_trip_restores_the_table(db: Session, alembic: Config) -> None: + """Downgrade to 0013, then upgrade back to 0014: the table is back + with the column contract intact.""" + command.downgrade(alembic, "0013") + command.upgrade(alembic, "0014") + assert _version(db) == "0014", "round-trip upgrade must land at 0014" + + assert _table_exists(db, "ui_settings"), "ui_settings must be back" + + id_col = _column(db, "ui_settings", "id") + assert id_col is not None and id_col[0] == "integer", ( + "id must be INTEGER after the round-trip" + ) + brand = _column(db, "ui_settings", "brand") + assert brand is not None and brand[1] == "YES", ( + "brand must be VARCHAR NULL after the round-trip" + ) + assert brand[3] == 7, "brand must be String(7) after the round-trip" diff --git a/tests/integration/test_security_headers.py b/tests/integration/test_security_headers.py index fb31df2..0debfcb 100644 --- a/tests/integration/test_security_headers.py +++ b/tests/integration/test_security_headers.py @@ -30,8 +30,12 @@ from __future__ import annotations import httpx from fastapi.testclient import TestClient +from sqlalchemy import text +from sqlalchemy.orm import Session +from app.core import theming from app.core.security_headers import CSP +from app.models import UiSettings #: The exact owner-approved A1 policy string (phase 82). The constant is #: the single source of truth; the unit suite additionally pins that the @@ -54,9 +58,14 @@ def _assert_security_headers(response: httpx.Response) -> None: ) -def test_page_carries_all_three_headers(client: TestClient) -> None: +def test_page_carries_all_three_headers(client: TestClient, db: Session) -> None: """``GET /`` (the shell page) — 200 + all three headers, CSP exactly - the A1 string.""" + the A1 string. The ``ui_settings`` row is cleared first (phase 91, + task 05: a themed page carries the A1 string EXTENDED with the + style-src hash — the plain-A1 pin is the UNTHAMED page's + contract, and the dev database must not leak a theme into it).""" + db.execute(text("DELETE FROM ui_settings")) + db.commit() response = client.get("/") assert response.status_code == 200 _assert_security_headers(response) @@ -102,3 +111,44 @@ def test_caching_rewrite_still_runs_under_headers_middleware(client: TestClient) "the ?v=<token> asset rewrite no longer runs — the outermost " "security-header middleware altered or swallowed the body" ) + + +def test_themed_page_carries_a1_plus_style_src_theme_hash( + client: TestClient, db: Session +) -> None: + """Phase 91 (task 05, defect fix): the A1 CSP would BLOCK the + inline ``<style id="bor-theme">`` pre-paint tag in every real + browser (``style-src`` falls back to ``default-src 'self'``) — so a + THemed HTML page carries the A1 string EXTENDED with + ``style-src 'self' 'sha256-<hash>'``, the CSP3 hash of the exact + tag content: the current theme is the only inline style ever + permitted, no blanket ``'unsafe-inline'``, a different palette is + still blocked. The unthemed page keeps the plain A1 string (no + exemption for a tag that is not served). Pinned against the real + app (the unit suite pins the two middleware halves in isolation). + """ + db.execute(text("DELETE FROM ui_settings")) + db.commit() + try: + db.add(UiSettings(id=1, brand="#818cf8")) + db.commit() + colors = dict(theming.BUILTIN_COLORS) + colors["brand"] = "#818cf8" + tag = theming.theme_style_tag(colors) + response = client.get("/") + assert response.status_code == 200 + assert tag in response.text # the themed page serves the tag + assert response.headers["content-security-policy"] == ( + f"{CSP}; style-src 'self' '{theming.theme_csp_hash(tag)}'" + ) + assert "unsafe-inline" not in response.headers["content-security-policy"] + # The other two phase-82 headers ride along, unchanged. + assert response.headers["x-frame-options"] == "DENY" + assert response.headers["x-content-type-options"] == "nosniff" + finally: + db.execute(text("DELETE FROM ui_settings")) + db.commit() + # The UNthemed page after the row is gone: plain A1, no tag. + response = client.get("/") + assert response.headers["content-security-policy"] == CSP + assert "bor-theme" not in response.text diff --git a/tests/integration/test_ui_settings_api.py b/tests/integration/test_ui_settings_api.py new file mode 100644 index 0000000..9bae17a --- /dev/null +++ b/tests/integration/test_ui_settings_api.py @@ -0,0 +1,182 @@ +"""Integration: the admin UI-settings gate + the /api/config effective +strings (phase 91, task 01). + +The auth + public-contract half of task 01, driven through the real app +(TestClient keeps the cookie jar — the house ``test_auth_api`` / +``test_tokens_api`` admin-login pattern): + +* the admin gate — ``GET /api/ui-settings`` and ``PUT`` are 403 + ``admin only`` for anonymous callers AND for a signed-in token USER + (role ``"user"`` — the router-wide ``require_admin`` closes the + surface on every method, the phase-79 token matrix contract), 200 for + the admin on both; +* ``/api/config`` effective strings (B1: DB-over-env) — an env-only + deployment (no ``ui_settings`` row) returns the env strings; after an + admin PUT, the ANONYMOUS ``/api/config`` returns the DB strings; +* the five-key /api/config contract: the retired CSS-file theming's + ``theme`` key is GONE (task 03) — the app metadata, the docs flag, + and the two effective strings are the entire response. + +Real Postgres (``podman compose up -d db``); no LLM involved. + +Requires: podman compose up -d db +""" +from __future__ import annotations + +from collections.abc import Iterator + +import pytest +from fastapi.testclient import TestClient +from sqlalchemy import text +from sqlalchemy.orm import Session + +from app.config import get_settings +from app.core import theming +from app.main import app as fastapi_app +from tests.conftest import ADMIN_PASSWORD + + +@pytest.fixture(autouse=True) +def clean_state(db: Session) -> Iterator[None]: + """Both touched tables are global state: the single ui_settings row + and the api_tokens the token-user test creates (the house + TRUNCATE/DELETE reset pattern).""" + db.execute(text("DELETE FROM ui_settings")) + db.execute(text("TRUNCATE api_tokens")) + db.commit() + yield + db.execute(text("DELETE FROM ui_settings")) + db.execute(text("TRUNCATE api_tokens")) + db.commit() + + +def _admin_client() -> TestClient: + """A fresh client signed in as the admin (the ``_admin_client`` + pattern from test_auth_api.py).""" + c = TestClient(fastapi_app) + r = c.post("/api/login", json={"password": ADMIN_PASSWORD}) + assert r.status_code == 204, f"admin login failed: {r.status_code} {r.text}" + return c + + +def test_anonymous_get_and_put_403(client: TestClient) -> None: + """Router-level ``require_admin``: both routes are 403 ``admin only`` + for the unsigned-in caller (one fixed detail — no enumeration).""" + r = client.get("/api/ui-settings") + assert r.status_code == 403 + assert r.json() == {"detail": "admin only"} + r = client.put("/api/ui-settings", json={"app_name": "nope"}) + assert r.status_code == 403 + assert r.json() == {"detail": "admin only"} + + +def test_token_user_get_and_put_403(client: TestClient) -> None: + """A signed-in token USER (role ``"user"``) is NOT the admin: the + Theme tab's surface is closed to them on both methods (the + phase-79 token matrix contract — only the admin themes the + deployment).""" + admin = _admin_client() + r = admin.post("/api/tokens", json={"label": "alice"}) + assert r.status_code == 201, r.text + token = r.json()["token"] + assert client.post("/api/token-auth", json={"token": token}).status_code == 204 + assert client.get("/api/whoami").json() == {"authenticated": True, "role": "user"} + + r = client.get("/api/ui-settings") + assert r.status_code == 403 + assert r.json() == {"detail": "admin only"} + r = client.put("/api/ui-settings", json={"brand": "#123456"}) + assert r.status_code == 403 + assert r.json() == {"detail": "admin only"} + + +def test_admin_get_and_put_200(client: TestClient, db: Session) -> None: + """The admin passes the gate on both methods: GET reports the + effective defaults (row missing), PUT persists + reports the new + effective values, and a follow-up GET reads them back.""" + client.post("/api/login", json={"password": ADMIN_PASSWORD}) + + r = client.get("/api/ui-settings") + assert r.status_code == 200 + body = r.json() + assert set(body) == set(theming.STRING_FIELDS) | set(theming.COLOR_FIELDS) + assert body["app_name"] == get_settings().app_name + assert {k: body[k] for k in theming.COLOR_FIELDS} == theming.BUILTIN_COLORS + + r = client.put( + "/api/ui-settings", + json={"app_name": "Reese Brain", "brand": "#818cf8"}, + ) + assert r.status_code == 200, r.text + assert r.json()["app_name"] == "Reese Brain" + assert r.json()["brand"] == "#818cf8" + + r = client.get("/api/ui-settings") + assert r.status_code == 200 + assert r.json()["app_name"] == "Reese Brain" + assert r.json()["brand"] == "#818cf8" + # Untouched fields stay at their defaults (DB-over-env / -built-in). + assert r.json()["footer_text"] == get_settings().footer_text + assert r.json()["bg"] == theming.BUILTIN_COLORS["bg"] + + +def _config_keys() -> set[str]: + """The /api/config key set after task 03: the five phase-39/59/62 + keys — the retired CSS-file theming's ``theme`` key is gone.""" + return {"app_name", "version", "docs_repo_configured", + "input_placeholder", "footer_text"} + + +def test_api_config_env_only_deployment_returns_env_strings(client: TestClient) -> None: + """B1 with an empty ui_settings table: /api/config serves the ENV + strings (the code defaults — conftest pins them) and the key set is + the five-key contract (the retired theming's ``theme`` key is gone + — task 03).""" + r = client.get("/api/config") + assert r.status_code == 200 + body = r.json() + assert set(body) == _config_keys() + assert body["app_name"] == get_settings().app_name + assert body["input_placeholder"] == get_settings().input_placeholder + assert body["footer_text"] == get_settings().footer_text + + +def test_api_config_carries_no_theme_key(client: TestClient) -> None: + """Phase 91 (task 03): the retired CSS-file theming left NO trace + in the endpoint — the response has no ``theme`` key at all (an + env-only deployment and a themed one answer with the same keys; the + colors are injected pre-paint, they never ride this fetch).""" + r = client.get("/api/config") + assert r.status_code == 200 + assert "theme" not in r.json() + + +def test_api_config_returns_db_strings_after_admin_put( + client: TestClient, db: Session +) -> None: + """B1 with a set row: after an admin PUT, the ANONYMOUS /api/config + (the frontend's boot fetch — no admin needed) serves the DB strings + over the env values; the untouched fields keep the env values; the + five-key set is unchanged (the retired ``theme`` key is absent).""" + admin = _admin_client() + r = admin.put( + "/api/ui-settings", + json={ + "app_name": "Brain of Testy", + "input_placeholder": "Ask the vault…", + "footer_text": "Powered by my own models", + }, + ) + assert r.status_code == 200, r.text + + r = client.get("/api/config") + assert r.status_code == 200 # /api/config stays PUBLIC (no gate) + body = r.json() + assert set(body) == _config_keys() + assert body["app_name"] == "Brain of Testy" + assert body["input_placeholder"] == "Ask the vault…" + assert body["footer_text"] == "Powered by my own models" + # The colors never ride /api/config (the pre-paint injection is + # task 02; brand.js's surface is the five keys). + assert "theme" not in body + assert body["version"] == get_settings().app_version diff --git a/tests/unit/test_caching.py b/tests/unit/test_caching.py index 1073bdc..9a2dede 100644 --- a/tests/unit/test_caching.py +++ b/tests/unit/test_caching.py @@ -28,12 +28,17 @@ import pytest from fastapi import FastAPI, Request, Response from fastapi.responses import HTMLResponse, JSONResponse from fastapi.testclient import TestClient +from sqlalchemy import text +from sqlalchemy.orm import Session from starlette.responses import FileResponse from starlette.staticfiles import StaticFiles import app.core.caching as caching from app.config import Settings +from app.core import theming from app.core.caching import asset_version, rewrite_asset_refs +from app.core.security_headers import CSP +from app.models import UiSettings TOKEN = "abc123" @@ -212,6 +217,7 @@ def test_html_pages_include_history() -> None: "/git-sources.html", "/history.html", "/tokens.html", # phase 79 task 06: the admin tokens page (shell route) + "/theme.html", # phase 91 task 04: the admin theme page (shell route) "/shared.html", # phase 51: the shared page's static path "/doc-edit.html", # phase 59: the doc edit screen (task 06) ): @@ -718,3 +724,160 @@ def test_assets_path_keeps_validators_and_304( assert r304.status_code == 304 # versioned-URL 304s stay safe assert r304.content == b"" assert r304.headers["cache-control"] == caching.ASSET_CACHE_CONTROL + + +# --------------------------------------------------------------------------- +# Phase 91 (task 02): the pre-paint inline theme tag +# --------------------------------------------------------------------------- +# +# The middleware's rewrite branch now ALSO builds the theme tag from the +# effective ``ui_settings`` row (task 01's resolver — one short-lived +# session per response, no process cache) and inserts it before the +# first ``</head>``. Unset/defaults → ``tag == ""`` → the served bytes +# are EXACTLY the phase-33/54 rewrite-only output (B4's byte-identical +# contract); a DB blip is the same no-op (the page never breaks). + + +def _theme_page(name: str) -> str: + """The ``text/html`` body the fixture routes below serve.""" + return ( + f"<html><head><title>{name}" + '' + f"
{name}
" + ) + + +def _theme_page_app() -> FastAPI: + """A bare app with the middleware: the shell page at ``/`` plus a + second known page (``/document.html``) and the phase-51 dynamic + ``/shared/`` route (the prefix branch) — every served + ``text/html`` with one versionable asset ref.""" + app = FastAPI() + + @app.get("/", response_class=HTMLResponse) + def index() -> str: + return _theme_page("index") + + @app.get("/document.html", response_class=HTMLResponse) + def document() -> str: + return _theme_page("document") + + @app.get("/shared/{token}", response_class=HTMLResponse) + def shared(token: str) -> str: + return _theme_page(f"shared-{token}") + + caching.configure_caching(app) + return app + + +def test_middleware_unset_page_is_byte_identical_to_rewrite_only(db: Session) -> None: + """THE byte-identical contract (B4): with NO ``ui_settings`` row the + served body is EXACTLY the phase-33/54 rewrite-only output — not a + single byte differs, no ``#bor-theme`` anywhere.""" + db.execute(text("DELETE FROM ui_settings")) + db.commit() + client = TestClient(_theme_page_app()) + token = caching.asset_version() + for path, name in (("/", "index"), ("/document.html", "document")): + r = client.get(path) + assert r.status_code == 200 + assert r.headers["cache-control"] == "no-cache" + expected = caching.rewrite_asset_refs(_theme_page(name), token) + assert r.content == expected.encode("utf-8") # byte-identical + assert "bor-theme" not in r.text + # No tag → no style-src exemption: the response carries no CSP + # of its own (this bare app has no security-headers layer), so + # the outer middleware's plain A1 string stands untouched. + assert "content-security-policy" not in r.headers + + +def test_middleware_themed_injects_tag_before_head_on_every_page(db: Session) -> None: + """A ``ui_settings`` row with ONE changed color: every HTML page — + ``/``, the non-shell ``/document.html``, and the dynamic + ``/shared/`` (the prefix branch) — carries EXACTLY ONE + ``', r.text) + assert declared is not None + names = re.findall(r"--([a-z-]+):", declared.group(1)) + assert names == [k.replace("_", "-") for k in theming.COLOR_FIELDS] + assert "--brand:#818cf8;" in r.text + # Phase 91 (task 05): the inline tag is blocked by the + # phase-82 CSP in a real browser unless this response also + # carries the style-src exemption — the A1 string plus a + # sha256 hash of the EXACT tag content (the current theme + # is the only inline style ever permitted; no + # 'unsafe-inline'). + assert r.headers["content-security-policy"] == ( + f"{CSP}; style-src 'self' '{theming.theme_csp_hash(tag)}'" + ) + assert "unsafe-inline" not in r.headers["content-security-policy"] + # The phase-33/54 asset rewrite is untouched and applies too. + assert f'href="/assets/styles.css?v={token}"' in r.text + finally: + db.execute(text("DELETE FROM ui_settings")) + db.commit() + + +@pytest.mark.parametrize( + ("what",), + [("session",), ("resolver",)], + ids=["session-open-fails", "resolver-fails"], +) +def test_middleware_db_failure_serves_page_without_tag( + monkeypatch: pytest.MonkeyPatch, what: str +) -> None: + """A DB blip must NEVER break the page (loadHealth house style): + whether the session fails to open or the row read raises, the page + still 200s with the byte-identical rewrite-only body (no tag) and + the no-cache contract intact — a pre-migration boot is the same + path.""" + if what == "session": + + def _boom_session() -> object: + raise RuntimeError("db down") + + monkeypatch.setattr(caching, "SessionLocal", _boom_session) + else: + + def _boom_resolver(session: object) -> dict[str, str]: + raise RuntimeError("select failed") + + monkeypatch.setattr(caching.theming, "effective_settings", _boom_resolver) + client = TestClient(_theme_page_app()) + r = client.get("/") + assert r.status_code == 200 + assert r.headers["cache-control"] == "no-cache" + token = caching.asset_version() + assert r.content == caching.rewrite_asset_refs( + _theme_page("index"), token + ).encode("utf-8") + assert "bor-theme" not in r.text + # The DB-failure fallback is the UNSET shape: no tag, no style-src + # exemption (the plain A1 policy stands — the page degrades to the + # built-in palette, never to an inline-style exemption for a tag + # that is not there). + assert "content-security-policy" not in r.headers diff --git a/tests/unit/test_config.py b/tests/unit/test_config.py index ff46f58..ad6cd72 100644 --- a/tests/unit/test_config.py +++ b/tests/unit/test_config.py @@ -481,70 +481,25 @@ def test_docs_branchs_garbage_ignored_when_repo_unset( def test_ui_customization_defaults_are_the_phase_61_copy() -> None: """UNSET => byte-identical to the phase-61 neutral UI: the locked - phase-61 copy is the DEFAULT (composer placeholder + footer line), - and an empty theme = the built-in dark-tech palette.""" + phase-61 copy is the DEFAULT (composer placeholder + footer line). + Phase 91 (task 03): the retired CSS-file theme env var is gone — + ``Settings`` no longer has a theme field at all (a leftover value + in a deployment's .env is ignored, not a boot failure).""" s = _settings() assert s.input_placeholder == "Ask me anything…" assert s.footer_text == "Powered by self-hosted models" - assert s.theme == "" + assert "theme" not in type(s).model_fields def test_ui_customization_env_overrides(monkeypatch: pytest.MonkeyPatch) -> None: - """The three settings honor their ``BOR_`` env vars - (``BOR_INPUT_PLACEHOLDER`` / ``BOR_FOOTER_TEXT`` / ``BOR_THEME``); + """The two string settings honor their ``BOR_`` env vars + (``BOR_INPUT_PLACEHOLDER`` / ``BOR_FOOTER_TEXT``); placeholder/footer accept any string (empty is legal — the brand layer then keeps the template default).""" monkeypatch.setenv("BOR_INPUT_PLACEHOLDER", "Ask the vault…") monkeypatch.setenv("BOR_FOOTER_TEXT", "Powered by my own models") - monkeypatch.setenv("BOR_THEME", "indigo.css") s = _settings() assert s.input_placeholder == "Ask the vault…" assert s.footer_text == "Powered by my own models" - assert s.theme == "indigo.css" monkeypatch.setenv("BOR_INPUT_PLACEHOLDER", "") assert _settings().input_placeholder == "" # empty stands - - -def test_theme_validator_accepts_empty_and_bare_css_filename() -> None: - """Phase 62 (A5): empty = the built-in palette; a bare lowercase - ``.css`` filename (the ``indigo.css`` example) is the only - non-empty shape — dashes/underscores/digits are legal tokens.""" - assert _settings().theme == "" # "" passes - assert _settings(theme="indigo.css").theme == "indigo.css" - assert _settings(theme="dark-2026_v2.css").theme == "dark-2026_v2.css" - - -@pytest.mark.parametrize( - ("bad", "match"), - [ - # uppercase — the shape is lowercase-only - ("Indigo.css", "Indigo.css"), - # path escape — a theme is a filename, never a path - ("../evil.css", r"\.\./evil\.css"), - ("a/b.css", r"a/b\.css"), - ("/abs.css", r"got '/abs\.css'"), - # a missing extension is not a theme file - ("indigo", r"got 'indigo'"), # must not match the example text - ], -) -def test_theme_validator_rejects_malformed_naming_the_value( - bad: str, - match: str, -) -> None: - """A typo in ``BOR_THEME`` must kill startup, not silently 404 at - runtime — the rejection names the offending value (the phase-56 - fail-loud house style) alongside the allowed shape.""" - with pytest.raises(ValidationError, match=match): - _settings(theme=bad) - - -def test_bor_theme_env_malformed_fails_startup_naming_value( - monkeypatch: pytest.MonkeyPatch, -) -> None: - """The startup path: a malformed ``BOR_THEME`` in the environment - fails Settings construction loudly (the app builds its settings at - import time, so this is a refused boot), naming the value — the - E2E boots-check lands in task 05.""" - monkeypatch.setenv("BOR_THEME", "../evil.css") - with pytest.raises(ValidationError, match=r"\.\./evil\.css"): - _settings() diff --git a/tests/unit/test_frontend_brand.py b/tests/unit/test_frontend_brand.py index 7a53c5d..6a26892 100644 --- a/tests/unit/test_frontend_brand.py +++ b/tests/unit/test_frontend_brand.py @@ -99,14 +99,16 @@ def test_brand_js_reskins_title_brand_text_prose_and_attributes() -> None: def test_brand_js_applies_phase_62_customization_from_the_same_fetch() -> None: """Phase 62 (owner-locked 2026-09-01, TODO L3): the SAME settled - /api/config answer also drives the three customization keys — the - #message-input placeholder, every .footer-text node, and the theme - stylesheet link (inserted right after the styles.css link, guarded - by #theme-override, degrading with a console.warn on 404 — A5). + /api/config answer also drives the two customization STRING keys — + the #message-input placeholder and every .footer-text node. Phase + 91 (task 03): the retired CSS-file theme link (the old step 7) + and its id-guard / styles.css-finder / onerror-degrade machinery + are GONE — color theming is server-side inline injection + (app/core/theming.py), never a brand.js DOM write. No second network call: the keys ride the existing boot fetch.""" js = _text(BRAND_JS) assert js.count('= fetch("/api/config"') == 1, ( - "the three keys must ride the existing boot fetch — no new call" + "the keys must ride the existing boot fetch — no new call" ) # 5. The composer placeholder (chat page only — the null guard # no-ops on every other page). @@ -116,28 +118,14 @@ def test_brand_js_applies_phase_62_customization_from_the_same_fetch() -> None: # textContent: an operator string can't inject markup. assert 'document.querySelectorAll(".footer-text")' in js assert "footer_text" in js - # 7. The theme link: /assets/themes/, inserted right after - # the styles.css link, tagged #theme-override (the idempotency - # guard), with the A5 degradation warn. - assert '"/assets/themes/"' in js - assert 'link.id = "theme-override"' in js - assert 'getElementById("theme-override")' in js - assert 'insertAdjacentElement("afterend", link)' in js - assert "link.onerror" in js - assert '"brand: theme " + themeName' in js - # The styles.css finder must survive the phase-33/54 cache-bust - # rewrite: the SERVED HTML carries the asset ref with a - # ?v= query (and el.href is the absolute URL), so the match - # has to run on the RAW attribute path with query/fragment - # stripped — el.href.endsWith(…) would silently skip the insertion - # (the theme never applied; found by the task-05 E2E). - assert 'getAttribute("href")' in js - assert "split(/[?#]/)[0]" in js - assert 'endsWith("styles.css")' in js - assert "el.href.endsWith" not in js + # Phase 91 (task 03): the retired theme-link machinery is absent — + # no theme key read, no link insertion (the whole step-7 block + # lived inside the themeName guard — themeName gone, block gone). + assert "themeName" not in js + assert 'insertAdjacentElement' not in js # The empty-skip no-op contract: each key is guarded before any # DOM write, so an unset deployment stays byte-identical. - for guard in ("if (placeholder) {", "if (footerText) {", "if (themeName) {"): + for guard in ("if (placeholder) {", "if (footerText) {"): assert guard in js, ( f"an empty value must skip its application ({guard})" ) diff --git a/tests/unit/test_frontend_router.py b/tests/unit/test_frontend_router.py index 28b0b08..2decd99 100644 --- a/tests/unit/test_frontend_router.py +++ b/tests/unit/test_frontend_router.py @@ -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- 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']*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 'Actions' 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('
", 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("", 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']*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 "

Sign in to change the theme

" 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']*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']*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']*for="{field_id}"[^>]*>', body + ), f"missing the visible label for #{field_id}" + assert re.search( + rf']*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']*for="{field_id}"[^>]*>', body + ), f"missing the visible label for #{field_id}" + assert re.search( + rf']*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']*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']*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']*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" + ) diff --git a/tests/unit/test_models.py b/tests/unit/test_models.py index 6f78dea..172c93e 100644 --- a/tests/unit/test_models.py +++ b/tests/unit/test_models.py @@ -18,6 +18,33 @@ def test_all_tables_registered() -> None: assert "documents" in tables assert "chunks" in tables assert "query_log" in tables + assert "ui_settings" in tables # phase 91: the single-row UI settings + + +def test_ui_settings_single_row_nullable_contract() -> None: + """Phase 91: the single-row UI settings table — Integer PK ``id`` + with the Python-side ``default=1`` (the row is always id 1), the 3 + strings VARCHAR(300) and the 8 identity colors VARCHAR(7), ALL + nullable (NULL = default — B1: env value for the strings, the + built-in palette for the colors).""" + settings_table = Base.metadata.tables["ui_settings"] + assert set(settings_table.c.keys()) == { + "id", "app_name", "input_placeholder", "footer_text", + "bg", "surface", "ink", "ink_soft", "line", + "brand", "brand_soft", "brand_ink", + } + pk = settings_table.c["id"] + assert pk.primary_key is True, "ui_settings.id must be the PK" + assert pk.default is not None, "id needs the Python-side default=1" + for name in ("app_name", "input_placeholder", "footer_text"): + col = settings_table.c[name] + assert col.nullable is True, f"{name} must be NULL (env default)" + assert getattr(col.type, "length", None) == 300, f"{name} must be String(300)" + for name in ("bg", "surface", "ink", "ink_soft", "line", + "brand", "brand_soft", "brand_ink"): + col = settings_table.c[name] + assert col.nullable is True, f"{name} must be NULL (the built-in)" + assert getattr(col.type, "length", None) == 7, f"{name} must be String(7) — #rrggbb" def test_chunks_embedding_is_vector_768() -> None: diff --git a/tests/unit/test_save_as_doc_button.py b/tests/unit/test_save_as_doc_button.py index 4778324..8bdd22f 100644 --- a/tests/unit/test_save_as_doc_button.py +++ b/tests/unit/test_save_as_doc_button.py @@ -51,11 +51,13 @@ def test_app_config_dict_carries_the_docs_flag() -> None: s = _settings() body = app_config(s) - # Phase 62 (task 01): the response grew to the six-key set — the - # phase-62 UI customization keys ride the SAME endpoint. + # Phase 62 (task 01): the response grew to the phase-62 UI + # customization keys; phase 91 (task 03) deleted the retired + # CSS-file theming's ``theme`` key — the five keys below are the + # entire endpoint contract. assert set(body) == { "app_name", "version", "docs_repo_configured", - "input_placeholder", "footer_text", "theme", + "input_placeholder", "footer_text", } assert body["docs_repo_configured"] is s.docs_configured assert body["docs_repo_configured"] is False diff --git a/tests/unit/test_security_headers.py b/tests/unit/test_security_headers.py index aceee8c..1dca11b 100644 --- a/tests/unit/test_security_headers.py +++ b/tests/unit/test_security_headers.py @@ -149,6 +149,32 @@ def test_404_shaped_response_carries_all_three_headers() -> None: assert body_msg["body"] == b"not found" +def test_pre_existing_csp_from_an_inner_layer_is_preserved() -> None: + """Phase 91 (task 05): the caching middleware publishes, on themed + HTML pages only, the A1 string EXTENDED with a ``style-src`` sha256 + hash for the inline theme tag (the A1 policy would block the tag in + every real browser). A CSP an inner layer has already set is that + layer's deliberate one and must survive the outer middleware — + while the other two headers are still added.""" + themed = ( + "default-src 'self'; base-uri 'none'; frame-ancestors 'none'; " + "style-src 'self' 'sha256-2rm3wPcQfXmE8q1s9vBzK7hN4tY5uJ6gW3oR0cAeDfH='" + ) + wrapped = SecurityHeadersMiddleware( + _plain_app( + 200, + b"", + headers=[[b"content-security-policy", themed.encode("ascii")]], + ) + ) + sent = _drive(wrapped, _http_scope()) + + start = sent[0] + assert _header(start, "content-security-policy") == themed # not clobbered + assert _header(start, "x-frame-options") == "DENY" + assert _header(start, "x-content-type-options") == "nosniff" + + # --------------------------------------------------------------------------- # The SSE streaming passthrough pin # --------------------------------------------------------------------------- diff --git a/tests/unit/test_themes.py b/tests/unit/test_themes.py deleted file mode 100644 index 078c805..0000000 --- a/tests/unit/test_themes.py +++ /dev/null @@ -1,195 +0,0 @@ -"""Unit: the phase-62 example theme (``frontend/assets/themes/``) and -the Containerfile line that ships it (A7). - -No Python logic exists for this task — the mechanism lives in -``brand.js`` (pinned by test_frontend_brand.py) and the theme is a -drop-in stylesheet. Like the other frontend-adjacent unit files, this -module pins the assets as text, so a silent regression (a theme file -gaining a selector, a declaration drifting, the Containerfile line -vanishing) is caught without a browser. The browser-visible layer -(computed ``--brand``, the inserted ````) is E2E-gated by -``tests/e2e/test_ui_customization.py`` (task 05). -""" -from __future__ import annotations - -import re -from pathlib import Path - -REPO_ROOT = Path(__file__).resolve().parents[2] -FRONTEND = REPO_ROOT / "frontend" -THEMES = FRONTEND / "assets" / "themes" -INDIGO = THEMES / "indigo.css" -GUIDE = THEMES / "README.md" -CONTAINERFILE = REPO_ROOT / "Containerfile" - -#: The 8 identity variables a theme may override — and the EXACT set -#: indigo.css ships (the semantic families accent/ok/err are states, -#: not identity: a theme that overrides them stops being honest). -IDENTITY_VARS = ( - "--bg", - "--surface", - "--ink", - "--ink-soft", - "--line", - "--brand", - "--brand-soft", - "--brand-ink", -) - -INDIGO_VALUES: dict[str, str] = { - "--bg": "#0a0e1a", - "--surface": "#111726", - "--ink": "#e6e9f0", - "--ink-soft": "#a8b0c8", - "--line": "#232c44", - "--brand": "#818cf8", - "--brand-soft": "#1a1f38", - "--brand-ink": "#c7d2fe", -} - - -def _text(path: Path) -> str: - return path.read_text(encoding="utf-8") - - -def _strip_comments(css: str) -> str: - """Drop ``/* … */`` comments — the pins assert against declarations, - not prose.""" - return re.sub(r"/\*.*?\*/", "", css, flags=re.S) - - -def _declarations(css: str) -> dict[str, str]: - """The ``--name: value`` declarations of the (single) ``:root`` - block, in file order.""" - return dict(re.findall(r"(--[a-z-]+)\s*:\s*([^;]+);", css)) - - -def test_example_theme_files_exist() -> None: - """The example theme and its authoring guide ship in the static - dir (served at /assets/themes/… in dev AND in the image).""" - assert INDIGO.is_file(), f"missing example theme: {INDIGO}" - assert GUIDE.is_file(), f"missing authoring guide: {GUIDE}" - - -def test_indigo_starts_with_a_single_root_block_and_nothing_else() -> None: - """The whole file is ONE ``:root`` block (the cascade is the entire - mechanism): after stripping comments the first non-whitespace - content is ``:root``, and no other rule, selector, or declaration - exists anywhere in the file.""" - css = _strip_comments(_text(INDIGO)) - assert css.lstrip().startswith(":root"), ( - "indigo.css must start with the :root block (after its header " - "comment) — nothing may precede it" - ) - assert re.fullmatch(r"\s*:root\s*\{[^{}]*\}\s*", css, re.S) is not None, ( - "indigo.css must be exactly one :root block — no selectors, " - "no @media, no nested or extra rules" - ) - - -def test_indigo_overrides_exactly_the_eight_identity_variables() -> None: - """EXACTLY the 8 identity overrides with the locked values — no - other declarations (a 9th declaration here would be the theme - reaching past the palette), and the semantic families - (accent/ok/err) must be untouched (they encode states).""" - decls = _declarations(_strip_comments(_text(INDIGO))) - assert set(decls) == set(IDENTITY_VARS), ( - f"indigo.css must override exactly the 8 identity variables, got " - f"{sorted(decls)}" - ) - for name in IDENTITY_VARS: - assert decls[name].strip() == INDIGO_VALUES[name], ( - f"{name} drifted from the locked value " - f"{INDIGO_VALUES[name]!r}, got {decls[name].strip()!r}" - ) - for family in ("--accent-", "--ok-", "--err-"): - assert not any(k.startswith(family) for k in decls), ( - f"semantic {family}* variables must stay the built-in " - f"theme (they encode states)" - ) - - -def test_indigo_identity_pairs_meet_wcag_aa() -> None: - """The five identity text/background pairs, computed from the file's - OWN hex values (not re-typed), each meet WCAG 2.1 AA (>= 4.5:1) — - AGENTS.md rule 5. The pairs are the ones the layout actually pairs: - ink on bg/surface, ink-soft on surface, the dark bg ink on brand - (text on brand buttons is --bg, never white — the built-in's - documented 3.7:1 trap), brand-ink on surface.""" - decls = _declarations(_strip_comments(_text(INDIGO))) - - def lum(hexcolor: str) -> float: - h = hexcolor.lstrip("#") - chans = (int(h[i : i + 2], 16) / 255.0 for i in (0, 2, 4)) - lin = [ - c / 12.92 if c <= 0.04045 else ((c + 0.055) / 1.055) ** 2.4 - for c in chans - ] - r, g, b = lin - return 0.2126 * r + 0.7152 * g + 0.0722 * b - - def ratio(fg: str, bg: str) -> float: - l1, l2 = lum(fg), lum(bg) - return (max(l1, l2) + 0.05) / (min(l1, l2) + 0.05) - - pairs = ( - ("ink on bg", decls["--ink"], decls["--bg"]), - ("ink on surface", decls["--ink"], decls["--surface"]), - ("ink-soft on surface", decls["--ink-soft"], decls["--surface"]), - ("bg ink on brand", decls["--bg"], decls["--brand"]), - ("brand-ink on surface", decls["--brand-ink"], decls["--surface"]), - ) - for name, fg, bg in pairs: - r = ratio(fg, bg) - assert r >= 4.5, f"{name}: {r:.2f}:1 < 4.5:1 (WCAG 2.1 AA)" - - -def test_containerfile_ships_the_whole_themes_directory() -> None: - """A7: stage 1 copies the WHOLE themes directory (no per-file - esbuild — a future theme file needs no Containerfile edit), and it - does so AFTER the styles.css minify line (so the served /assets/ - tree is complete before the pages cp).""" - cf = _text(CONTAINERFILE) - stage1 = cf.split("AS frontend", 1)[1].split("\nFROM", 1)[0] - lines = stage1.splitlines() - cp_idxs = [ - i - for i, ln in enumerate(lines) - if re.search(r"\bcp\s+-r\s+\./assets/themes\s+/out/assets/themes\b", ln) - ] - assert len(cp_idxs) == 1, ( - "stage 1 must ship the themes directory with exactly one " - "'cp -r ./assets/themes /out/assets/themes' line" - ) - styles_idxs = [ - i for i, ln in enumerate(lines) if "esbuild ./assets/styles.css" in ln - ] - assert len(styles_idxs) == 1, "stage 1 must minify styles.css" - assert cp_idxs[0] > styles_idxs[0], ( - "the themes cp must come AFTER the styles.css minify line" - ) - cp_line = lines[cp_idxs[0]] - assert "--bundle" not in cp_line and "esbuild" not in cp_line, ( - "A7: the themes directory is copied verbatim — no per-file " - "esbuild minify" - ) - - -def test_authoring_guide_pins_the_contract() -> None: - """The guide documents the load path (BOR_THEME → /api/config → - brand.js link after styles.css), the filename validator regex, the - 8-variable table, the 4.5:1 bar, the never-white-on-brand trap, - and the A7 rebuild story (a new file needs no Containerfile edit).""" - guide = _text(GUIDE) - for marker in ( - "BOR_THEME", - "/api/config", - "styles.css", - r"^[a-z0-9_-]+\.css$", - "4.5:1", - "white-on-brand", - "cp -r ./assets/themes /out/assets/themes", - ): - assert marker in guide, f"themes/README.md must document {marker!r}" - for var in IDENTITY_VARS: - assert var in guide, f"the variable table must list {var}" diff --git a/tests/unit/test_theming.py b/tests/unit/test_theming.py new file mode 100644 index 0000000..5bafa49 --- /dev/null +++ b/tests/unit/test_theming.py @@ -0,0 +1,310 @@ +"""Unit: the built-in identity palette + the effective-settings resolver +(phase 91, task 01). + +Covers ``app/core/theming.py`` — the single source of the built-in +identity palette (re-homed from the retired phase-62 CSS-file themes' +authoring guide before task 03 deleted it) and the DB-over-env / +DB-over-built-in resolver shared by ``/api/ui-settings`` and +``/api/config``: + +* ``BUILTIN_COLORS`` — the DRIFT GUARD: the 8 built-ins must equal the + values parsed straight out of ``frontend/assets/styles.css``'s + ``:root`` block, so the Python palette and the stylesheet can never + silently diverge; +* ``theme_style_tag`` — the byte-identical contract (all built-in → + ``""``) and the exact tag shape (all 8 variables, ``COLOR_FIELDS`` + order, lowercased hex); +* ``effective_settings`` — missing row → env strings + built-ins; a DB + row's set columns win; an empty-string DB string falls back to env + (the resolver treats "" as unset, B1). House DB-test pattern (the + ``test_tokens`` precedent): the real compose Postgres, skipped with + clear instructions when the stack is not up. +""" +from __future__ import annotations + +import base64 +import hashlib +import re +from pathlib import Path +from typing import Any + +from sqlalchemy import select +from sqlalchemy.orm import Session + +from app.config import Settings +from app.core import theming +from app.models import UiSettings + +REPO_ROOT = Path(__file__).resolve().parents[2] +STYLES_CSS = REPO_ROOT / "frontend" / "assets" / "styles.css" + + +def _delete_row() -> Any: + from sqlalchemy import delete + + return delete(UiSettings).where(UiSettings.id == 1) + + +def _root_declarations() -> dict[str, str]: + """The ``--name: value`` declarations of styles.css's (first) + ``:root`` block, comments stripped, in file order.""" + css = STYLES_CSS.read_text(encoding="utf-8") + match = re.search(r":root\s*\{", css) + assert match is not None, "styles.css must have a :root block" + block = css[match.end() : css.index("}", match.end())] + block = re.sub(r"/\*.*?\*/", "", block, flags=re.S) + return dict(re.findall(r"(--[a-z-]+)\s*:\s*([^;]+);", block)) + + +def test_builtin_colors_match_styles_css_root() -> None: + """The drift guard: every built-in equals the stylesheet's ``:root`` + value for the same variable (and ``BUILTIN_COLORS`` names exactly + the 8 identity variables — no more, no fewer).""" + decls = _root_declarations() + builtin_names = set(theming.BUILTIN_COLORS) + assert builtin_names == { + "bg", "surface", "ink", "ink_soft", "line", + "brand", "brand_soft", "brand_ink", + }, f"BUILTIN_COLORS must name exactly the 8 identity variables, got {sorted(builtin_names)}" + for name, value in theming.BUILTIN_COLORS.items(): + css_name = f"--{name.replace('_', '-')}" + assert css_name in decls, f"styles.css :root is missing {css_name}" + assert decls[css_name].strip() == value, ( + f"{css_name} drifted: BUILTIN_COLORS has {value!r}, " + f"styles.css has {decls[css_name].strip()!r}" + ) + + +def test_color_fields_are_the_eight_keys_in_readme_order() -> None: + """``COLOR_FIELDS`` is the 8 keys in the themes-README order — the + order the resolver, the API, and the tag renderer all rely on.""" + assert theming.COLOR_FIELDS == ( + "bg", "surface", "ink", "ink_soft", + "line", "brand", "brand_soft", "brand_ink", + ) + assert theming.STRING_FIELDS == ("app_name", "input_placeholder", "footer_text") + + +def _env_settings() -> Settings: + """An explicit env source (the resolver's optional ``settings`` + parameter) — deterministic values, independent of the local ``.env`` + (the ``/api/config`` env pins in test_api.py own the env-file + behaviour; this unit module only needs stable fallbacks).""" + return Settings( + app_name="Env Name", + input_placeholder="Env placeholder…", + footer_text="Env footer", + ) + + +# ---------- effective_settings (real Postgres — house DB-test pattern) ---------- + + +def test_effective_missing_row_is_env_strings_plus_builtins(db: Session) -> None: + """A missing row (GET creates nothing) means "defaults": the env + strings + the built-in palette, all 11 keys.""" + row = db.execute(select(UiSettings).where(UiSettings.id == 1)).scalars().first() + assert row is None, "the test starts from a row-missing state" + effective = theming.effective_settings(db, _env_settings()) + assert set(effective) == set(theming.STRING_FIELDS) | set(theming.COLOR_FIELDS) + assert effective["app_name"] == "Env Name" + assert effective["input_placeholder"] == "Env placeholder…" + assert effective["footer_text"] == "Env footer" + assert {k: effective[k] for k in theming.COLOR_FIELDS} == theming.BUILTIN_COLORS + + +def test_effective_db_row_wins_column_by_column(db: Session) -> None: + """Set columns win, unset columns fall back — per column, so a + partial row (only ``bg`` set) mixes the DB color with the built-ins + and the env strings.""" + db.add(UiSettings(id=1, bg="#111111", app_name="DB Name")) + db.commit() + try: + effective = theming.effective_settings(db, _env_settings()) + assert effective["bg"] == "#111111" # DB wins + assert effective["app_name"] == "DB Name" # DB wins + # Unset columns: env strings + the built-in colors. + assert effective["input_placeholder"] == "Env placeholder…" + assert effective["footer_text"] == "Env footer" + for key in theming.COLOR_FIELDS: + if key != "bg": + assert effective[key] == theming.BUILTIN_COLORS[key] + finally: + db.execute(_delete_row()) + db.commit() + + +def test_effective_empty_string_db_string_falls_back_to_env(db: Session) -> None: + """B1: an EMPTY string in the DB is unset — the resolver falls back + to the env value (a hand-edited row with '' can't blank the UI). + Colors: ``None`` → the built-in (an empty color is impossible through + the API — the hex validator — the resolver's not-None rule covers + the hand-edited edge by returning whatever the row holds).""" + db.add(UiSettings(id=1, app_name="")) + db.commit() + try: + effective = theming.effective_settings(db, _env_settings()) + assert effective["app_name"] == "Env Name" # "" → env fallback + assert effective["footer_text"] == "Env footer" + assert effective["brand"] == theming.BUILTIN_COLORS["brand"] # None → built-in + finally: + db.execute(_delete_row()) + db.commit() + + +def test_effective_without_explicit_settings_uses_get_settings(db: Session) -> None: + """``settings=None`` (the design's call shape) resolves the env + fallback from the cached :func:`app.config.get_settings` — the + values it reports must be real ``str``s for all 11 keys.""" + from app.config import get_settings + + effective = theming.effective_settings(db) + assert set(effective) == set(theming.STRING_FIELDS) | set(theming.COLOR_FIELDS) + assert effective["app_name"] == get_settings().app_name + for field in theming.STRING_FIELDS: + assert isinstance(effective[field], str) and effective[field] + assert all(re.fullmatch(r"#[0-9a-f]{6}", effective[k]) for k in theming.COLOR_FIELDS) + assert {k: effective[k] for k in theming.COLOR_FIELDS} == theming.BUILTIN_COLORS + + +# ---------- theme_style_tag (pure) ---------- + + +def test_theme_style_tag_all_builtins_is_empty_string() -> None: + """The byte-identical contract: an unset (or "defaults saved") + deployment serves NO tag — exactly the pre-phase-91 HTML.""" + assert theming.theme_style_tag(dict(theming.BUILTIN_COLORS)) == "" + # The tag is PURE string-equality: uppercase hex is NOT the built-in + # (the API's lowercasing-before-store is what makes stored hex + # canonical — pin the renderer's own contract here). + colors = {k: v.upper() for k, v in theming.BUILTIN_COLORS.items()} + assert theming.theme_style_tag(colors) != "" + + +def test_theme_style_tag_one_changed_carries_all_eight_in_order() -> None: + """A single non-built-in color still emits ALL 8 variables, in + ``COLOR_FIELDS`` order, with the exact tag shape (no whitespace).""" + colors = dict(theming.BUILTIN_COLORS) + colors["brand"] = "#818cf8" + tag = theming.theme_style_tag(colors) + assert tag == ( + '" + ) + # The changed value lands under the dashed CSS name… + assert "--brand:#818cf8;" in tag + # …and the underscored field (ink_soft) renders as --ink-soft. + assert "--ink-soft:#b8a8a8;" in tag + assert "--ink_soft" not in tag + + +def test_theme_style_tag_multiple_changed() -> None: + """Two changed colors: both values present, the rest built-in, order + unchanged (the tag is a complete :root override — the page never + mixes a partial palette).""" + colors = dict(theming.BUILTIN_COLORS) + colors["bg"] = "#0a0e1a" + colors["brand_ink"] = "#c7d2fe" + tag = theming.theme_style_tag(colors) + assert tag.startswith('") + # The order of the 8 dashed names is the COLOR_FIELDS order. + names = re.findall(r"--([a-z-]+):", tag) + assert names == [k.replace("_", "-") for k in theming.COLOR_FIELDS] + + +# ---------- inject_theme (pure — task 02's injection helper) ---------- + +_HEAD_HTML = "t

b

" + + +def test_inject_theme_empty_tag_is_identity() -> None: + """``tag == ""`` (the unset / "defaults saved" deployment — what + ``theme_style_tag" returns for all-built-in colors) → the html is + returned EXACTLY as passed in, byte for byte (B4's no-op + contract).""" + assert theming.inject_theme(_HEAD_HTML, "") == _HEAD_HTML + # Whitespace is NOT an empty tag — a real tag is always inserted. + assert theming.inject_theme(_HEAD_HTML, " ") != _HEAD_HTML + + +def test_inject_theme_missing_head_is_identity() -> None: + """No ```` occurrence → unchanged (nothing to anchor to); + the empty string (no ```` either) is identity too.""" + tag = '' + html = "no head" + assert theming.inject_theme(html, tag) == html + assert theming.inject_theme("", tag) == "" + + +def test_inject_theme_exact_placement_before_first_head_close() -> None: + """The tag lands with a leading newline immediately BEFORE the + first ```` — nothing between the tag and the close, nothing + moved after it.""" + tag = '' + html = "tafter" + assert theming.inject_theme(html, tag) == ( + "t\n" + tag + "after" + ) + # A LATER ````-shaped stretch of text is not the anchor — the + # FIRST occurrence wins (the one that closes the real head). + html2 = "" + assert theming.inject_theme(html2, tag) == ( + "\n" + tag + "" + ) + + +def test_inject_theme_double_injection_is_idempotent() -> None: + """The defensive idempotence rule keys on the id: once a + ``id="bor-theme"`` tag is present the helper is the identity — the + page can never carry two theme tags, even for a different tag.""" + tag = '' + once = theming.inject_theme(_HEAD_HTML, tag) + assert once.count('id="bor-theme"') == 1 + assert theming.inject_theme(once, tag) == once + other = '' + assert theming.inject_theme(once, other) == once + + +# --------------------------------------------------------------------------- +# Phase 91 (task 05, defect fix): theme_csp_hash — the CSP3 hash of the +# inline tag's content (the phase-82 CSP would otherwise BLOCK the tag +# in every real browser; the caching middleware publishes the hash as a +# style-src exemption on themed HTML pages only). +# --------------------------------------------------------------------------- + + +def test_theme_csp_hash_empty_tag_is_empty_string() -> None: + """No tag (unset/defaults deployment) → no hash — the plain A1 + policy stands and the header stays byte-identical to pre-91.""" + assert theming.theme_csp_hash("") == "" + + +def test_theme_csp_hash_is_sha256_of_the_tag_content() -> None: + """CSP3 §13.4: the hash covers the character data BETWEEN the tags + (the ``:root{…}`` declarations — the rendered content carries no + leading/trailing whitespace, so no stripping applies), base64 + after SHA-256, ``sha256-`` prefixed.""" + tag = '' + expected = "sha256-" + base64.b64encode( + hashlib.sha256(b":root{--bg:#111111}").digest() + ).decode("ascii") + assert theming.theme_csp_hash(tag) == expected + + +def test_theme_csp_hash_changes_with_the_palette() -> None: + """A different palette → a different hash: the browser keeps + blocking the OLD tag once the theme changes (the exemption always + matches exactly the served bytes, never a stale palette).""" + colors = dict(theming.BUILTIN_COLORS) + colors["brand"] = "#818cf8" + first = theming.theme_csp_hash(theming.theme_style_tag(colors)) + colors["brand"] = "#22c55e" + second = theming.theme_csp_hash(theming.theme_style_tag(colors)) + assert first + assert first != second + assert first.startswith("sha256-") + assert second.startswith("sha256-") diff --git a/tests/unit/test_ui_settings.py b/tests/unit/test_ui_settings.py new file mode 100644 index 0000000..c51d1f0 --- /dev/null +++ b/tests/unit/test_ui_settings.py @@ -0,0 +1,193 @@ +"""Unit: the admin UI-settings API (phase 91, task 01). + +Covers ``app/api/ui_settings.py`` — the PUT validation + normalization +contract and the GET/PUT persistence on the single ``ui_settings`` row: + +* PUT validation — the 422s NAME the offending field (fixed details): + a >300-char string after the trim, a non-``#rrggbb`` color (wrong + prefix, 3-digit shorthand, 8 hex chars, missing ``#``); +* normalization — colors are lowercased on store; a color EQUAL to its + built-in is stored as NULL (the owner-locked rule: "save the defaults" + must leave the row empty — the no-op injection contract); an empty / + whitespace-only string is the clear operation (NULL); +* GET — the effective merge (a partial row reports the DB values over + the env/built-in defaults); +* upsert — the first PUT CREATES the id-1 row, the second UPDATES that + same row (one row, always id 1). + +House pattern (the ``test_tokens_api`` precedent): the real app via +TestClient (cookie jar = the house admin-login fixture) against the real +compose Postgres; the single row is global state, so an autouse fixture +resets it around every test. +""" +from __future__ import annotations + +from collections.abc import Iterator + +import pytest +from fastapi.testclient import TestClient +from sqlalchemy import select, text +from sqlalchemy.orm import Session + +from app.config import get_settings +from app.core import theming +from app.models import UiSettings + +ALL_NULL_BODY: dict[str, str | None] = { + "app_name": None, "input_placeholder": None, "footer_text": None, + "bg": None, "surface": None, "ink": None, "ink_soft": None, + "line": None, "brand": None, "brand_soft": None, "brand_ink": None, +} + + +@pytest.fixture(autouse=True) +def clean_ui_settings(db: Session) -> Iterator[None]: + """ui_settings holds ONE row of global state: reset it around every + test (the ``clean_tokens`` house pattern, DELETE — the row is + created only by the PUT upsert, so "absent" is the natural + pristine state).""" + db.execute(text("DELETE FROM ui_settings")) + db.commit() + yield + db.execute(text("DELETE FROM ui_settings")) + db.commit() + + +def _row(db: Session) -> UiSettings | None: + return db.execute(select(UiSettings).where(UiSettings.id == 1)).scalars().first() + + +def test_put_too_long_string_422_names_the_field( + admin_client: TestClient, db: Session +) -> None: + """Each of the 3 strings: >300 chars AFTER the trim is a 422 naming + that field; a rejected PUT half-writes nothing; exactly 300 still + passes (the column is VARCHAR(300)).""" + for field in theming.STRING_FIELDS: + r = admin_client.put("/api/ui-settings", json={field: "x" * 301}) + assert r.status_code == 422, (field, r.text) + assert r.json()["detail"] == f"{field} is too long (max 300)" + # A whitespace-padded 301 is still 301 after the trim… + r = admin_client.put("/api/ui-settings", json={field: " x" * 151}) + assert r.status_code == 422, (field, r.text) + # No rejected PUT created the row — the upsert runs after validation. + assert _row(db) is None + # Exactly 300 passes — stored, trimmed. + r = admin_client.put("/api/ui-settings", json={"app_name": "y" * 300}) + assert r.status_code == 200, r.text + assert r.json()["app_name"] == "y" * 300 + + +def test_put_bad_hex_422_names_the_field(admin_client: TestClient) -> None: + """Each of the 8 colors: anything not ``^#[0-9a-fA-F]{6}$`` is a 422 + naming that field — 3-digit shorthand, 8 hex digits, a bare hex + without ``#``, a named color, and the empty string (the color clear + operation is ``null``, not ``""``).""" + for field in theming.COLOR_FIELDS: + for bad in ("fff", "#ff", "ff00aa", "#12345678", "red"): + r = admin_client.put("/api/ui-settings", json={field: bad}) + assert r.status_code == 422, (field, bad, r.text) + assert r.json()["detail"] == f"{field} must be a #rrggbb hex color" + + +def test_put_lowercases_colors_on_store( + admin_client: TestClient, db: Session +) -> None: + """Uppercase hex passes the validator and is stored LOWERCASE — the + canonical form the tag renderer and the drift comparison rely on.""" + r = admin_client.put("/api/ui-settings", json={"brand": "#818CF8"}) + assert r.status_code == 200, r.text + assert r.json()["brand"] == "#818cf8" + row = _row(db) + assert row is not None + assert row.brand == "#818cf8" # the stored column, not just the response + + +def test_put_built_in_color_is_stored_as_null( + admin_client: TestClient, db: Session +) -> None: + """The owner-locked normalization: a color equal to its built-in is + stored as NULL — PUTting the whole built-in palette (with one value + in uppercase, proving the compare happens AFTER the lowercase) + leaves the row COMPLETELY empty: "save the defaults" must keep an + unset deployment byte-identical (the no-op injection contract).""" + body = dict(ALL_NULL_BODY) + for key, value in theming.BUILTIN_COLORS.items(): + body[key] = value.upper() if key == "brand" else value + r = admin_client.put("/api/ui-settings", json=body) + assert r.status_code == 200, r.text + # The response is the effective values — still the built-ins… + for key in theming.COLOR_FIELDS: + assert r.json()[key] == theming.BUILTIN_COLORS[key] + # …and the row itself is empty (the upsert created a row of NULLs). + row = _row(db) + assert row is not None, "the PUT upsert creates the id-1 row" + assert row.id == 1 + for field in (*theming.STRING_FIELDS, *theming.COLOR_FIELDS): + assert getattr(row, field) is None, f"{field} must be stored as NULL" + + +def test_put_empty_string_is_the_clear_operation( + admin_client: TestClient, db: Session +) -> None: + """A whitespace-only (or empty) string trims to empty → NULL — the + clear operation, not a 422 and not a stored blank: the effective + value falls back to the env default.""" + r = admin_client.put("/api/ui-settings", json={"app_name": " ", "footer_text": ""}) + assert r.status_code == 200, r.text + row = _row(db) + assert row is not None + assert row.app_name is None + assert row.footer_text is None + # The response reports the effective (env) fallback, not "". + assert r.json()["app_name"] == get_settings().app_name + assert r.json()["footer_text"] == get_settings().footer_text + + +def test_get_effective_merge_partial_row(admin_client: TestClient, db: Session) -> None: + """GET reports the DB values over the defaults, column by column: a + row with ONLY ``bg`` set (hand-inserted) reports that color plus the + built-ins and the env strings — all 11 keys, no nulls.""" + db.add(UiSettings(id=1, bg="#123456")) + db.commit() + r = admin_client.get("/api/ui-settings") + assert r.status_code == 200 + body = r.json() + assert set(body) == set(theming.STRING_FIELDS) | set(theming.COLOR_FIELDS) + assert body["bg"] == "#123456" # the DB value wins + for key in theming.COLOR_FIELDS: + if key != "bg": + assert body[key] == theming.BUILTIN_COLORS[key] + assert body["app_name"] == get_settings().app_name + assert body["input_placeholder"] == get_settings().input_placeholder + assert body["footer_text"] == get_settings().footer_text + + +def test_upsert_creates_then_updates_the_id_1_row( + admin_client: TestClient, db: Session +) -> None: + """The first PUT creates the id-1 row; the second updates the SAME + row (still exactly one row, still id 1 — the single-row contract).""" + r1 = admin_client.put( + "/api/ui-settings", json={"brand": "#123abc", "app_name": "First"} + ) + assert r1.status_code == 200, r1.text + row = _row(db) + assert row is not None and row.id == 1 + assert row.brand == "#123abc" + assert row.app_name == "First" + + r2 = admin_client.put( + "/api/ui-settings", json={"brand": "#abcdef", "input_placeholder": "Second"} + ) + assert r2.status_code == 200, r2.text + assert r2.json()["brand"] == "#abcdef" + assert r2.json()["input_placeholder"] == "Second" + + db.expire_all() # drop the test session's pre-second-PUT view (house pattern) + rows = db.execute(select(UiSettings)).scalars().all() + assert len(rows) == 1, "the upsert must never create a second row" + assert rows[0].id == 1 + assert rows[0].brand == "#abcdef" # updated, not appended + assert rows[0].input_placeholder == "Second" # the new string landed + assert rows[0].app_name is None # absent in the second body → NULL diff --git a/tests/unit/test_wide_column_css.py b/tests/unit/test_wide_column_css.py index d406e7c..e331a2f 100644 --- a/tests/unit/test_wide_column_css.py +++ b/tests/unit/test_wide_column_css.py @@ -168,20 +168,23 @@ def test_tuning_shell_stays_hardcoded_46rem() -> None: def test_no_other_hardcoded_46rem_rule_remains() -> None: """After the switch, the form columns are the ONLY rules with a - literal max-width: 46rem: .tuning-shell (phase 27) and + literal max-width: 46rem: .tuning-shell (phase 27), .doc-edit-shell (phase 59, task 06 — the doc edit screen is a FORM column, not a reading column, so it must not ride --chat-column and phase 58's wide-desktop doubling must never - stretch the form). Every reading column rides the token (the - --chat-column base declaration is the other non-rule occurrence - of 46rem).""" + stretch the form), and .theme-shell (phase 91 task 04 — the + admin Theme editor is a form column too: the palette grid + + fieldsets must never ride the wide-desktop doubling). Every + reading column rides the token (the --chat-column base + declaration is the other non-rule occurrence of 46rem).""" css = _css() - assert css.count("max-width: 46rem") == 2, ( - "only the form columns (.tuning-shell, .doc-edit-shell) may " - "keep a literal max-width: 46rem" + assert css.count("max-width: 46rem") == 3, ( + "only the form columns (.tuning-shell, .doc-edit-shell, " + ".theme-shell) may keep a literal max-width: 46rem" ) assert "max-width: 46rem" in _rule_block(css, ".tuning-shell") assert "max-width: 46rem" in _rule_block(css, ".doc-edit-shell") + assert "max-width: 46rem" in _rule_block(css, ".theme-shell") def test_comments_cite_the_wide_override_with_provenance() -> None: