phase: 92_theme_save_and_coverage
Build and Push Containers / build-and-push-app (push) Successful in 1m47s
Build and Push Containers / build-and-push-db (push) Successful in 11s

**Phase 92 final verification pass — all green.** This pass re-verified the completed tasks (all 5 task files already in `complete/`) against every completion criterion; no defects found, nothing to fix.

- Verified: 9th identity var `grid_line` end-to-end (migration `0015` at head, model/`theming.py`/schemas/API, 422 + built-in→NULL tests present); `styles.css` zero hardcoded literals outside `:root` + derived `--brand-*` vars; 9th picker in theme form; wordmark themed; `theme.js` save/reset/re-show/mount live-sync; dedicated E2E suite + phase-91 suite updated.
- `uv run pytest --cov=app --cov-report=term-missing` → **1845 passed, exit 0, TOTAL 99%** (>90%)
- `uv run ruff check .` → clean; `uv run pyright` → 0 errors, 0 warnings
- `uv run pytest tests/e2e/test_theme_save_and_coverage.py -v --no-cov` → **3 passed** (save-live, reset-live, whole-site)
- `uv run pytest tests/e2e/test_admin_theme_tab.py -v --no-cov` → **5 passed**
- Criteria: (1) Save/Reset repaint open page, no nav, SPA-nav survives, pre-paint intact ✅; (2) both `rg` gates green (only `:root` + documented `#fff` Stop label; zero SVG hex attrs), grid/selection/hovers/wash/wordmark E2E-proven ✅; (3) no-op contract live-checked: row-less `/` = no tag + exact A1 CSP, grid-only row = 9-var tag in `COLOR_FIELDS` order + sha256 CSP, with-row ≡ row-less bytes ✅; (4) full suite/coverage/lint/both E2E ✅; (5) commit left to the harness per instructions.
- Deviations (previously made, probe-verified, kept): live repaint uses CSSOM `<html>` overrides because Chromium blocks `<style>` textContent mutations under the locked sha256-only CSP (tag text still mirrors the next load; `<html>` style exact-saved after Save, empty after Reset); wordmark themed via 3 `.brand-mark` CSS rules instead of inline styles (task 03's inline attrs were CSP-blocked — fixed during task 04).
- Next pending phase: none — `todo/` contains only `92_theme_save_and_coverage`.
This commit is contained in:
2026-09-10 00:23:08 -04:00
parent d22d260b8b
commit df91c6316c
49 changed files with 2282 additions and 189 deletions
+13 -9
View File
@@ -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
+36 -2
View File
@@ -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/<token>`` (the prefix branch) — carries EXACTLY ONE
``<style id="bor-theme">`` IMMEDIATELY before ``</head>`` (a leading
newline, nothing between), with all 8 ``--*`` vars in ``COLOR_FIELDS``
newline, nothing between), with all 9 ``--*`` vars in ``COLOR_FIELDS``
order and the changed value; the ``?v=`` asset rewrite still applies
alongside."""
db.execute(text("DELETE FROM ui_settings"))
@@ -819,7 +819,7 @@ def test_middleware_themed_injects_tag_before_head_on_every_page(db: Session) ->
# (nothing between the tag and the close).
assert "\n" + tag + "</head>" in r.text
assert r.text.index(tag) == r.text.index("</head>") - len(tag)
# All 8 vars, COLOR_FIELDS order, the changed value present.
# All 9 vars, COLOR_FIELDS order, the changed value present.
declared = re.search(r'<style id="bor-theme">:root\{([^}]*)\}</style>', 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 + "</head>" in r.text
# All 9 vars, COLOR_FIELDS order (grid_line between line and brand).
declared = re.search(r'<style id="bor-theme">:root\{([^}]*)\}</style>', 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",)],
+3 -1
View File
@@ -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.
+7 -3
View File
@@ -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
+5 -4
View File
@@ -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'<form[^>]*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",
+8 -7
View File
@@ -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)"
+6 -2
View File
@@ -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"
)
+6 -3
View File
@@ -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)
+35 -16
View File
@@ -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 == (
'<style id="bor-theme">:root{'
"--bg:#0f0a0a;--surface:#1a0f0f;--ink:#f0e6e6;--ink-soft:#b8a8a8;"
"--line:#2d1a1a;--brand:#818cf8;--brand-soft:#2d0a0a;--brand-ink:#fca5a5;"
"--line:#2d1a1a;--grid-line:#4a2626;--brand:#818cf8;"
"--brand-soft:#2d0a0a;--brand-ink:#fca5a5;"
"}</style>"
)
# The changed value lands under the dashed CSS name…
@@ -211,7 +230,7 @@ def test_theme_style_tag_multiple_changed() -> None:
assert tag.startswith('<style id="bor-theme">:root{--bg:#0a0e1a;')
assert "--brand-ink:#c7d2fe;" in tag
assert tag.endswith("}</style>")
# 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]
+34 -3
View File
@@ -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")