"""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('
", 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("", 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:

Settings

+ 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('
", view) body = html[view:main_end] assert "

Settings

" in body sub = re.search(r'

([\s\S]*?)

', 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('
", view) body = html[view:main_end] gate = re.search(r']*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 "

Sign in to change settings

" 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('
", view) body = html[view:main_end] content = re.search(r']*id="settings-content"[^>]*>', body) assert content and "hidden" in content.group(0), ( "#settings-content must ship hidden (anonymous-safe)" ) banner = re.search( r']*class="kb-banner is-error"[^>]*id="settings-disabled"[^>]*>' r"|]*id=\"settings-disabled\"[^>]*>", body, ) assert banner, "the #settings-disabled banner must exist" btag_match = re.search(r']*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']*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']*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 '' in body filter_input = re.search( r']*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']*class="settings-table"[^>]*>', body) assert table, "the .settings-table must exist" thead = body[body.find('class="settings-table"') : body.find("")] assert '' in thead, ( "the caption is visually-hidden (the phase-86 table pattern)" ) assert thead.count('Setting' in thead assert 'Value' 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 'Source' in thead assert ( 'Actions' 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']*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']*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']*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']*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 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("", table_start)] assert 'Value' 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']*id="nav-theme"[^>]*>', text) assert theme, f"{page.name} must carry the #nav-theme nav link" match = re.search(r']*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" )