diff --git a/.agents/phases/complete/92_theme_save_and_coverage/00_phase.md b/.agents/phases/complete/92_theme_save_and_coverage/00_phase.md new file mode 100644 index 0000000..c34563e --- /dev/null +++ b/.agents/phases/complete/92_theme_save_and_coverage/00_phase.md @@ -0,0 +1,96 @@ +# Phase 92 — Theme defects: save applies live (no reload) + the theme controls drive the ENTIRE site + +**Source:** Owner chat defect report (post-phase-91): (1) "Clicking 'save theme' reverts the theme back to the previous theme, a refresh is required to see the new theme."; (2) "Not everything is controllable via the theme controls. Certain buttons and text are still light pink on highlight, for example. The background grid never changes color." — "The theme controls should allow manipulating the entire site's theme." +**Story:** n/a (owner-chat defect fix on `91_admin_theme_tab`). +**Context:** Phase 91 shipped the admin Theme tab: 8 identity color pickers + 3 strings, persisted to the single-row `ui_settings` table (`app/api/ui_settings.py`, `app/core/theming.py`), injected pre-paint as `` by the `CachingMiddleware` (`app/core/caching.py`), edited live in `frontend/assets/theme.js`. **Defect 1 root cause (confirmed in code):** after a successful Save/Reset, `theme.js::saveTheme()` runs `loadSettings()` then `clearPreview()` — the live-preview overrides (inline custom properties on ``) are removed, and the page falls back to the `#bor-theme` tag that was baked into THIS document at page load — i.e. the PREVIOUS theme. The new palette only appears after a full reload (the server then injects the new tag). The phase-91 E2E masked this: its save test never asserts the live computed palette after save, and its reset test only checks computed values after a fresh `page.goto`. **Defect 2 root cause:** `frontend/assets/styles.css` still carries ~45 hardcoded color literals outside `:root` that no control can reach: the background grid (`body::before`, `rgb(74 38 38 / 0.6)` — never themes), `::selection` (brand at 45%, hardcoded), the header hairline gradient (hardcoded rose→orange→amber), every primary-button hover (`#f55a72` rose / legacy indigo `#7d88f5`), the disabled/busy state (indigo `#a5b4fc`), the Stop state (`#be123c`/`#9f1239`), three chip hovers (indigo `#2a345f`), six hardcoded `#1a0f0f` surface backgrounds + `#e6d0d0` code text, the ok/err alpha derivatives, four modal backdrops, the spinner track, and the static brand-mark SVG in all five HTML files (`fill="#1a0f0f" stroke="#f43f5e" … stroke="#fca5a5"` — the wordmark never themes). + +## Objective +Fix both phase-91 defects so the Theme tab controls the entire site's theme: (1) Save/Reset must apply the new palette to the OPEN page immediately — no reload, no revert — by syncing the live document's `#bor-theme` tag to the saved state; (2) every color in the UI must be driven by the identity palette (directly or via `color-mix()` derivations) — zero hardcoded literals outside `:root` — with the background grid promoted to a 9th tab-controlled identity variable (`--grid-line`). + +## Dependencies +- `91_admin_theme_tab` (complete) — the entire phase builds on it: the `ui_settings` table + resolver + admin API (`app/api/ui_settings.py`), `app/core/theming.py` (`BUILTIN_COLORS` / `COLOR_FIELDS` / `effective_settings` / `theme_style_tag` / `inject_theme` / `theme_csp_hash`), the pre-paint injection + CSP hash in `app/core/caching.py`, the tab shell + editor (`frontend/index.html` `#view-theme`, `frontend/assets/theme.js`), and the E2E suite `tests/e2e/test_admin_theme_tab.py` (updated in place, task 05). Its contracts (byte-identical no-op tag, admin-only gate, pre-paint first paint, §7.4 save lifecycle) all stay green. + +## Design (shared by all tasks — the executor reads this, not the chat) + +### Defect 1 — Save/Reset applies the theme to the open document (task 04) +The server-side pre-paint injection is untouched. The fix is entirely client-side in `theme.js`: after any settled read of the effective values, SYNC THE DOCUMENT'S `#bor-theme` TAG to those values, mirroring what the server would inject on the next load. + +- `theme.js` gains two small pure helpers + one DOM sync: + - **Built-in capture:** `BUILTINS` — the 9 built-in hexes captured from the color inputs' STATIC values at mount (BEFORE the first `loadSettings()` repopulates). The static values are the built-ins by the house contract (the phase-91 E2E asserts them against `styles.css` `:root`), so this keeps ONE source — no third hardcoded palette copy. + - `themeRootContent(colors) -> string | null` — `null` when all 9 effective colors equal their built-ins (the no-op case), else `":root{" + Σ f"--{field.replace(/_/g,'-')}:value;" (COLOR_FIELDS/FIELDS order) + "}"` — byte-identical to the CONTENT of `app.core.theming.theme_style_tag`'s tag (same fields, same order, lowercased hex from the resolver). + - `applyServedTheme(effective)`: content `null` → `document.getElementById("bor-theme")?.remove()`; else if the tag exists → set `el.textContent = content` (only when different); else create it with `document.createElement("style")` + `el.id = "bor-theme"` + `textContent` and append to `document.head`. + - **CSP (why this shape):** the phase-82/91 CSP (`style-src 'self' 'sha256-…'`, no `'unsafe-inline'`) blocks inline `' @@ -323,8 +326,8 @@ def _fill_theme_form( 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``).""" + """Fill the 12 inputs: the 3 text fields (``strings``, default + the E2E set) + the 9 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"]) @@ -334,7 +337,7 @@ def _fill_theme_form( def _expect_form_values(page: Page, strings: dict[str, str], colors: dict[str, str]) -> None: - """Assert all 11 inputs show the given effective values.""" + """Assert all 12 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"]) @@ -344,7 +347,7 @@ def _expect_form_values(page: Page, strings: dict[str, str], colors: dict[str, s 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 + all 9 vars = the given hexes, placed IMMEDIATELY before ```` (``inject_theme``'s exact placement: the tag ends exactly where ```` begins and carries the injector's single leading newline).""" @@ -357,7 +360,7 @@ def _assert_raw_tag(raw: str, colors: dict[str, str]) -> None: def _wait_theme_computed(page: Page, colors: dict[str, str], timeout: int = 15_000) -> None: - """The first-paint proof: all 8 computed ``:root`` custom + """The first-paint proof: all 9 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 @@ -399,8 +402,8 @@ def test_theme_tab_admin_save(page: Page, app_url: str, db_ready: None) -> None: 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 + # The 12 inputs show the EFFECTIVE defaults: the 3 template + # strings + the 9 built-in hexes parsed straight out of # styles.css's :root (the resolver's missing-row branch). _expect_form_values(page, defaults, builtin) @@ -432,7 +435,7 @@ def test_theme_tab_admin_save(page: Page, app_url: str, db_ready: None) -> None: # 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 + # The row landed in Postgres (the id-1 single row, all 12 values # — every palette color differs from its built-in, so nothing # collapsed to NULL). with SessionLocal() as db: @@ -472,7 +475,7 @@ def test_saved_theme_is_pre_paint_for_everyone( _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 + # bytes): exactly one inline theme tag, all 9 vars = the saved # hexes, immediately before (the pre-paint mechanism the # middleware unit tests pin — this is its observable # consequence). @@ -603,7 +606,7 @@ def test_anonymous_and_token_user_are_walled( # --------------------------------------------------------------------------- -# 4. Reset: the §7.4 lifecycle, the 11 defaults, NO theme tag, and +# 4. Reset: the §7.4 lifecycle, the 12 defaults, NO theme tag, and # byte-identical served HTML (the no-op injection contract) # --------------------------------------------------------------------------- @@ -650,7 +653,7 @@ def test_reset_restores_the_builtin_byte_identical( finally: _release_theme_puts(page) - # The form re-populates to the 11 defaults (the env/built-in + # The form re-populates to the 12 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 diff --git a/tests/e2e/test_theme_save_and_coverage.py b/tests/e2e/test_theme_save_and_coverage.py new file mode 100644 index 0000000..9264d82 --- /dev/null +++ b/tests/e2e/test_theme_save_and_coverage.py @@ -0,0 +1,767 @@ +"""Phase 92 E2E (Playwright): Save/Reset repaint the OPEN page without a +reload, and the Theme tab's variables drive the ENTIRE site. + +Source: owner chat defect report (post-phase-91): (1) "Clicking 'save +theme' reverts the theme back to the previous theme, a refresh is +required to see the new theme."; (2) "Not everything is controllable +via the theme controls. Certain buttons and text are still light pink +on highlight, for example. The background grid never changes color." — +"The theme controls should allow manipulating the entire site's theme." + +Run in isolation (DB must be up: ``podman compose up -d db``): + + uv run pytest tests/e2e/test_theme_save_and_coverage.py -v --no-cov + +Test → contract mapping (one story, one phase, one isolated file): + +1. ``test_save_applies_live_without_reload`` — defect 1 (Save): the + §7.4 save lifecycle lands, and — with NO navigation — the OPEN + document mirrors the saved state: the computed ``:root`` palette is + the saved 9 hexes, the ``#bor-theme`` tag's DOM text is the exact + 9-var ``:root{…}`` the next load would serve, and ````'s + inline style holds EXACTLY the 9 saved custom properties (no stale + pick). The server agrees (the raw ``/`` carries the 9-var tag), and + an SPA nav to Chat (same document) keeps the saved palette on the + computed ``--brand`` and the ``.send-btn`` fill. +2. ``test_reset_applies_live_without_reload`` — defect 1 (Reset): on a + THEMED load (the served tag is present), the §7.4 reset lifecycle + lands, and — with NO navigation — the ``#bor-theme`` tag is REMOVED + from the live document, the computed ``:root`` palette is the 9 + built-ins (parsed from ``styles.css`` in-test), ```` carries + no overrides, the served HTML has no tag, and the with-row bytes + equal a row-less deployment byte for byte (the no-op contract end + to end, now 9-wide). +3. ``test_theme_controls_drive_the_whole_site`` — defect 2: with the + palette seeded, a fresh load's first paint is the themed paint + (the raw HTML carries the 9-var tag immediately before ```` + + the phase-91 CSP ``style-src 'self' 'sha256-…'``), and the + browser-COMPUTED values prove every themed surface follows the tab: + the background grid texture (``body::before`` — the owner's named + defect) at 60% ``--grid-line``, ``::selection`` at 45% ``--brand``, + the button hovers at the derived ``--brand-hover`` (and NOT the + legacy indigo ``#7d88f5``), the nav-link wash at the saved + ``--brand-soft`` (exact), and the wordmark at the saved + ``--surface`` (exact). + +CSP reality (why the open-page repaint rides the ```` overrides +— the one deviation from the ``00_phase.md`` design): the repo's strict +policy (phase 82/91 — A1 + ``style-src 'self' 'sha256-'``, +no ``'unsafe-inline'``) makes the design's "sync the tag, then clear +the preview" shape impossible in a real browser: Chromium re-checks +``style-src`` on EVERY DOM-API content change to a ``' + + +def _expected_tag_content(colors: dict[str, str]) -> str: + """The tag's INNER ``:root{…}`` string — the ``#bor-theme`` + element's ``textContent`` after a settled save (task 04's mirror + half is byte-identical to it: same 9 fields, same order, the + resolver's lowercased hexes).""" + declarations = "".join(f"--{k.replace('_', '-')}:{colors[k]};" for k in COLOR_FIELDS) + return f":root{{{declarations}}}" + + +def _wait_theme_computed(page: Page, colors: dict[str, str], timeout: int = 15_000) -> None: + """All 9 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, + ) + + +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 _wait_mount_settled(page: Page) -> None: + """The theme view's mount-time load has SETTLED: its populate is + the last visible step — the app-name input carries the effective + default (the static markup ships it empty, so only a settled + ``loadSettings`` can have written it). From here on, no in-flight + mount load can race the test's own save/reset refetch (an + out-of-order settle would re-reconcile the open document onto a + stale read).""" + expect(page.locator("#theme-app-name")).to_have_value( + _template_defaults()["app_name"], timeout=15_000 + ) + + +def _expected_overrides(colors: dict[str, str], builtins: dict[str, str]) -> dict[str, str]: + """The ```` inline custom properties task 04's + ``applyServedTheme`` leaves after settling on ``colors`` (the + CSSOM PAINT half — the only CSP-clean repaint path, see the module + docstring): exactly the vars that differ from their built-in, at + the saved values (empty for the built-in palette).""" + return { + f"--{field.replace('_', '-')}": value + for field, value in colors.items() + if value != builtins[field] + } + + +def _wait_settled_open_document( + page: Page, tag_text: str | None, overrides: dict[str, str] +) -> None: + """The OPEN document mirrors the settled state (defect 1): the + ``#bor-theme`` tag's DOM text is ``tag_text`` (``None`` = the tag + is REMOVED — the no-op/reset case) and ````'s inline style + holds EXACTLY the ``overrides`` custom properties (no stale pick — + the pre-task-04 code fails this wait: its save cleared the preview + onto the stale served tag, leaving neither the synced tag text + nor the paint-half overrides). The absent-tag case rides the ``""`` + sentinel: Playwright's wait_for_function serializes a Python + ``None`` arg as JS ``undefined`` (not ``null`` — probe-verified), + and the real tag content is never empty anyway.""" + page.wait_for_function( + """(expected) => { + const el = document.getElementById('bor-theme'); + if (expected.tag === '') { + if (el !== null) return false; + } else if (el === null || el.textContent !== expected.tag) { + return false; + } + const s = document.documentElement.style; + const actual = {}; + for (let i = 0; i < s.length; i++) { + const p = s[i]; + if (p.startsWith('--')) actual[p] = s.getPropertyValue(p); + } + const keys = Object.keys(actual).sort(); + const expKeys = Object.keys(expected.overrides).sort(); + if (keys.length !== expKeys.length) return false; + return keys.every( + (k, i) => k === expKeys[i] && actual[k] === expected.overrides[k] + ); + }""", + arg={"tag": "" if tag_text is None else tag_text, "overrides": overrides}, + timeout=15_000, + ) + + +# --------------------------------------------------------------------------- +# color-mix resolution (the browser's sRGB interpolation, for the +# used-surface assertions — the serialized strings are compared with +# the ±1/255 tolerance, never raw color-mix(…) tokens) +# --------------------------------------------------------------------------- + +#: The modern serialization the browser uses for color-mix() results: +#: ``color(srgb R G B[/ A])`` — 0..1 float channels, alpha optional +#: (opaque). Plain-var surfaces serialize as ``rgb(R, G, B)`` (8-bit). +_COLOR_SRGB = re.compile( + r"color\(srgb\s+([0-9.]+)\s+([0-9.]+)\s+([0-9.]+)(?:\s*/\s*([0-9.]+))?" +) +#: The ±1/channel tolerance (±1/255 in the 0..1 float space) — browser +#: rounding is not pinned by the spec, and the window still fails any +#: legacy hardcoded value by orders of magnitude. +_TOL = 1.0 / 255.0 + 1e-9 + + +def _hex_channels(hex_str: str) -> tuple[float, float, float]: + """``#rrggbb`` → (r, g, b) in 0..1 floats.""" + return ( + int(hex_str[1:3], 16) / 255.0, + int(hex_str[3:5], 16) / 255.0, + int(hex_str[5:7], 16) / 255.0, + ) + + +def _mix( + a_hex: str, percent: float, b_hex: str | None = None +) -> tuple[float, float, float, float]: + """The browser's ``color-mix(in srgb, A p%, B)`` — CSS Color 4: + sRGB interpolation is PREMULTIPLIED (with ``B = transparent`` = + (0,0,0,0) — ``b_hex=None`` — the result is simply A at alpha + ``p/100``). Returns (r, g, b, a) in 0..1 straight channels.""" + a = (*_hex_channels(a_hex), 1.0) + b: tuple[float, float, float, float] = ( + (0.0, 0.0, 0.0, 0.0) + if b_hex is None + else (*_hex_channels(b_hex), 1.0) + ) + w = percent / 100.0 + alpha = a[3] * w + b[3] * (1.0 - w) + if alpha == 0.0: + return (0.0, 0.0, 0.0, 0.0) + prem = tuple(a[i] * a[3] * w + b[i] * b[3] * (1.0 - w) for i in range(3)) + return (prem[0] / alpha, prem[1] / alpha, prem[2] / alpha, alpha) + + +def _parse_color_srgb(ser: str) -> tuple[float, float, float, float]: + """Parse the browser's ``color(srgb R G B[/ A])`` serialization + (the color-mix() result form) into 0..1 floats (opaque → a=1).""" + match = _COLOR_SRGB.search(ser) + assert match is not None, f"no color(srgb …) serialization in {ser!r}" + r, g, b = (float(match.group(i)) for i in (1, 2, 3)) + a = float(match.group(4)) if match.group(4) is not None else 1.0 + return (r, g, b, a) + + +def _close(got: tuple[float, float, float, float], want: tuple[float, float, float, float]) -> None: + """±1/channel (float) on r/g/b, ~exact on alpha (the browser + serializes the exact mix alpha).""" + for i in range(3): + assert abs(got[i] - want[i]) <= _TOL, ( + f"channel {i}: {got[i]} !~ {want[i]} (full: {got} vs {want})" + ) + assert abs(got[3] - want[3]) <= 1e-4, f"alpha: {got[3]} != {want[3]}" + + +# --------------------------------------------------------------------------- +# 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 12 inputs: the 3 text fields (``strings``, default + the E2E set) + the 9 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 _assert_raw_tag(raw: str, colors: dict[str, str]) -> None: + """The RAW served HTML carries exactly one inline theme tag, with + all 9 vars = the given hexes, placed IMMEDIATELY before + ```` (``inject_theme``'s exact placement: the tag ends + exactly where ```` 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("") + assert start + len(tag) == head, "the tag must end exactly where begins" + assert raw[start - 1] == "\n", "the tag must carry the injector's leading newline" + + +# --------------------------------------------------------------------------- +# 1. Defect 1 (Save): the §7.4 lifecycle lands and the OPEN page +# repaints the saved palette — no navigation, no reload +# --------------------------------------------------------------------------- + + +def test_save_applies_live_without_reload(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-gate")).to_be_hidden() + expect(page.locator("#theme-content")).to_be_visible(timeout=15_000) + # The mount's initial load has settled (the row-less state — no + # tag, no overrides) before the test touches the form: no + # in-flight load can race the save's own refetch. + _wait_mount_settled(page) + + # Fill the 12 inputs (the 3 strings + the 9-color palette) and Save + # through the real form — the PUT held so the §7.4 in-flight state + # is observable deterministically (the same lifecycle assertions + # as the phase-91 suite — the save's contract is unchanged). + _fill_theme_form(page, PALETTE) + _hold_theme_puts(page) + try: + page.click("#theme-save") + 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() + 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) + + # NO navigation (the URL never leaves the Theme view): the OPEN + # document mirrors the settled save — the tag's DOM text is the + # exact 9-var :root{…} the next load would serve (task 04's mirror + # half) and 's inline style holds EXACTLY the 9 saved + # custom properties (the paint half — no stale pick; the pre-fix + # code cleared the preview onto the STALE served tag and fails + # this wait). + expect(page).to_have_url(APP_URL + "/theme.html") + _wait_settled_open_document( + page, _expected_tag_content(PALETTE), _expected_overrides(PALETTE, builtin) + ) + # …and all 9 computed :root custom properties ARE the saved hexes + # (the open page painted the saved palette — defect 1 gone). + _wait_theme_computed(page, PALETTE) + + # The server agrees: the RAW served HTML (a fresh request) carries + # the 9-var tag immediately before . + r = httpx.get(app_url + "/", timeout=10) + assert r.status_code == 200 + _assert_raw_tag(r.text, PALETTE) + + # SPA navigation (the router's view switch — same document, NO + # reload): the theme view hides, the chat view shows, and the + # saved palette survives on the live computed values. + page.click('a.nav-link[href="/"]') + expect(page.locator("#view-theme")).to_be_hidden() + expect(page.locator("#view-chat")).to_be_visible(timeout=15_000) + assert ( + page.evaluate( + "() => getComputedStyle(document.documentElement)" + ".getPropertyValue('--brand').trim()" + ) + == PALETTE["brand"] + ) + # .send-btn { background: var(--brand) } — the used color is the + # saved brand, exact (8-bit hex → rgb() serialization). + expect(page.locator(".send-btn")).to_have_css( + "background-color", "rgb(79, 70, 229)" + ) + + +# --------------------------------------------------------------------------- +# 2. Defect 1 (Reset): on a themed load, Reset removes the tag from +# the live document and paints the built-ins — no navigation +# --------------------------------------------------------------------------- + + +def test_reset_applies_live_without_reload(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) + + # Seed the theme via the API, THEN load the shell: the served + # document carries the 9-var tag (a THEMED load — the reset must + # remove it from the live document, not just stop serving it). + _seed_theme_via_api(app_url, _cookies(page)) + page.goto(app_url + "/theme.html") + # Served-state sanity: the first paint IS the themed paint. + _wait_theme_computed(page, PALETTE) + # AND the mount's initial load has settled: its applyServedTheme + # has run (the overrides exist — the document mirrors the + # served theme). From here on, only the reset's own refetch can + # re-reconcile the open document. + _wait_settled_open_document( + page, _expected_tag_content(PALETTE), _expected_overrides(PALETTE, builtin) + ) + + # Reset to defaults: the §7.4 lifecycle (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) + + # NO navigation: the #bor-theme tag is REMOVED from the live + # document (effective = the built-ins → content null → the tag + # goes) and the page paints the 9 built-ins (parsed from + # styles.css in-test) with carrying no overrides at all — + # the reset is the one case where the cleared-preview shape and + # the overrides shape agree: the style attribute is empty. + expect(page).to_have_url(APP_URL + "/theme.html") + _wait_settled_open_document(page, None, {}) + _wait_theme_computed(page, builtin) + assert ( + page.evaluate("() => (document.documentElement.getAttribute('style') || '').trim()") + == "" + ) + + # The server agrees: no tag served (the all-NULL row is the + # no-op)… + r = httpx.get(app_url + "/", timeout=10) + assert "bor-theme" not in r.text + # …and the byte-identical contract end to end: the with-row bytes + # equal a ROW-LESS deployment byte for byte (now 9-wide — a + # defaults-saved row never adds a byte). + with_row = r.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" + ) + + +# --------------------------------------------------------------------------- +# 3. Defect 2: the tab's variables drive EVERY themed surface — the +# grid, the selection, the hovers, the wordmark (computed values) +# --------------------------------------------------------------------------- + + +def test_theme_controls_drive_the_whole_site(page: Page, app_url: str, db_ready: None) -> None: + page.set_default_timeout(30_000) + login(page, app_url, next="/") + _seed_theme_via_api(app_url, _cookies(page)) + + # Pre-paint with the 9th var: the raw served HTML carries the + # 9-var tag immediately before , permitted in a real + # browser only via the phase-91 CSP hash (now 9-wide). + r = httpx.get(app_url + "/", timeout=10) + assert r.status_code == 200 + _assert_raw_tag(r.text, PALETTE) + csp = r.headers.get("content-security-policy", "") + assert "style-src 'self' 'sha256-" in csp, csp + + # Fresh load: the first paint is the themed paint. + page.goto(app_url + "/") + _wait_theme_computed(page, PALETTE) + + # The background grid (the owner's named defect: "the background + # grid never changes color") — body::before's 1px line stops are + # color-mix(in srgb, var(--grid-line) 60%, transparent), which the + # browser serializes as color(srgb … / 0.6) at the grid line's + # channels (premultiplied sRGB with transparent = the source at + # the mix alpha). + grid_image = page.evaluate( + "() => getComputedStyle(document.body, '::before').backgroundImage" + ) + want = _mix(PALETTE["grid_line"], 60.0, None) + stops = [ + (float(m.group(1)), float(m.group(2)), float(m.group(3)), + float(m.group(4)) if m.group(4) is not None else 1.0) + for m in _COLOR_SRGB.finditer(grid_image) + ] + assert any( + abs(stop[3] - want[3]) <= 1e-4 + and all( + abs(got - want_c) <= _TOL + for got, want_c in zip(stop[:3], want[:3], strict=True) + ) + for stop in stops + ), f"the 60% --grid-line stop {want} is not in the grid: {grid_image!r}" + + # ::selection — color-mix(in srgb, var(--brand) 45%, transparent): + # the brand's channels at alpha exactly 0.45. + selection = page.evaluate( + "() => getComputedStyle(document.documentElement, '::selection').backgroundColor" + ) + _close(_parse_color_srgb(selection), _mix(PALETTE["brand"], 45.0, None)) + + # The button hovers (the "light pink on highlight" defect) — the + # derived --brand-hover: the brand at 86% toward white. + page.hover(".new-chat-btn") + new_chat_bg = page.evaluate( + "() => getComputedStyle(document.querySelector('.new-chat-btn')).backgroundColor" + ) + _close(_parse_color_srgb(new_chat_bg), _mix(PALETTE["brand"], 86.0, "#ffffff")) + # Explicitly NOT the legacy indigo hover (#7d88f5 — the incoherent + # pre-phase-92 literal that survived under the rose brand): + assert new_chat_bg != "rgb(125, 136, 245)", new_chat_bg + assert abs(_parse_color_srgb(new_chat_bg)[0] - 125 / 255.0) > _TOL + + page.hover(".send-btn") + send_bg = page.evaluate( + "() => getComputedStyle(document.querySelector('.send-btn')).backgroundColor" + ) + _close(_parse_color_srgb(send_bg), _mix(PALETTE["brand"], 86.0, "#ffffff")) + + # The house nav-link hover wash — the saved --brand-soft, EXACT + # (a plain var resolves to the 8-bit hex serialization). + page.hover("#nav-sources") + nav_bg = page.evaluate( + "() => getComputedStyle(document.querySelector('#nav-sources')).backgroundColor" + ) + assert nav_bg == "rgb(30, 36, 71)", nav_bg + + # The wordmark (the static brand-mark SVG that "never themes") — + # its first path's fill is var(--surface): the saved surface, + # EXACT. + fill = page.evaluate( + "() => getComputedStyle(document.querySelector('.brand-mark path')).fill" + ) + assert fill == "rgb(17, 23, 48)", fill diff --git a/tests/integration/test_ui_settings_api.py b/tests/integration/test_ui_settings_api.py index 9bae17a..deb2fb1 100644 --- a/tests/integration/test_ui_settings_api.py +++ b/tests/integration/test_ui_settings_api.py @@ -27,12 +27,13 @@ from collections.abc import Iterator import pytest from fastapi.testclient import TestClient -from sqlalchemy import text +from sqlalchemy import select, 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 app.models import UiSettings from tests.conftest import ADMIN_PASSWORD @@ -120,6 +121,37 @@ def test_admin_get_and_put_200(client: TestClient, db: Session) -> None: assert r.json()["bg"] == theming.BUILTIN_COLORS["bg"] +def test_admin_grid_line_validation_and_normalization( + client: TestClient, db: Session +) -> None: + """Phase 92 (task 01): the 9th identity color against the LIVE API — + a bad hex is a 422 naming ``grid_line`` (same fixed detail as the + other 8); the built-in value stores NULL (the response still + reports the built-in — the no-op normalization); a non-built-in + value is stored and reported back. The admin gate itself is pinned + unchanged by the tests above (router-wide ``require_admin``).""" + client.post("/api/login", json={"password": ADMIN_PASSWORD}) + + r = client.put("/api/ui-settings", json={"grid_line": "nope"}) + assert r.status_code == 422, r.text + assert r.json()["detail"] == "grid_line must be a #rrggbb hex color" + + r = client.put("/api/ui-settings", json={"grid_line": "#4a2626"}) + assert r.status_code == 200, r.text + assert r.json()["grid_line"] == theming.BUILTIN_COLORS["grid_line"] + row = db.execute(select(UiSettings).where(UiSettings.id == 1)).scalars().first() + assert row is not None, "the PUT upsert creates the id-1 row" + assert row.grid_line is None # built-in → NULL normalization + + r = client.put("/api/ui-settings", json={"grid_line": "#123123"}) + assert r.status_code == 200, r.text + assert r.json()["grid_line"] == "#123123" + r = client.get("/api/ui-settings") + assert r.status_code == 200 + assert r.json()["grid_line"] == "#123123" # the stored value reads back + assert len(r.json()) == 12 # the 12-key response shape (9 colors + 3 strings) + + 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.""" diff --git a/tests/unit/test_background_no_motion.py b/tests/unit/test_background_no_motion.py index b30adc1..874e2af 100644 --- a/tests/unit/test_background_no_motion.py +++ b/tests/unit/test_background_no_motion.py @@ -108,17 +108,21 @@ def test_no_background_layer_declares_animation() -> None: def test_grid_layer_is_static_and_unchanged() -> None: """The owner removed the animated part, not the grid: body::before - keeps 44px cells, 1px lines at the fixed 60% line alpha (warm - rebrand tone), and the widened radial mask (both the -webkit- and - standard mask properties) — and carries NO animation.""" + keeps 44px cells, 1px lines at 60% of the grid line color, and the + widened radial mask (both the -webkit- and standard mask + properties) — and carries NO animation. Phase 92 (task 02): the + line color is the 9th identity variable --grid-line at 60% (the + built-in #4a2626 reproduces the old warm tone exactly — and the + tab's Grid lines picker now repaints this texture).""" block = _rule_block(_css(), GRID_LAYER) + grid = "color-mix(in srgb, var(--grid-line) 60%, transparent)" assert "background-size: 44px 44px" in block - assert ( - "linear-gradient(to right, rgb(74 38 38 / 0.6) 1px, transparent 1px)" in block - ), "grid must keep horizontal 1px lines at 60% line alpha" - assert ( - "linear-gradient(to bottom, rgb(74 38 38 / 0.6) 1px, transparent 1px)" in block - ), "grid must keep vertical 1px lines at 60% line alpha" + assert f"linear-gradient(to right, {grid} 1px, transparent 1px)" in block, ( + "grid must keep horizontal 1px lines at 60% --grid-line" + ) + assert f"linear-gradient(to bottom, {grid} 1px, transparent 1px)" in block, ( + "grid must keep vertical 1px lines at 60% --grid-line" + ) mask = "radial-gradient(140% 110% at 50% 0%, black 40%, transparent 90%)" assert f"-webkit-mask-image: {mask};" in block assert f"mask-image: {mask};" in block diff --git a/tests/unit/test_caching.py b/tests/unit/test_caching.py index 9a2dede..0bf8f10 100644 --- a/tests/unit/test_caching.py +++ b/tests/unit/test_caching.py @@ -796,7 +796,7 @@ def test_middleware_themed_injects_tag_before_head_on_every_page(db: Session) -> ``/``, 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)) @@ -842,6 +842,40 @@ def test_middleware_themed_injects_tag_before_head_on_every_page(db: Session) -> db.commit() +def test_middleware_grid_only_change_tag_carries_grid_line(db: Session) -> None: + """Phase 92 (task 01): the 9th identity variable — the OTHER 8 colors + at built-in + ONLY ``grid_line`` set still breaks the no-op contract: + the tag is NON-empty and carries ALL 9 vars (``--grid-line:`` with + the changed value, the rest their built-ins, ``COLOR_FIELDS`` order) + with the matching style-src CSP hash.""" + db.execute(text("DELETE FROM ui_settings")) + db.add(UiSettings(id=1, grid_line="#123123")) + db.commit() + try: + colors = dict(theming.BUILTIN_COLORS) + colors["grid_line"] = "#123123" # one changed color, rest built-in + tag = theming.theme_style_tag(colors) + assert tag != "" # the no-op contract holds ONLY for all-built-in + assert "--grid-line:#123123;" in tag + client = TestClient(_theme_page_app()) + r = client.get("/") + assert r.status_code == 200 + assert r.text.count('id="bor-theme"') == 1 + assert "\n" + tag + "" in r.text + # All 9 vars, COLOR_FIELDS order (grid_line between line and brand). + declared = re.search(r'', 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 names.index("grid-line") == 5 + assert r.headers["content-security-policy"] == ( + f"{CSP}; style-src 'self' '{theming.theme_csp_hash(tag)}'" + ) + finally: + db.execute(text("DELETE FROM ui_settings")) + db.commit() + + @pytest.mark.parametrize( ("what",), [("session",), ("resolver",)], diff --git a/tests/unit/test_chat_persistence.py b/tests/unit/test_chat_persistence.py index f78121f..17b21e6 100644 --- a/tests/unit/test_chat_persistence.py +++ b/tests/unit/test_chat_persistence.py @@ -245,7 +245,9 @@ def test_new_chat_button_style_contract() -> None: assert "background: var(--brand)" in body, "solid brand fill (the rebrand)" assert "color: var(--bg)" in body, "--bg text on --brand = 5.2:1 (AA)" hover = re.search(r"\.new-chat-btn:hover \{([\s\S]*?)\n\}", css) - assert hover and "#f55a72" in hover.group(1), "hover lightens the brand fill" + assert hover and "var(--brand-hover)" in hover.group(1), ( + "hover lightens the brand fill (phase 92: --brand-hover)" + ) # Mobile (≤640px): the button sits in .chat-shell, not the navbar — # the label stays visible and the plus icon is hidden (room in the # body); the pill stays ≥44px via min-height. diff --git a/tests/unit/test_frontend_feedback.py b/tests/unit/test_frontend_feedback.py index c9a3df8..350515f 100644 --- a/tests/unit/test_frontend_feedback.py +++ b/tests/unit/test_frontend_feedback.py @@ -97,15 +97,19 @@ def test_reduced_motion_calm_not_removed() -> None: def test_busy_button_style_tokens() -> None: """Phase 48 (revised contract, owner-locked 2026-08-29): in flight the button is the enabled Stop control — "Stop" label, .is-stop - class (rose treatment, 6.3:1 with the #fff label), spinner hidden; - idle/error keep the brand Send button (dark ink on brand 5.2:1). + class (--brand-stop treatment — the brand darkened toward --bg, + 5.8:1 with the white label at the built-in default, phase 92), + spinner hidden; idle/error keep the brand Send button + (dark ink on brand 5.2:1). The spinner element stays in the markup + CSS (16px dark arc — the reduced-motion pin below) but the state machine never shows it: the Stop label + treatment carry the in-flight state.""" css = _css() js = _js() assert ".send-btn.is-stop" in css - assert "#be123c" in css, "the stop background: rose-700 (6.3:1 with #fff)" + assert "background: var(--brand-stop)" in css, ( + "the stop background: the brand darkened toward --bg (5.8:1 with the white label)" + ) assert ".send-btn.is-stop:hover" in css, "the darker hover step" assert re.search(r"\.spinner \{[^}]*width: 16px", css) assert 'sendLabel.textContent = inFlight ? "Stop" : "Send"' in js diff --git a/tests/unit/test_frontend_router.py b/tests/unit/test_frontend_router.py index 2decd99..2b72128 100644 --- a/tests/unit/test_frontend_router.py +++ b/tests/unit/test_frontend_router.py @@ -759,7 +759,7 @@ def test_history_refresh_button_css_reuses_the_new_chat_language() -> None: assert "color: var(--bg)" in body, "--bg text on --brand (5.2:1, AA)" assert "min-height: 44px" in body, "the comfortable touch target" assert "border-radius: 999px" in body and "border: 0" in body, "the pill" - assert ".history-refresh:hover { background: #f55a72; color: var(--bg); }" in css + assert ".history-refresh:hover { background: var(--brand-hover); color: var(--bg); }" in css assert ".history-refresh:disabled { opacity: 0.6; cursor: wait; }" in css, ( "the in-flight disabled state is dimmed (the house language)" ) @@ -902,8 +902,8 @@ def test_theme_view_scaffold_in_the_shell() -> None: 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 + — the server re-validates) + the 9 labeled type=color palette inputs + (the 9 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 @@ -941,7 +941,7 @@ def test_theme_view_scaffold_in_the_shell() -> None: ) # 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 + # and the 9 labeled type=color palette inputs (the 9 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" @@ -959,6 +959,7 @@ def test_theme_view_scaffold_in_the_shell() -> None: "theme-ink", "theme-ink-soft", "theme-line", + "theme-grid-line", "theme-brand", "theme-brand-soft", "theme-brand-ink", diff --git a/tests/unit/test_models.py b/tests/unit/test_models.py index 172c93e..d7d793c 100644 --- a/tests/unit/test_models.py +++ b/tests/unit/test_models.py @@ -22,15 +22,16 @@ def test_all_tables_registered() -> None: 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).""" + """Phase 91 (9 identity colors after phase 92, task 01): 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 9 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", + "bg", "surface", "ink", "ink_soft", "line", "grid_line", "brand", "brand_soft", "brand_ink", } pk = settings_table.c["id"] @@ -40,7 +41,7 @@ def test_ui_settings_single_row_nullable_contract() -> None: 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", + for name in ("bg", "surface", "ink", "ink_soft", "line", "grid_line", "brand", "brand_soft", "brand_ink"): col = settings_table.c[name] assert col.nullable is True, f"{name} must be NULL (the built-in)" diff --git a/tests/unit/test_save_chat_ui.py b/tests/unit/test_save_chat_ui.py index 06dfc4e..b189fc9 100644 --- a/tests/unit/test_save_chat_ui.py +++ b/tests/unit/test_save_chat_ui.py @@ -453,7 +453,9 @@ def test_share_button_css_is_the_exact_save_family() -> None: assert "background: var(--brand)" in body, "same solid brand fill as Save" assert "color: var(--bg)" in body, "--bg text on --brand = 5.2:1 (AA)" hover = re.search(r"\.share-chat-btn:hover \{([\s\S]*?)\n\}", css) - assert hover and "#f55a72" in hover.group(1), "hover lightens the brand fill" + assert hover and "var(--brand-hover)" in hover.group(1), ( + "hover lightens the brand fill (phase 92: --brand-hover)" + ) svg = re.search(r"\.share-chat-btn svg \{([\s\S]*?)\n\}", css) assert svg and "display: none" in svg.group(1), "icon hidden on desktop (like Save)" mobile = re.search(r"@media \(max-width: 640px\) \{([\s\S]*?)\n\}", css) @@ -1013,7 +1015,9 @@ def test_stale_banner_css_is_the_kb_banner_family() -> None: ): assert prop in body, f".stale-regenerate must keep the Save/Share family ({prop})" hover = re.search(r"\.stale-regenerate:hover \{([\s\S]*?)\n\}", css) - assert hover and "#f55a72" in hover.group(1), "hover lightens the brand fill" + assert hover and "var(--brand-hover)" in hover.group(1), ( + "hover lightens the brand fill (phase 92: --brand-hover)" + ) assert re.search(r"\.stale-regenerate svg \{ width: 16px; height: 16px", css), ( "the redo glyph rides the 16px pill size" ) diff --git a/tests/unit/test_sync_button.py b/tests/unit/test_sync_button.py index eef2031..ecaf2e1 100644 --- a/tests/unit/test_sync_button.py +++ b/tests/unit/test_sync_button.py @@ -600,8 +600,9 @@ def test_sync_result_is_styled() -> None: def test_sync_modal_css_error_palette_and_stacking() -> None: - """.sync-modal-backdrop: fixed, full-viewport, rgba dim, z-index - above the sticky header; .sync-modal: the centered ≈28rem panel on + """.sync-modal-backdrop: fixed, full-viewport, the --bg-82% dim + (phase 92: color-mix of the identity variable), z-index above the + sticky header; .sync-modal: the centered ≈28rem panel on the phase-08 error palette (panel on --err-bg, 1px --err-line border, --err-ink error text, --ink title — all computed ≥4.5:1); open/close via .is-open (visibility/opacity).""" @@ -612,7 +613,9 @@ def test_sync_modal_css_error_palette_and_stacking() -> None: assert "position: fixed" in b assert "inset: 0" in b assert "z-index: 1000" in b, "above the sticky header (20) + skip-link (100)" - assert "rgba(" in b, "the dim over the page" + assert "color-mix(in srgb, var(--bg) 82%, transparent)" in b, ( + "the dim over the page (phase 92: --bg at 82%)" + ) open_state = re.search(r"\.sync-modal-backdrop\.is-open\s*\{([^}]*)\}", css) assert open_state, ".is-open must be the open state" assert "visibility: visible" in open_state.group(1) diff --git a/tests/unit/test_theming.py b/tests/unit/test_theming.py index 5bafa49..95fb218 100644 --- a/tests/unit/test_theming.py +++ b/tests/unit/test_theming.py @@ -7,12 +7,12 @@ 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 +* ``BUILTIN_COLORS`` — the DRIFT GUARD: the 9 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`` + ``""``) and the exact tag shape (all 9 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 @@ -45,6 +45,14 @@ def _delete_row() -> Any: return delete(UiSettings).where(UiSettings.id == 1) +def _start_row_missing(db: Session) -> None: + """The single row is global state: wipe it so every DB test starts + from the row-missing state it asserts (a stale row from an earlier + interrupted run must not break them).""" + db.execute(_delete_row()) + db.commit() + + def _root_declarations() -> dict[str, str]: """The ``--name: value`` declarations of styles.css's (first) ``:root`` block, comments stripped, in file order.""" @@ -59,13 +67,13 @@ def _root_declarations() -> dict[str, str]: 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).""" + the 9 identity variables — no more, no fewer).""" decls = _root_declarations() builtin_names = set(theming.BUILTIN_COLORS) assert builtin_names == { - "bg", "surface", "ink", "ink_soft", "line", + "bg", "surface", "ink", "ink_soft", "line", "grid_line", "brand", "brand_soft", "brand_ink", - }, f"BUILTIN_COLORS must name exactly the 8 identity variables, got {sorted(builtin_names)}" + }, f"BUILTIN_COLORS must name exactly the 9 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}" @@ -75,12 +83,15 @@ def test_builtin_colors_match_styles_css_root() -> None: ) -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.""" +def test_color_fields_are_the_nine_keys_in_readme_order() -> None: + """``COLOR_FIELDS`` is the 9 keys in the themes-README order — the + order the resolver, the API, and the tag renderer all rely on. + (Phase 92, task 01: ``grid_line`` is the 9th identity variable, + slotting in between ``line`` and ``brand`` — structural colors + first, brand last.)""" assert theming.COLOR_FIELDS == ( "bg", "surface", "ink", "ink_soft", - "line", "brand", "brand_soft", "brand_ink", + "line", "grid_line", "brand", "brand_soft", "brand_ink", ) assert theming.STRING_FIELDS == ("app_name", "input_placeholder", "footer_text") @@ -102,7 +113,8 @@ def _env_settings() -> Settings: 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.""" + strings + the built-in palette, all 12 keys.""" + _start_row_missing(db) 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()) @@ -117,6 +129,7 @@ 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.""" + _start_row_missing(db) db.add(UiSettings(id=1, bg="#111111", app_name="DB Name")) db.commit() try: @@ -140,6 +153,7 @@ def test_effective_empty_string_db_string_falls_back_to_env(db: Session) -> None 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).""" + _start_row_missing(db) db.add(UiSettings(id=1, app_name="")) db.commit() try: @@ -155,9 +169,10 @@ def test_effective_empty_string_db_string_falls_back_to_env(db: Session) -> None 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.""" + values it reports must be real ``str``s for all 12 keys.""" from app.config import get_settings + _start_row_missing(db) effective = theming.effective_settings(db) assert set(effective) == set(theming.STRING_FIELDS) | set(theming.COLOR_FIELDS) assert effective["app_name"] == get_settings().app_name @@ -181,16 +196,20 @@ def test_theme_style_tag_all_builtins_is_empty_string() -> None: 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).""" +def test_theme_style_tag_one_changed_carries_all_nine_in_order() -> None: + """A single non-built-in color still emits ALL 9 variables, in + ``COLOR_FIELDS`` order, with the exact tag shape (no whitespace). + Phase 92 (task 01): the tag carries ``--grid-line:#4a2626;`` between + ``--line`` and ``--brand`` (the 9th identity variable — the + background grid texture).""" 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… @@ -211,7 +230,7 @@ def test_theme_style_tag_multiple_changed() -> None: assert tag.startswith('") - # The order of the 8 dashed names is the COLOR_FIELDS order. + # The order of the 9 dashed names is the COLOR_FIELDS order. names = re.findall(r"--([a-z-]+):", tag) assert names == [k.replace("_", "-") for k in theming.COLOR_FIELDS] diff --git a/tests/unit/test_ui_settings.py b/tests/unit/test_ui_settings.py index c51d1f0..bef46c7 100644 --- a/tests/unit/test_ui_settings.py +++ b/tests/unit/test_ui_settings.py @@ -36,7 +36,8 @@ 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, + "line": None, "grid_line": None, "brand": None, "brand_soft": None, + "brand_ink": None, } @@ -79,7 +80,7 @@ def test_put_too_long_string_422_names_the_field( 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 + """Each of the 9 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 ``""``).""" @@ -88,6 +89,12 @@ def test_put_bad_hex_422_names_the_field(admin_client: TestClient) -> None: 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" + # Phase 92 (task 01): the 9th identity color names its 422 the same + # fixed way as the other 8 (the loop above already covers it via + # COLOR_FIELDS; the explicit case pins the field name in the detail). + r = admin_client.put("/api/ui-settings", json={"grid_line": "nope"}) + assert r.status_code == 422, r.text + assert r.json()["detail"] == "grid_line must be a #rrggbb hex color" def test_put_lowercases_colors_on_store( @@ -127,6 +134,30 @@ def test_put_built_in_color_is_stored_as_null( assert getattr(row, field) is None, f"{field} must be stored as NULL" +def test_put_grid_line_built_in_is_stored_as_null( + admin_client: TestClient, db: Session +) -> None: + """Phase 92 (task 01): the 9th identity color gets the same + owner-locked normalization as the other 8 — PUTting the built-in + grid hex stores NULL (the response still reports the built-in, and + the row's grid column stays empty); a NON-built-in value is stored + as-is (lowercased).""" + r = admin_client.put("/api/ui-settings", json={"grid_line": "#4a2626"}) + assert r.status_code == 200, r.text + assert r.json()["grid_line"] == theming.BUILTIN_COLORS["grid_line"] + row = _row(db) + assert row is not None + assert row.grid_line is None # built-in → NULL + + r = admin_client.put("/api/ui-settings", json={"grid_line": "#123123"}) + assert r.status_code == 200, r.text + assert r.json()["grid_line"] == "#123123" + db.expire_all() # drop the test session's pre-second-PUT view (house pattern) + row = _row(db) + assert row is not None + assert row.grid_line == "#123123" # non-built-in is stored as-is + + def test_put_empty_string_is_the_clear_operation( admin_client: TestClient, db: Session ) -> None: @@ -147,7 +178,7 @@ def test_put_empty_string_is_the_clear_operation( 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.""" + built-ins and the env strings — all 12 keys, no nulls.""" db.add(UiSettings(id=1, bg="#123456")) db.commit() r = admin_client.get("/api/ui-settings")