Files
brain-of-reese/tests/unit/test_settings_frontend_view.py
T
ducoterra e9ae6e2ba3
Build and Push Containers / build-and-push-app (push) Successful in 1m34s
Build and Push Containers / build-and-push-db (push) Successful in 14s
phase: 171_admin_settings_layout
Expected state: only `00_phase.md` remains in `todo/` (the harness moves it on success; I must not touch it). All verification is complete.

---

**Phase 171 final verification pass — report**

- **Verified (all 4 tasks complete, CSS-only diff confirmed byte-exact to contract):** `#settings-content` flex column + `gap: 1.25rem` (tasks 01); `.settings-group-row th` band `--brand-soft` + 700 + `--line` top hairline (02); `#settings-save:disabled` `cursor: default` (03); new phase-dedicated Playwright suite (04). Mobile media queries and `settings.js`/`app/` byte-untouched, as pinned.
- **Gates:** `uv run pytest --cov=app --cov-report=term-missing` → **4069 passed, app/ 99%** (>90%); `uv run ruff check .` → clean; `uv run pyright` → **0 errors, 0 warnings**.
- **E2E (in isolation):** `test_admin_settings_layout.py` → **5 passed**; `test_admin_settings_panel.py` (phase 168) → **12 passed**; `test_wide_desktop_column.py` → **3 passed**.
- **Completion criteria:** search/table no-intersection + gapped action row at 1440/375 px ✅ · distinct group bands (bg ≠ card, weight 700, ≥1px top hairline) ✅ · disabled Save `cursor: default` with §7.4 dim + aria-busy lifecycle intact ✅ · full suite + coverage + lint ✅ · all three E2E suites isolated ✅ · atomic commit + dir move = **harness's job** (task files already in `complete/`; per protocol I left every change uncommitted in the working tree).
- **Documentation:** README.md (all 1487 lines) and AGENTS.md (all 80 lines) fully reviewed — every config default cross-checked vs `app/config.py`, all quick-reference commands verified against existing files, AGENTS.md rules/contracts verified vs code — **verified consistent, no changes needed** (phase 171 is CSS-only: no setup/command/env/API impact).
- **Notable:** one phase-168 test failed on the first isolated run solely because an unrelated `undercooked` dev server held port 8124 (the suite's `DISABLED_PORT`); re-ran with the suite's built-in `E2E_APP_PORT=8300` override — no code change, foreign process untouched. Post-run DB state clean (`app_settings` 0, `ui_settings` 0, e2e tokens 0).
- **Next pending phase:** none — phase 171 was the last `todo/` phase.
2026-10-10 04:45:34 -04:00

809 lines
37 KiB
Python

"""Unit: the phase-168 Settings view wiring (task 05) — the static
source-level pins for the shell's eighth view.
The browser behavior is E2E-gated (the phase-168 story suite —
``test_admin_settings_panel.py``, task 06); here we pin the
source-level invariants the "views of one document" architecture
depends on, so a silent regression is caught without a browser
(house pattern: ``tests/unit/test_thinking_scroll.py`` /
``test_frontend_router.py`` read the sources and assert on their
mechanisms).
Pinned (task 05 spec):
* ``index.html`` carries the ``#view-settings`` section (ship-hidden
+ inert + focusable — the WCAG pair, AGENTS.md rule 5) with the
``#settings-gate`` (ship-hidden, its Sign in returning to the
Settings view), the ship-hidden ``#settings-content`` holding the
``#settings-disabled`` kill-switch banner (D6), the ONE
``role="status"`` region, the ``role="alert"`` error line, the
client-side search input, the ``.table-wrap`` catalogue with the
visually-hidden caption + the four-column thead, and the action
row (``#settings-save`` — ships disabled — ``#settings-clear-all``
+ the aria-hidden ``#settings-dirty`` hint); the ``#nav-settings``
link ships hidden (admin-only).
* ``router.js`` maps ``/settings.html`` in ALL THREE tables
(VIEW / VIEW_PATH / VIEW_MODULES — a static specifier so the
Containerfile's esbuild stage inlines it into the router bundle)
+ the per-view title/description (the router is the SINGLE writer
of both).
* ``app/main.py``'s shell tuple and ``app/core/caching.py``'s
``HTML_PAGES`` both list ``/settings.html`` (the no-cache +
``?v=`` contract, A19).
* ``header.js`` reveals ``#nav-settings`` keyed off ``admin`` (NOT
``signedIn`` — a token user never sees it), the same two-line
null-safe pattern as the other admin-only links.
* ``settings.js`` builds the DOM with ``createElement`` /
``textContent`` — NO ``innerHTML`` (template-literal or not) and
NO ``http(s)://`` reference (rule 6 — everything local; the
no-HTML-strings house rule).
* ``styles.css`` declares ``.settings-table`` + the three
``.settings-src.is-*`` variants + the dirty-row marker and adds
NO new ``:root`` variable (the phase-156 B3 rule — the badges
are solid existing tokens).
* phase 171 (task 01): ``styles.css`` styles ``#settings-content``
as a flex column with the shell's own 1.25rem gap (the ONE
uniform gap — the search row and the action row keep no
margin), and the four 640px stacking rules survive unchanged.
* phase 171 (task 02): the ``.settings-group-row th`` band is the
``--brand-soft`` tint (the sticky-th solid — B3: no new palette
variable), weight 700, and hairline-delimited above (a
``--line`` border-top) — the band reads as a category at a
glance and never merges with the card edge or the band above
it (the ``.settings-table th`` family supplies the bottom
border); the row cells stay on the card's ``--surface``.
* phase 171 (task 03): a disabled ``#settings-save`` is *just*
disabled — ``cursor: default`` (the ``.settings-reset:disabled``
idiom; the ``wait`` loading cursor is gone), the §7.4 dim
(``opacity: 0.6``) + ``aria-busy`` in-flight state intact, and
``cursor: wait`` is absent from every ``.settings*`` /
``#settings-*`` rule (the negative pin is scoped to the
settings rules — the chat edit form's ``.edit-save`` keeps its
own ``wait`` and is out of scope).
* the FOUR pages that carry the Theme link (index / document /
login / shared) now carry the Settings link too (the phase-34
one-bar contract — test_nav_consistency pins the parity).
"""
from __future__ import annotations
import re
from pathlib import Path
FRONTEND = Path(__file__).resolve().parents[2] / "frontend"
ASSETS = FRONTEND / "assets"
REPO = Path(__file__).resolve().parents[2]
def _read(path: Path) -> str:
assert path.is_file(), f"missing {path}"
return path.read_text(encoding="utf-8")
def _html() -> str:
return _read(FRONTEND / "index.html")
# ---------- the shell markup: the #view-settings section ----------
def test_settings_view_section_ships_hidden_inert_focusable() -> None:
"""The #view-settings section lives in the shell's single main,
ships hidden AND inert (the WCAG a11y pair — a hidden view must
not receive focus or keyboard traversal), and is focusable
(tabindex="-1" — the router focuses it on user-initiated
switches)."""
html = _html()
view = html.find('<section class="view" id="view-settings"')
assert view != -1, "the #view-settings section must be in the shell"
tag_end = html.find(">", view)
tag = html[view:tag_end]
assert "hidden" in tag and "inert" in tag, (
"the folded view ships hidden AND inert"
)
assert 'tabindex="-1"' in tag, "the target view is focusable"
assert 'aria-label="Settings"' in tag, "the view is labelled"
main_end = html.find("</main>", view)
assert view < main_end, "the view section lives inside the single main"
# The 72rem .container — no new width token (PLAN §7.1).
body = html[view:main_end]
assert 'class="container settings-shell"' in body, (
"the view rides the standard 72rem .container (no new width)"
)
def test_settings_page_head_states_the_rule_in_the_owner_terms() -> None:
"""The page-head: <h1>Settings</h1> + a .page-sub that states
the rule in the owner's terms — the value is stored in the
database and wins over the environment variables; clearing hands
control back to the environment (and the .env file is never
written — LOCKED A1)."""
html = _html()
view = html.find('<section class="view" id="view-settings"')
main_end = html.find("</main>", view)
body = html[view:main_end]
assert "<h1>Settings</h1>" in body
sub = re.search(r'<p class="page-sub">([\s\S]*?)</p>', body)
assert sub, "the .page-sub must exist"
text = " ".join(sub.group(1).split())
assert "stored in the database" in text, (
"the sub states: the value is stored in the database"
)
assert "win over" in text, "the sub states: it wins over the env vars"
assert "hands control back to the environment" in text, (
"the sub states: clearing hands control back to the environment"
)
def test_settings_gate_is_the_tokens_gate_shape_ships_hidden() -> None:
"""The anonymous sign-in gate: the EXACT #tokens-gate shape —
the .sources-gate visual language, ship-hidden, its heading
naming what is admin-only, and its Sign in returning to the
Settings view (?next=/settings.html — the no-JS fallback)."""
html = _html()
view = html.find('<section class="view" id="view-settings"')
main_end = html.find("</main>", view)
body = html[view:main_end]
gate = re.search(r'<section[^>]*id="settings-gate"[^>]*>', body)
assert gate, "the #settings-gate must exist"
assert "hidden" in gate.group(0), "#settings-gate must ship hidden"
assert 'class="sources-gate"' in gate.group(0), (
"the gate reuses the .sources-gate visual language"
)
assert 'aria-labelledby="settings-gate-title"' in gate.group(0)
assert "<h2 id=\"settings-gate-title\">Sign in to change settings</h2>" in body
assert 'href="/login.html?next=/settings.html"' in body, (
"the gate's Sign in returns to the Settings view (no-JS fallback)"
)
def test_settings_content_scaffold_and_feedback_regions() -> None:
"""The ship-hidden #settings-content holds, in order: the
#settings-disabled kill-switch banner (role=alert, ship-hidden —
D6: the mechanism is off, not the panel), the ONE status region
(role=status, aria-live=polite — the #tokens-status precedent),
the role=alert error line (ship-hidden — the #theme-error
precedent), the search row (label + type=search input), the
.table-wrap catalogue (the visually-hidden caption + the
four-column thead with the visually-hidden Actions header), and
the action row (Save — ships DISABLED — Clear all + the
aria-hidden dirty hint)."""
html = _html()
view = html.find('<section class="view" id="view-settings"')
main_end = html.find("</main>", view)
body = html[view:main_end]
content = re.search(r'<div[^>]*id="settings-content"[^>]*>', body)
assert content and "hidden" in content.group(0), (
"#settings-content must ship hidden (anonymous-safe)"
)
banner = re.search(
r'<div[^>]*class="kb-banner is-error"[^>]*id="settings-disabled"[^>]*>'
r"|<div[^>]*id=\"settings-disabled\"[^>]*>",
body,
)
assert banner, "the #settings-disabled banner must exist"
btag_match = re.search(r'<div[^>]*id="settings-disabled"[^>]*>', body)
assert btag_match, "the #settings-disabled banner tag must match"
btag = btag_match.group(0)
assert 'role="alert"' in btag and "hidden" in btag, (
"the banner is a ship-hidden role=alert"
)
status = re.search(
r'<span[^>]*id="settings-status"[^>]*>', body
)
assert status, "the #settings-status region must exist"
stag = status.group(0)
assert 'role="status"' in stag and 'aria-live="polite"' in stag, (
"the ONE status region (the #tokens-status precedent)"
)
error = re.search(r'<p[^>]*id="settings-error"[^>]*>', body)
assert error, "the #settings-error line must exist"
etag = error.group(0)
assert 'role="alert"' in etag and "hidden" in etag, (
"the error line is a ship-hidden role=alert"
)
# The search row: the label + the type=search input (client-side,
# the phase-101 D4 contract — no fetch).
assert '<label for="settings-filter">Search settings</label>' in body
filter_input = re.search(
r'<input[^>]*id="settings-filter"[^>]*>', body, re.S
)
assert filter_input, "the #settings-filter input must exist"
assert 'type="search"' in filter_input.group(0), (
"the filter is a type=search input"
)
# The scroll card: the .table-wrap + the visually-hidden caption
# + the four-column thead (the Actions header visually-hidden —
# the row buttons carry their own aria-labels).
assert 'class="table-wrap"' in body, (
"the catalogue rides the .table-wrap card (the phase-156 "
"bounded scroll + sticky header contract)"
)
table = re.search(r'<table[^>]*class="settings-table"[^>]*>', body)
assert table, "the .settings-table must exist"
thead = body[body.find('class="settings-table"') : body.find("</thead>")]
assert '<caption class="visually-hidden">' in thead, (
"the caption is visually-hidden (the phase-86 table pattern)"
)
assert thead.count('<th scope="col">') == 4, (
"the table is four columns (Setting | Value | Source | Actions)"
)
assert '<th scope="col">Setting</th>' in thead
assert '<th scope="col">Value</th>' in thead
assert '<th scope="col">Source</th>' in thead
assert (
'<th scope="col"><span class="visually-hidden">Actions</span></th>'
in thead
), "the Actions header is visually-hidden (the row buttons are labelled)"
# The action row: Save (type=button, ships DISABLED — no dirty
# rows yet), Clear all (type=button), the aria-hidden dirty hint.
save = re.search(r'<button[^>]*id="settings-save"[^>]*>', body)
assert save, "the #settings-save button must exist"
stag = save.group(0)
assert 'type="button"' in stag and "disabled" in stag, (
"Save is a type=button that ships disabled (no dirty rows)"
)
clear_all = re.search(r'<button[^>]*id="settings-clear-all"[^>]*>', body)
assert clear_all, "the #settings-clear-all button must exist"
assert 'type="button"' in clear_all.group(0)
assert "Clear all overrides" in body
hint = re.search(r'<span[^>]*id="settings-dirty"[^>]*>', body)
assert hint, "the #settings-dirty hint must exist"
assert 'aria-hidden="true"' in hint.group(0), (
"the hint is aria-hidden (the rows + the status carry the info)"
)
def test_settings_nav_link_ships_hidden_in_the_shell() -> None:
"""The #nav-settings link (the shell's eighth view) ships hidden
(admin-only) and UNstamped — the router is the single writer of
the active state, and a token user (role "user") must never see
the link (header.js reveals it for admin only)."""
html = _html()
match = re.search(r'<a[^>]*id="nav-settings"[^>]*>', html)
assert match, "the shell must carry the #nav-settings nav link"
link = match.group(0)
assert 'href="/settings.html"' in link, "the Settings link's href"
assert "hidden" in link, "#nav-settings ships hidden (admin-only)"
assert "is-active" not in link, "no static active stamp on the Settings link"
def test_nav_settings_sits_after_nav_theme() -> None:
"""The one-bar contract: the Settings link is the LAST admin link
(after #nav-theme) — the navbar order is part of the
test_nav_consistency inventory, so the position is pinned."""
html = _html()
theme_i = html.find('id="nav-theme"')
settings_i = html.find('id="nav-settings"')
sign_in_i = html.find('id="sign-in-link-mobile"')
assert 0 <= theme_i < settings_i < sign_in_i, (
"the Settings link sits after Theme and before the auth links"
)
# ---------- router.js: /settings.html in ALL THREE tables ----------
def test_router_js_maps_settings_in_all_three_tables() -> None:
"""router.js: VIEW maps /settings.html → settings, VIEW_PATH maps
settings → /settings.html, and VIEW_MODULES lazy-imports
./settings.js with a STATIC specifier (so the Containerfile's
esbuild --bundle of router.js inlines it — settings.js needs no
new esbuild line because no page references it directly;
tests/integration/test_containerfile_assets.py proves the
coverage). The router is the SINGLE writer of the per-view
title/meta — settings has both entries."""
js = _read(ASSETS / "router.js")
view_start = js.find("const VIEW = {")
view_body = js[view_start : js.find("\n}", view_start)]
assert '"/settings.html": "settings"' in view_body, (
"phase 168 task 05 folds the Settings view into the shell"
)
path_start = js.find("const VIEW_PATH = {")
path_body = js[path_start : js.find("\n}", path_start)]
assert 'settings: "/settings.html"' in path_body, (
"VIEW_PATH must map settings → /settings.html"
)
mods_start = js.find("const VIEW_MODULES = {")
mods_body = js[mods_start : js.find("\n}", mods_start)]
assert 'settings: () => import("./settings.js")' in mods_body, (
"the Settings view module is lazy-imported on first show "
"(a static specifier — the bundle inlines it)"
)
# The per-view head values (the router's single-writer contract).
assert 'settings: "Settings · Brain of Reese"' in js, (
"the Settings view's title (carried through brandName)"
)
assert (
"Edit any application setting — a saved value wins over the environment, "
"live on save; reset hands control back."
) in js, "the Settings view's meta description"
# ---------- the server-side registration (shell route + caching) ----------
def test_shell_route_and_html_pages_carry_settings() -> None:
"""app/main.py's _shell_routes tuple and caching.HTML_PAGES both
list /settings.html — without the tuple the deep link 404s
(the static mount has no settings.html file), without the
HTML_PAGES entry it would serve unversioned asset refs, which
the immutable-for-a-year asset caching would pin to stale CSS
after a deploy (the phase-35 lesson, the A19 contract)."""
main_py = _read(REPO / "app" / "main.py")
app_start = main_py.find("def create_app")
mount_i = main_py.find('app.mount("/", StaticFiles')
assert 0 <= app_start < mount_i, "create_app + the static mount must exist"
routes_block = main_py[app_start:mount_i]
assert '"/settings.html"' in routes_block, (
"main.py's shell tuple must list /settings.html (deep link)"
)
caching_py = _read(REPO / "app" / "core" / "caching.py")
pages_start = caching_py.find("HTML_PAGES: tuple")
pages_end = caching_py.find("\n)\n", pages_start)
pages_block = caching_py[pages_start:pages_end]
assert '"/settings.html"' in pages_block, (
"caching.HTML_PAGES must list /settings.html (no-cache + ?v=)"
)
# The shell route comes AFTER the API router registration and
# BEFORE the static mount (routes-first — the docstring contract).
include_i = main_py.find("app.include_router(admin_settings_router")
assert 0 <= app_start < include_i < mount_i, (
"the shell route sits between the API routers and the static mount"
)
# ---------- header.js: the reveal is admin-keyed, null-safe ----------
def test_header_js_reveals_settings_link_for_admin_only() -> None:
"""header.js reveals #nav-settings for role admin — the same
ship-hidden / reveal-for-admin contract as the other admin-only
links: the two-line reveal (`hidden = !admin`) sits in
initSharedHeader, null-safe (a page without the link is a
no-op), and the gate is the `admin` flag (role === "admin") —
a token user (role "user") never sees the link. The lookup +
reveal sit AFTER the whoami resolution (the admin flag exists
only once fetchWhoami has settled)."""
header_js = _read(ASSETS / "header.js")
fn = header_js.find("export async function initSharedHeader")
assert fn != -1, "initSharedHeader must exist"
body = header_js[fn:]
lookup = body.find('document.querySelector("#nav-settings")')
assert lookup != -1, "header.js must look up #nav-settings"
reveal = body.find("navSettings.hidden = !admin")
assert 0 <= lookup < reveal, (
"the reveal must be the two-line pattern: null-safe lookup, "
'then hidden = !admin (the admin flag — role === "admin")'
)
whoami = body.find("const whoami = await fetchWhoami()")
admin_flag = body.find('const admin = whoami.role === "admin"')
assert 0 <= whoami < admin_flag < lookup, (
"the reveal keys off the resolved admin flag"
)
# The gate is `admin`, NOT `signedIn` (a token user never sees
# it — the phase-79 contract).
reveal_line = body[lookup:reveal + 60]
assert "signedIn" not in reveal_line, (
"the reveal must key off admin (a token user never sees the link)"
)
# ---------- settings.js: DOM APIs only, no CDN ----------
def test_settings_js_builds_dom_with_element_apis_only() -> None:
"""settings.js follows the phase-76 view-module contract:
`export async function mount(root)` is the entry, the whoami
gate (fetchIsAdmin) runs in mount and the anonymous branch shows
the gate + hides the content + RETURNS with NO
/api/admin/settings request (the router 403s anonymous), and the
bor:view-refresh listener is armed ONLY in the ADMIN branch
(after the gate), gated on the started flag. The DOM is built
with createElement + textContent — NO innerHTML (template
literal or not: the XSS-safe-by-construction house rule) and NO
http(s):// reference (rule 6 — everything is local; the fetch
paths are same-origin)."""
js = _read(ASSETS / "settings.js")
assert "export async function mount(root)" in js, (
"mount(root) must be the module's entry (the phase-76 fold)"
)
assert 'import { fetchIsAdmin } from "./header.js";' in js, (
"the view imports ONLY the shared cached whoami promise"
)
mount_i = js.find("export async function mount(root)")
gate_i = js.find("if (!(await fetchIsAdmin()))")
assert 0 <= mount_i < gate_i, "the whoami gate must run in mount"
branch = js[gate_i:js.find("return;", gate_i)]
assert "fetch(" not in branch, (
"the anonymous branch must not fetch anything"
)
assert "gateEl.hidden = false" in branch
assert "contentEl.hidden = true" in branch
# The re-show refresh: armed in the ADMIN branch only (after the
# gate), gated on the first load (started).
listener = js.find('addEventListener("bor:view-refresh"')
assert 0 <= gate_i < listener, (
"the refresh listener is armed only in the ADMIN branch (after the gate)"
)
assert re.search(r"if \(started\) void loadSettings\(\)", js), (
"the listener is gated on the first load (started)"
)
# No HTML-string DOM building (the house rule) — innerHTML is
# absent from the file entirely (a template-literal innerHTML
# would also match this check).
assert "innerHTML" not in js, (
"the DOM is built with createElement + textContent — no "
"innerHTML anywhere (XSS-safe by construction)"
)
# No CDN (rule 6): no http(s):// reference in the module — the
# fetch paths are same-origin (/api/admin/settings…).
assert not re.search(r"https?://", js), (
"no CDN — every reference is local (AGENTS.md rule 6)"
)
# The API surface: GET the catalogue, PUT one value, DELETE one /
# DELETE all (the phase-168 API contract, task 04).
assert 'fetch("/api/admin/settings")' in js, "the catalogue GET"
assert "fetch(`/api/admin/settings/${key}`" in js, (
"the per-key PUT / DELETE"
)
# The §7.4 lifecycle: the save button is aria-busy + labelled
# while in flight, re-enabled in the same path (success AND
# failure — the saveInFlight guard).
assert 'setAttribute("aria-busy", "true")' in js
assert "removeAttribute(\"aria-busy\")" in js
assert 'textContent = "Saving…"' in js
def test_settings_js_dirty_tracking_and_stop_at_first_failure() -> None:
"""The K4 contract, pinned at source level: the dirty state is a
module Map (key → value); markDirty compares the control value
against the SERVED value (a masked field's served value is "" —
an empty input means "leave unchanged", never an accidental
wipe); Save walks the dirty rows IN DOM ORDER, PUTs { value },
and breaks at the first failure (the 422 detail lands in the
role=alert region, the load re-applies the remaining edits,
focus goes to the offending input); a successful save lands the
`Saved N settings.` status line + the restart-required
suffix."""
js = _read(ASSETS / "settings.js")
assert "const dirty = new Map();" in js, (
"the dirty state is a module Map"
)
mark = js.find("function markDirty(")
mark_body = js[mark:js.find("\n }", mark)]
assert 'item.secret ? "" : item.value ?? ""' in mark_body, (
"the served value is '' for a masked field (never an accidental wipe)"
)
save = js.find("async function saveSettings()")
save_body = js[save:js.find("\n }", save)]
assert "for (const row of dataRows())" in save_body, (
"Save walks the dirty rows in DOM order"
)
put_i = save_body.find("JSON.stringify({ value })")
break_i = save_body.find("break; // stop at the first failure (K4)")
assert 0 <= put_i < break_i, "the loop breaks at the first failure (K4)"
assert "showError(" in save_body, "the 422 detail lands in the error region"
assert "input.focus()" in save_body, (
"focus goes to the offending input (K4)"
)
assert '`Saved ${saved.length} settings.`' in save_body, (
"the one status line (role=status)"
)
assert "applies on the next restart" in save_body, (
"a saved restart-required row says so (the owner is never "
"told 'saved' and then sees nothing change)"
)
# The reset + clear-all contract: the DELETE one/all + the
# settled status lines.
assert "`${envVar} is back to the environment value.`" in js, (
"the per-row Reset status line"
)
assert "Cleared ${cleared} overrides." in js, (
"the Clear-all status line (N from the response)"
)
# The two-step confirm (the house history-confirm-* classes,
# focus to Yes, Escape / No cancels).
assert 'label.textContent = "Clear all overrides?"' in js
assert 'yes.className = "history-confirm-yes"' in js
assert 'no.className = "history-confirm-no"' in js
assert "yes.focus()" in js, "focus moves to Yes (keyboard confirm)"
assert "e.key === \"Escape\"" in js, "Escape cancels (the task contract)"
# ---------- styles.css: the settings block, no new :root tokens ----------
def test_styles_css_settings_block_uses_existing_tokens_only() -> None:
"""styles.css declares the .settings-table (full width — the
.git-sources-table language, no skinny list) + the three
.settings-src.is-* variants (SOLID existing tokens — B3) + the
dirty-row marker, and adds NO new :root variable (the phase-156
B3 rule — the badges are text, the color is decoration; a themed
page's #bor-theme override of the token handles the color)."""
css = _read(ASSETS / "styles.css")
assert "---------- Settings view (phase 168) ----------" in css, (
"the Settings view block must exist"
)
block = re.search(r"\.settings-table \{([\s\S]*?)\n\}", css)
assert block, "styles.css must style .settings-table"
assert "width: 100%" in block.group(1), (
"the catalogue is full width (no skinny list — PLAN §7.1)"
)
for variant, bg in (
("is-db", "var(--brand-soft)"),
("is-env", "var(--surface)"),
("is-default", "var(--line)"),
):
rule = re.search(
rf"\.settings-src\.{variant} \{{([\s\S]*?)\n\}}", css
)
assert rule, f"styles.css must style .settings-src.{variant}"
body = rule.group(1)
assert bg in body, f".settings-src.{variant} is the solid {bg} token (B3)"
# The badge is TEXT (B5): the text is never color-alone — the
# variant always pairs the background with an existing ink
# token (never a color declaration without a text color).
assert "color: var(--" in body, (
f".settings-src.{variant} carries an existing ink token (text, not color alone)"
)
# The dirty-row marker (the --brand left border on the row).
assert "settings-row.is-dirty" in css, (
"the dirty-row marker must exist (unsaved survives grayscale)"
)
dirty = re.search(r"\.settings-row\.is-dirty[^{]*\{([\s\S]*?)\n\}", css)
assert dirty and "var(--brand)" in dirty.group(1), (
"the dirty marker is the --brand left border"
)
# No new :root variable (B3, the phase-156 rule): the settings
# block must not declare a custom property — only var(--…)
# references of the pre-existing tokens (a themed page's
# #bor-theme override of the token handles the color).
settings_block = css[css.find("Settings view (phase 168)"): css.find(
"Shared page (phase 51"
)]
declarations = re.findall(r"^\s{2,}--[\w-]+\s*:", settings_block, re.M)
assert not declarations, (
f"the settings block must not declare :root variables (B3): "
f"{declarations}"
)
# The touch targets: the ≥44px rules (the house language) + the
# mobile 640px block stacks the search + action rows.
for selector in ("#settings-filter", ".settings-control"):
rule = re.search(rf"{re.escape(selector)} \{{([\s\S]*?)\n\}}", css)
assert rule, f"styles.css must style {selector}"
assert "min-height: 44px" in rule.group(1), f"{selector}: the ≥44px target"
mobile = re.search(
r"@media \(max-width: 640px\) \{([\s\S]*?)\n\}\n", css
)
assert mobile, "the 640px media query must exist"
mbody = mobile.group(1)
assert ".settings-search { flex-direction: column; align-items: stretch; }" in mbody
assert "#settings-save { width: 100%; }" in mbody, (
"the Save pill is full width on phones (the ≥44px target stands)"
)
# ---------- phase 171 (task 01): the #settings-content rhythm ----------
def test_settings_content_is_a_flex_column_with_the_shell_gap() -> None:
"""Phase 171 (task 01; TODO L4): ``#settings-content`` — the
disabled banner, the status + error lines, the search row, the
``.table-wrap`` card, and the Save / Clear-all row — is a
flex column with the shell's own 1.25rem gap (ASSUMPTION A4:
no new spacing value). ONE uniform gap kills both layout
complaints: the search box clears the table card (the
"overlap") and the action row separates from the table (the
"smushed"). The gap is the ONLY spacing source: the search
row and the action row keep NO margin, and the 640px mobile
stacking composes with it unchanged."""
css = _read(ASSETS / "styles.css")
rule = re.search(r"#settings-content \{([\s\S]*?)\n\}", css)
assert rule, (
"styles.css must style #settings-content (the phase 171 rhythm rule)"
)
body = rule.group(1)
for decl in (
"display: flex",
"flex-direction: column",
"gap: 1.25rem",
):
assert decl in body, (
f"#settings-content must declare {decl} (the shell's rhythm, A4)"
)
# The gap is the ONLY spacing source (the task's verification
# by reading, pinned): the search row and the action row got
# NO new margin — the .settings-status / .settings-error
# min-heights reserve their own lines, the gap is additive.
for selector in (".settings-search", ".settings-actions"):
srule = re.search(rf"{re.escape(selector)} \{{([\s\S]*?)\n\}}", css)
assert srule, f"styles.css must style {selector}"
assert "margin" not in srule.group(1), (
f"{selector} keeps no margin — the #settings-content gap is "
"the only spacing source (phase 171 task 01)"
)
# ---------- phase 171 (task 02): the group band is distinct ----------
def test_settings_group_band_is_branded_bold_hairlined() -> None:
"""Phase 171 (task 02; TODO L4, owner-locked A3): the
``.settings-group-row th`` band — today ``background:
var(--surface)`` (the SAME fill as the ``.table-wrap`` card,
i.e. invisible) — becomes a band that reads as a category at a
glance: the ``--brand-soft`` tint (the SOLID hex the sticky
thead rule already documents — B3: no new palette variable;
a themed page's ``#bor-theme`` override of the token handles
the color — B4), weight 700, and a ``--line`` top hairline so
a band is delimited above AND below (the ``border-bottom``
comes from the ``.settings-table th`` family) and can never
merge with the card's top edge or the band above it. The
text stays ``--ink`` on ``--brand-soft`` (14.8:1, AA). Static,
text-only, no animation — and the rule keeps its existing
0.82rem / uppercase / letter-spacing typography."""
css = _read(ASSETS / "styles.css")
rule = re.search(r"\.settings-group-row th \{([\s\S]*?)\n\}", css)
assert rule, (
"styles.css must style .settings-group-row th (the group band)"
)
body = rule.group(1)
for decl in (
"background: var(--brand-soft)",
"font-weight: 700",
"border-top: 1px solid var(--line)",
):
assert decl in body, (
f".settings-group-row th must declare {decl} (the phase 171 "
"band — A3)"
)
# The old invisible fill is gone (the band is distinct from the
# row cells' --surface by construction).
assert "background: var(--surface)" not in body, (
"the band must not sit on the card's --surface (the phase-168 "
"invisible-band defect)"
)
# The text pairing + the existing typography survive.
assert "color: var(--ink)" in body, (
"the band text stays --ink (14.8:1 on --brand-soft, AA)"
)
for decl in (
"font-size: 0.82rem",
"text-transform: uppercase",
"letter-spacing: 0.04em",
):
assert decl in body, f"the band keeps {decl} (task 02: typography unchanged)"
# The bottom delimiter is the .settings-table th family's
# border-bottom (the band is delimited above AND below) — pinned
# so the band can never merge with the row beneath it.
table_th = re.search(r"\.settings-table th, \.settings-table td \{([\s\S]*?)\n\}", css)
assert table_th, "the .settings-table th/td family rule must exist"
assert "border-bottom: 1px solid var(--line)" in table_th.group(1), (
"the .settings-table th family supplies the band's bottom border"
)
# Static, text-only, no animation (nothing for
# prefers-reduced-motion to still).
assert "transition" not in body and "animation" not in body, (
"the band is static (no animation)"
)
# ---------- phase 171 (task 03): a disabled Save is just disabled ----------
def _settings_rule_bodies(css: str) -> list[str]:
"""Every CSS rule body whose selector part is a settings-page
selector (``.settings*`` / ``#settings-*``) — the scope of the
task-03 negative pin. Comments are stripped from the selector
part before matching; rules inside the 640px media query match
on their own selector (the media preamble is a separate match
and is excluded). Non-settings controls (the chat edit form's
``.edit-save`` keeps its own ``cursor: wait`` — a different
control, out of scope) never match."""
bodies: list[str] = []
for m in re.finditer(r"([^{}]+)\{([^{}]*)\}", css):
sel = re.sub(r"/\*[\s\S]*?\*/", "", m.group(1)).strip()
if not sel:
continue
parts = [p.strip() for p in sel.split(",")]
if any(p.startswith((".settings", "#settings-")) for p in parts):
bodies.append(m.group(2))
return bodies
def test_settings_save_disabled_is_just_disabled_no_wait_cursor() -> None:
"""Phase 171 (task 03; TODO L4, owner's words): a disabled
**Save settings** is *just* disabled — its computed cursor is
``default`` (the ``.settings-reset:disabled`` idiom), NOT the
``wait`` (loading) cursor the owner saw on hover. The §7.4
in-flight state survives unchanged: the **dimmed** ``:disabled``
(``opacity: 0.6``) + ``aria-busy`` — the cursor was never part
of the contract. The ``#settings-save:hover:not(:disabled)``
rule is byte-unchanged (the §7.4 lifecycle's ``cursor: pointer``
when enabled). And ``cursor: wait`` is ABSENT from every
settings-page rule (the house negative-pin idiom, scoped to the
``.settings*`` / ``#settings-*`` selectors — the chat edit
form's ``.edit-save`` rule keeps its own ``wait`` and must not
fail this pin)."""
css = _read(ASSETS / "styles.css")
rule = re.search(r"#settings-save:disabled \{([^{}]*)\}", css)
assert rule, "styles.css must style #settings-save:disabled"
body = rule.group(1)
assert "cursor: default" in body, (
"#settings-save:disabled must be cursor: default (the "
".settings-reset:disabled idiom — just disabled, task 03)"
)
assert "opacity: 0.6" in body, (
"the §7.4 dim survives (opacity 0.6 — the in-flight state)"
)
assert "cursor: wait" not in body, (
"the wait (loading) cursor is gone from the disabled save"
)
# The hover rule is byte-unchanged (task 03: the :not(:disabled)
# guard + the brand hover pair stand).
hover = re.search(
r"#settings-save:hover:not\(:disabled\) \{([^{}]*)\}", css
)
assert hover, "the #settings-save:hover:not(:disabled) rule must exist"
hbody = hover.group(1)
assert "background: var(--brand-hover)" in hbody
assert "color: var(--bg)" in hbody
assert "cursor: wait" not in hbody
# The idiom it mirrors stands (the .settings-reset:disabled
# cursor: default the comment references is live).
reset = re.search(r"\.settings-reset:disabled \{([^{}]*)\}", css)
assert reset and "cursor: default" in reset.group(1), (
"the .settings-reset:disabled idiom (cursor: default) must stand"
)
# The negative pin: no settings-page rule (a .settings* /
# #settings-* selector) may paint cursor: wait.
for b in _settings_rule_bodies(css):
assert "cursor: wait" not in b, (
"no settings-page rule may paint cursor: wait (phase 171 "
f"task 03): {b.strip()}"
)
# ---------- the four Theme-bearing pages carry the Settings link ----------
def test_settings_link_ships_on_every_theme_bearing_page() -> None:
"""The phase-34 one-bar contract — the SAME nav ships on every
page (test_nav_consistency pins the header inventory PARITY
across the shell pages, the document viewer, and the login
page), so #nav-settings (ship-hidden, admin-only) must be in
the #app-nav of EVERY header-bearing page: the shell +
document.html + login.html + shared.html. The doc-edit flow
page ships the reduced header (no admin links at all) and is
out of the contract."""
for page in (
FRONTEND / "index.html",
FRONTEND / "document.html",
FRONTEND / "login.html",
FRONTEND / "shared.html",
):
text = _read(page)
# The page must carry BOTH links — the Theme link (the
# phase-91 precedent) and the Settings link (this task).
theme = re.search(r'<a[^>]*id="nav-theme"[^>]*>', text)
assert theme, f"{page.name} must carry the #nav-theme nav link"
match = re.search(r'<a[^>]*id="nav-settings"[^>]*>', text)
assert match, (
f"{page.name} must carry the #nav-settings nav link (one-bar)"
)
link = match.group(0)
assert 'href="/settings.html"' in link, (
f"{page.name}: the Settings link's href"
)
assert "hidden" in link, (
f"{page.name}: #nav-settings ships hidden (admin-only)"
)
assert "is-active" not in link, (
f"{page.name}: no static active stamp on the Settings link"
)
# The Settings link sits AFTER the Theme link (the order
# test_nav_consistency's inventory compares).
assert text.find('id="nav-theme"') < text.find('id="nav-settings"'), (
f"{page.name}: the Settings link follows the Theme link"
)