phase: 190_app_name_settings_fix
Build and Push Containers / build-and-push-app (push) Successful in 2m9s
Build and Push Containers / build-and-push-db (push) Successful in 15s

All gates re-verified green. Report:

- **Verified (all 4 tasks already complete; this was the final pass):** `effective_settings` resolves the 3 brand strings panel-row → tab-row → env (`applied_override_keys`, presence-not-value); `PUT /api/ui-settings` ignores string body fields; Theme-tab form is colors-only (17 pickers, no `#theme-app-name`/`#theme-placeholder`/`#theme-footer`); the ONE shared `applyBrandFromConfig` re-applies title/`.brand-text`/footer/placeholder after a panel save-reset and a tab save-reset; boot path byte-identical (single `/api/config`).
- **Completion criteria:**
  - strings order panel→tab→env, both branches — ✅ `tests/unit/test_theming.py::test_effective_app_name_prefers_settings_panel_row_over_ui_settings_row`, `..._fall_back_to_ui_settings_row_without_panel_row`, `..._per_field_panel_then_tab_then_env`, `..._ignore_panel_rows_when_the_overlay_is_off`
  - PUT ignores string fields + no 3 inputs in the tab — ✅ `tests/integration/test_ui_settings_api.py::test_put_ignores_string_fields_in_body` (+2) and the E2E `test_theme_tab_dom_is_colors_only`
  - live apply without reload + incident regression — ✅ `tests/e2e/test_app_name_settings_fix.py` tests 1–4
  - byte-identical boot, no double-apply — ✅ `tests/unit/test_brand_live.py` markers + E2E "exactly one /api/config"
  - suite green, coverage >90%, ruff+pyright clean — ✅
  - no behavior change in prior phases — ✅ (palette, phase-168 panel, env fallback suites all green)
  - atomic commit — **left to the harness** (pipeline rule overrides per-phase committing; no git write run)
- **Gates:** `uv run pytest --cov=app --cov-report=term-missing` → **4630 passed, TOTAL 6941 16 99%**, exit 0 · `uv run pytest tests/e2e/test_app_name_settings_fix.py -v --no-cov` → **6 passed** in isolation · regression E2E (isolated, one process each, all exit 0): `test_smoke` `test_configurable_brand` `test_admin_theme_tab` `test_theme_save_and_coverage` `test_theme_semantic_completion` `test_themed_favicon` `test_dark_tech_theme` `test_admin_settings_panel` `test_admin_settings_layout` `test_settings_mobile_value_width` · `uv run ruff check .` → All checks passed · `uv run pyright` → 0 errors.
- **Documentation:** README — fixed "saving one repaints *every* open page" (only the open document repaints; other pages on next load) and a broken paragraph wrap in *Customizing the look*; AGENTS.md rule 16 — `test_chunks_hnsw_recall.py` now described accurately (index paths forced with `enable_seqscan`/`indexonlyscan`/`bitmapscan` off, unforced plan asserted only on a pristine relation); `tests/unit/test_theme_frontend.py` — two stale section headers ("20 form fields"/"20 inputs" → 17 color). No AGENTS.md settings-overlay/resolution-order clause exists, so per the phase none was added. Full env-var cross-check (`BOR_*` in `app/config.py` vs README) → 0 gaps; 0 missing file references.
- **Notable:** the working tree also carries this phase's flake hardening of the phase-188 `tests/integration/test_chunks_hnsw_recall.py` (verified legit and green). Live infra untouched; shared DB left clean (0 `app_settings`, 0 `ui_settings`). **Flagged, not changed:** `settings_overlay.applied_override_keys` lets a `ProgrammingError` (missing `app_settings`) propagate from public `GET /api/config`, where `load_overrides` deliberately fails soft — only reachable on an un-migrated (0027) DB, outside this phase's pinned A1 semantics, so left for the owner to rule on rather than silently deviating.
- **Next pending phase:** `191_source_name_labels_tags`.
This commit is contained in:
2026-10-18 02:59:19 -04:00
parent 47456a44df
commit c6ffa050a6
59 changed files with 4104 additions and 556 deletions
+152 -72
View File
@@ -17,16 +17,24 @@ 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
20 inputs show the effective defaults (the 3 template strings +
the 17 built-in hexes — the 9 identity + the 8 semantic state,
phase 93 — parsed out of ``styles.css``'s ``:root`` IN-TEST — the
suite can never drift from the stylesheet); Save
17 color inputs show the effective defaults (the built-in hexes —
the 9 identity + the 8 semantic state, phase 93 — 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).
(warning-only), and the row lands with its 17 colors and NO brand
string (phase 190, A1).
1b. ``test_tab_renders_the_17_colors_and_no_string_inputs`` — the
phase-190 (A1) strip in the browser: the tab's DOM holds exactly the
17 ``type=color`` inputs and NONE of the old string inputs
(``#theme-app-name`` / ``#theme-placeholder`` / ``#theme-footer`` —
count 0), the admin Settings panel being the single writable owner of
the brand strings; a Save posts a body with the 17 colors and no
string key.
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
@@ -34,8 +42,11 @@ Test → contract mapping (one story, one phase, one isolated file):
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).
stylesheet application). Phase 190 (A1) turned the other half of the
old B4 split into a contract: a palette save writes NO brand string,
so the anonymous page still renders the env/template name,
placeholder and footer line after the save (the boot fetch has
nothing new to apply — a theme save can no longer rename the app).
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
@@ -45,8 +56,8 @@ Test → contract mapping (one story, one phase, one isolated file):
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
20 defaults, serves NO theme tag, and the served bytes equal a
row-less deployment byte for byte (the no-op injection
17 built-in colors, 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
@@ -89,7 +100,7 @@ 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.core.theming import COLOR_FIELDS, STRING_FIELDS
from app.db import SessionLocal
from app.models import UiSettings
from e2e.auth_helpers import login, login_with_token
@@ -133,14 +144,10 @@ PALETTE: dict[str, str] = {
"accent_ink": "#d9c37a",
"accent_line": "#b8963e",
}
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 three brand strings are GONE from this surface (phase 190, A1 —
# the admin Settings panel is their single writable owner), so this
# suite carries no saved-string fixture any more: a palette save writes
# colors only, and every string assertion reads the env/template default.
#: 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).
@@ -306,12 +313,21 @@ def _cookies(page: Page) -> dict[str, str]:
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)
"""Admin ``PUT /api/ui-settings`` with the full 17-color palette
(the API seed — the UI save itself is test 1's job).
Phase 190 (A1): no string key in the body — the Settings panel owns
the brand strings and this API ignores them. The seed asserts the
colors round-trip AND that the effective strings are still the
env/template defaults, i.e. a theme save cannot rename the app."""
defaults = _template_defaults()
r = httpx.put(f"{app_url}/api/ui-settings", json=PALETTE, cookies=cookies, timeout=10)
assert r.status_code == 200, r.text
assert r.json() == body, "the PUT must echo the new effective values"
body = r.json()
for field in COLOR_FIELDS:
assert body[field] == PALETTE[field], f"effective {field} != {PALETTE[field]}"
for field, value in defaults.items():
assert body[field] == value, f"{field} must stay the effective default (A1)"
def _hold_theme_puts(page: Page, hold_s: float = 0.6) -> None:
@@ -336,27 +352,22 @@ 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 20 inputs: the 3 text fields (``strings``, default
the E2E set) + the 17 color pickers (``palette`` — all 17 vars,
phase 93)."""
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"])
#: The three string inputs the Theme tab used to render (phase 91) —
#: REMOVED in phase 190 (A1: the admin Settings panel is the single
#: writable owner of the brand strings). Every pin below asserts they
#: count 0 in the tab DOM.
REMOVED_STRING_IDS = ("#theme-app-name", "#theme-placeholder", "#theme-footer")
def _fill_theme_form(page: Page, palette: dict[str, str]) -> None:
"""Fill the 17 color pickers (``palette`` — all 17 vars, phase 93).
The tab has no string inputs since phase 190 (A1)."""
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 20 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"])
def _expect_form_values(page: Page, colors: dict[str, str]) -> None:
"""Assert the 17 color inputs show the given effective values."""
for field in COLOR_FIELDS:
expect(page.locator(COLOR_INPUT_IDS[field])).to_have_value(colors[field])
@@ -401,7 +412,6 @@ def _wait_theme_computed(page: Page, colors: dict[str, str], timeout: int = 15_0
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")
@@ -418,15 +428,13 @@ 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 20 inputs show the EFFECTIVE defaults: the 3 template
# strings + the 17 built-in hexes (9 identity + 8 semantic)
# parsed straight out of styles.css's :root (the resolver's
# missing-row branch).
_expect_form_values(page, defaults, builtin)
# The 17 color inputs show the EFFECTIVE defaults: the built-in
# hexes (9 identity + 8 semantic) parsed straight out of
# styles.css's :root (the resolver's missing-row branch).
_expect_form_values(page, 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.
# Set a distinct palette, 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:
@@ -448,19 +456,20 @@ def test_theme_tab_admin_save(page: Page, app_url: str, db_ready: None) -> None:
finally:
_release_theme_puts(page)
# The inputs re-populate to the SAVED (effective) values (the
# The inputs re-populate to the SAVED (effective) colors (the
# save's refetch is the canonical state).
_expect_form_values(page, SAVED_STRINGS, PALETTE)
_expect_form_values(page, PALETTE)
# The row landed in Postgres (the id-1 single row, all 20 values
# — every palette color differs from its built-in, so nothing
# collapsed to NULL).
# The row landed in Postgres (the id-1 single row) with its 17
# colors — every palette color differs from its built-in, so nothing
# collapsed to NULL — and with NO brand string: the tab no longer
# writes them, which is the write half of the TODO.md L3 fix
# (phase 190, A1).
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 STRING_FIELDS:
assert getattr(row, field) is None, f"{field} must never be stored"
for field in COLOR_FIELDS:
assert getattr(row, field) == PALETTE[field]
@@ -476,10 +485,73 @@ def test_theme_tab_admin_save(page: Page, app_url: str, db_ready: None) -> None:
expect(contrast).to_have_text("--bg on --brand: 3.0:1 — needs 4.5:1")
# ---------------------------------------------------------------------------
# 1b. The phase-190 (A1) strip in the browser: 17 color inputs, no
# string inputs, and a Save body that carries no string key
# ---------------------------------------------------------------------------
def test_tab_renders_the_17_colors_and_no_string_inputs(
page: Page, app_url: str, db_ready: None
) -> None:
"""The owner-visible shape of the strip (phase 190, A1): the Theme
tab is a COLORS surface — its DOM holds exactly the 17
``type=color`` pickers and NONE of the three string inputs it used
to render, and the body Save posts carries the 17 colors and no
string key. That is the read half of "changing the App name in
settings broke": the tab can neither show a stale name nor bake one
into its row (the write half is the ignored-string PUT, pinned by
``tests/integration/test_ui_settings_api.py``)."""
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 three string inputs are ABSENT — not hidden, not empty: gone
# from the DOM, so no selector can fill one and no populate() can
# write a stale effective value into it.
for selector in REMOVED_STRING_IDS:
expect(page.locator(selector)).to_have_count(0)
expect(page.get_by_label("App name")).to_have_count(0)
# The 17 color pickers are all there and still type=color.
color_inputs = page.locator('#view-theme input[type="color"]')
expect(color_inputs).to_have_count(len(COLOR_FIELDS))
_expect_form_values(page, builtin)
# Save posts the 17 colors only — the captured PUT body names no
# string field (the client half of the bake being dead).
captured: dict[str, object] = {}
def handle(route: Route) -> None:
if route.request.method == "PUT":
captured["body"] = route.request.post_data_json
route.continue_()
page.route("**/api/ui-settings", handle)
try:
_fill_theme_form(page, PALETTE)
page.click("#theme-save")
expect(page.locator("#theme-result")).to_have_text(
"Theme saved.", timeout=30_000
)
finally:
page.unroute("**/api/ui-settings")
body = captured.get("body")
assert isinstance(body, dict), "the Save PUT was not captured (route hook?)"
assert set(body) == set(COLOR_FIELDS), (
f"the PUT body must be the 17 colors only, got {sorted(body)}"
)
for field in STRING_FIELDS:
assert field not in body
assert body.get("bg") == PALETTE["bg"]
# ---------------------------------------------------------------------------
# 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
# + the computed palette at load (the no-pop-in proof), and the
# brand strings UNTOUCHED by a palette save (phase 190 A1)
# ---------------------------------------------------------------------------
@@ -527,16 +599,25 @@ def test_saved_theme_is_pre_paint_for_everyone(
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
# Phase 190 (A1): the palette save wrote NO brand string, so
# the anonymous page renders the env/template strings through
# the SAME brand.js boot fetch (name + window global, the
# composer placeholder, the footer line) — saving a theme can no
# longer rename the deployment, which is the owner-visible half
# of the TODO.md L3 fix. (The strings' own surface — the admin
# Settings panel — is pinned by test_admin_settings_panel.py and
# the phase-190 suite.)
defaults = _template_defaults()
expect(anon.locator(".brand-text")).to_have_text(
defaults["app_name"], timeout=15_000
)
assert anon.evaluate("() => window.BOR_BRAND") == defaults["app_name"]
expect(anon.locator("#message-input")).to_have_attribute(
"placeholder", PLACEHOLDER
"placeholder", defaults["input_placeholder"]
)
expect(anon.locator(".footer-text").first).to_have_text(
defaults["footer_text"]
)
expect(anon.locator(".footer-text").first).to_have_text(FOOTER)
finally:
if anon_ctx is not None:
anon_ctx.close()
@@ -625,7 +706,7 @@ def test_anonymous_and_token_user_are_walled(
# ---------------------------------------------------------------------------
# 4. Reset: the §7.4 lifecycle, the 20 defaults, NO theme tag, and
# 4. Reset: the §7.4 lifecycle, the 17 built-in colors, NO theme tag, and
# byte-identical served HTML (the no-op injection contract)
# ---------------------------------------------------------------------------
@@ -633,14 +714,13 @@ def test_anonymous_and_token_user_are_walled(
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)
_expect_form_values(page, builtin)
# Save a distinct theme through the UI (the reset must undo a
# REAL save)…
@@ -672,9 +752,9 @@ def test_reset_restores_the_builtin_byte_identical(
finally:
_release_theme_puts(page)
# The form re-populates to the 20 defaults (the env/built-in
# The form re-populates to the 17 built-in colors (the built-in
# merge, re-rendered from the refetch)…
_expect_form_values(page, defaults, builtin)
_expect_form_values(page, builtin)
# …and the WCAG warning is gone (the built-in palette passes all
# eight pairs).
expect(page.locator("#theme-contrast")).to_be_hidden()