Files
brain-of-reese/tests/unit/test_settings_frontend_view.py
ducoterra dd119d534a
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: 176_mobile_settings_value_width
All documentation claims verify against the code. Everything is green — final verification complete.

## Phase 176 — Final Verification Report

- **Verified (all 3 tasks in `complete/`):** the mobile Value-column fix is in the working tree — `frontend/index.html` L1602 carries `class="settings-value-th"`; `frontend/assets/styles.css` has exactly two new rules inside the `@media (max-width: 640px)` block: `.settings-table { min-width: 720px }` and `.settings-value-cell, .settings-value-th { min-width: calc(13rem + 2rem) }`; `settings.js` + `app/` byte-unchanged (diff = index.html + styles.css + unit test only).
- **Documented deviation (flagged in task-03 report, not silent):** spec's `min-width: 13rem` on the border-box cell left the control at 176px (< the 192px A3 pin); executor corrected the cell to `calc(13rem + 2rem)` so the control (100% of content) lands at exactly 13rem ≈ 208px — the A2 lock intent preserved, unit pin + CSS comment updated to match.
- **Tests/lint/coverage (exact commands, all green):** `uv run pytest --cov=app` → **4138 passed**, `app/` coverage **99%** (>90% ✓); `uv run ruff check .` → clean; `uv run pyright` → 0 errors, 0 warnings.
- **Completion criteria:** 375px controls ≥ 192px — ✅ (E2E `test_mobile_value_inputs_are_usable_width`); card scrolls / page doesn't overflow at 375px — ✅ (`test_mobile_table_still_scrolls_and_page_does_not_overflow`); 1440px media query inert — ✅ (`test_desktop_value_width_is_unchanged`); tab order + zero external requests — ✅; phase E2E isolated — ✅ 4/4; phase 171 isolated — ✅ 5/5; phase 168 isolated — ✅ 12/12; unit pins — ✅ 17/17; commit/move — the harness commits; phase dir already in `complete/`.
- **Documentation:** README.md and AGENTS.md reviewed in full — every claim spot-checked against code (E2E ports 8123/8901, defaults `BOR_COMPRESS_MIN_BYTES=860` / `BOR_LOG_LARGE_RESPONSE_BYTES=65536`, audit budget 262144, `.cell-clip`, `.bubble:empty`, `min(70vh, 64rem)`, aria-pressed toggle, CDN integration test, `.gitignore` entries, all referenced scripts/routes) — verified consistent, no changes needed.
- **Notable:** `image-1.png` (owner's TODO screenshot, referenced by the phase overview) sits untracked in the tree — left alone; not part of the phase's commit scope.
- **Next pending phase:** `177_themed_favicon`.
2026-10-11 13:23:04 -04:00

928 lines
44 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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" class="settings-value-th">Value</th>' in thead, (
"phase 176: the Value th carries the .settings-value-th class "
"(the mobile 13rem column floor constrains the header cell "
"under auto table layout, not just the td's)"
)
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()}"
)
# ---------- phase 176 (task 01): the mobile Value-column floor ----------
def test_settings_mobile_value_column_floor() -> None:
"""Phase 176 (task 01; TODO.md L3, owner-locked A1/A2): on a
phone (≤640px) the Settings table's Value inputs rendered 2–3
characters wide — the Setting cells' nowrap labels (the
``.settings-table td`` rule) exhausted the table's 560px floor
and collapsed the ``min-width: 0`` Value cells. The owner's
direction: "the table is horizontally scrollable, so we can have
slightly wider input fields." Pinned here (static, no browser):
* the Value column — ``.settings-value-cell`` (the td) and
``.settings-value-th`` (the th; task 01 added the class so the
column floor constrains the header cell too, under auto table
layout) — gets a ``min-width: calc(13rem + 2rem)`` floor: the
global border-box model (``* { box-sizing: border-box }``)
makes a cell's min-width a BORDER-box, so the cell is floored
at the locked 13rem of CONTENT (≈208px ≈ 14 mono chars at the
controls' 0.85rem) plus the td's own 1rem × 2 padding — the
``.settings-control`` (``width: 100%`` of the cell's content)
thus lands at the locked 13rem (the A2 intent, "slightly
wider input fields", and the A3 pin's 12rem / 192px threshold
with a 16px margin); a bare ``13rem`` border-box would only
leave 176px of content (the control), under the A3 pin, and
* the table's mobile scroll floor is raised to ``720px`` while
the base rule keeps ``560px`` (≥641px — the desktop layout
stays byte-identical, A1/A2).
Both rules live ONLY inside the existing ``@media (max-width:
640px)`` block (A1 — mobile-only scope): the selectors occur
exactly once in the whole file, and that one occurrence sits
inside the media block (the slice is by open/close markers —
the inner rules are 2-space-indented, so the first column-zero
``}`` after the open is the block's close; no hardcoded line
range, so the pin survives an insertion above it). The 720px
floor + the ``.table-wrap`` card's ``overflow: auto`` mean the
phone scrolls the CARD, never the page (the phase-171 "no
horizontal page overflow" E2E pin holds). 720px is a bare
length literal and the cell floor is the locked 13rem of content
+ the cell's 2rem of padding — NO new CSS custom property (B3
binds palette variables; a length in the mobile block is the
idiom the neighbouring ``width: 100%`` rules already use), so
``test_styles_css_settings_block_uses_existing_tokens_only``
stays green unchanged."""
css = _read(ASSETS / "styles.css")
# --- the ≤640px block, sliced by its open/close markers.
open_marker = "@media (max-width: 640px) {"
mobile_open = css.index(open_marker)
mobile_close = css.index("\n}", mobile_open + len(open_marker))
mobile = css[mobile_open:mobile_close + 2]
# --- the Value rule: exactly ONCE in the whole file, and that
# one occurrence sits INSIDE the ≤640px block (A1: no desktop
# copy, no stray rule elsewhere).
value_selector = ".settings-value-cell, .settings-value-th"
assert css.count(value_selector) == 1, (
"the Value rule must occur exactly once in styles.css"
)
assert mobile.count(value_selector) == 1, (
"the Value rule must sit inside the ≤640px block (A1: "
"mobile-only — at ≥641px the rule is inert)"
)
value_rule = re.search(
r"\.settings-value-cell, \.settings-value-th \{([^{}]*)\}", mobile
)
assert value_rule, "the Value rule must declare the column floor"
assert "min-width: calc(13rem + 2rem)" in value_rule.group(1), (
"the Value column (th AND td) is floored at 13rem of content "
"(≈208px ≈ 14 mono chars — A2: 'slightly wider input fields') "
"+ the cell's 2rem of padding (the global border-box model — "
"a bare 13rem border-box would leave the control at 176px, "
"under the A3 192px pin)"
)
# --- the table floor: BOTH rules pinned — the base keeps 560px
# for ≥641px (and must not know 720px) while the mobile block
# raises it to 720px (the phone scrolls the card — the
# .table-wrap overflow: auto contract, never the page).
base = re.search(r"\.settings-table \{([^{}]*)\}", css)
assert base, "styles.css must style .settings-table (the base rule)"
assert "min-width: 560px" in base.group(1), (
"the base .settings-table floor stays 560px (≥641px — "
"unchanged, A1)"
)
assert "720px" not in base.group(1), (
"the base .settings-table rule must not know 720px (the "
"720px floor belongs to the mobile block only, A1)"
)
mtable = re.search(r"\.settings-table \{([^{}]*)\}", mobile)
assert mtable, (
"the ≤640px block must carry the .settings-table floor rule"
)
assert "min-width: 720px" in mtable.group(1), (
"the mobile table floor is 720px (A2: the phone scrolls "
"the card horizontally)"
)
# The 720px declaration exists exactly once in the whole file —
# and it is the mobile block's (no ≥641px copy anywhere).
assert css.count("min-width: 720px") == 1 and (
"min-width: 720px" in mobile
), "the 720px floor must exist exactly once, in the ≤640px block"
# --- the index.html static thead: the Value th carries the
# .settings-value-th class so the column floor constrains the
# header cell too (deterministic under auto table layout —
# a plain <th> would otherwise stay unconstrained).
html = _html()
table_start = html.find('class="settings-table"')
assert table_start != -1, "the settings table must exist"
thead = html[table_start: html.find("</thead>", table_start)]
assert '<th scope="col" class="settings-value-th">Value</th>' in thead, (
"the settings thead's Value th carries the settings-value-th "
"class (task 01: the column floor is deterministic under "
"auto table layout)"
)
# ---------- 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"
)