phase: 171_admin_settings_layout
Build and Push Containers / build-and-push-app (push) Successful in 1m34s
Build and Push Containers / build-and-push-db (push) Successful in 14s

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.
This commit is contained in:
2026-10-10 04:45:34 -04:00
parent c4f098d0c0
commit e9ae6e2ba3
28 changed files with 1520 additions and 4 deletions
+200
View File
@@ -41,6 +41,25 @@ Pinned (task 05 spec):
``.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).
@@ -564,6 +583,187 @@ def test_styles_css_settings_block_uses_existing_tokens_only() -> None:
)
# ---------- 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 ----------