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