Skip to content

fix(ui): let a classed label own its box instead of the generic field-label rule - #329

Merged
argszero merged 1 commit into
mainfrom
fix/form-field-label-scope
Sep 30, 2026
Merged

argszero merged 1 commit into
mainfrom
fix/form-field-label-scope

Conversation

@argszero

Copy link
Copy Markdown
Owner

Summary

.form label { display: flex; flex-direction: column; … } was unconditional. At specificity (0,1,1) it outranked the component layer (0,1,0) for every <label> carrying a component class, so the box the class asked for never applied — lower specificity loses regardless of source order.

Two shapes are hit, from two different origins but one root cause:

Victim Symptom Origin
.check-line The Settings → Notifications checkboxes and the Preferences → table-density radios render stacked: the control sits above its caption, centred. #157 (09e4121) renamed the markup from class="check" to the prototype-canonical .check-line that #154 (75dcb2c) introduced — but left the stylesheet's .form label.check (0,2,1) behind. That rule was the only thing turning a checkbox row back to a row, and it stopped matching anything.
.chip The share-form weekday chips render as circles instead of pills. #32 (d31641d) turned the chips from <button> into <label>; the display:inline-flex #154 gave them was overridden by the generic rule.

.checkbox-line (login "remember me") only survived because #61 added flex-direction: row !important; ironically the prototype-canonical .check-line has no such protection.

Related Issue

Changes

  • ui/css/style.css: guard the two generic field-label rules with :not([class]) — a classed label takes its box from its own class, and the generic rule now serves only unclassed field labels.
  • ui/css/style.css: drop the now-dead .density-options .check rule (feat(ui): redesign settings/admin/ops views on the landed component layer #157 renamed that markup too) and give .checkbox, .checkbox-line back their own display: flex, which they used to borrow from the generic rule.
  • src/layout_gate.rs (new, #[cfg(test)], zero new dependencies): two derived rules so this cannot silently recur.
  • src/main.rs: register the gate (#[cfg(test)] mod layout_gate;).
  • 涉及配置/数据结构的改动已同步示例文件 — n/a

The gate

Not the two classes, but the shape they share:

  • R1 (roster, derived from ui/index.html) — every class used on a <label> must have a stylesheet rule that targets it and declares display. A label's box must come from its class, never borrowed from a generic rule.
  • R2 (mechanism) — any rule whose rightmost compound selector is a bare label and that declares display / flex-direction must carry a guard excluding classed elements (:not([class])).

Together they are the invariant: a generic field-label rule serves only labels without a class.

Expected values are entirely derived (roster from ui/index.html, rules from ui/css/style.css), so no snapshot is written and the next component class is covered on arrival.

Rejected alternatives (each only plugs today's two holes): add !important to .check-line; add a .form label.X row override per victim; only delete the dead .form label.check.

Tests

  • cargo test — 435 passed / 0 failed (baseline 429; +6 from the new gate)
  • cargo fmt --check — clean
  • cargo clippy --all-targets -- -D warnings — clean
  • New unit tests added

A/B (same tree, only ui/css/style.css swapped for upstream/main's):

pre-fix : R1 violations = ["checkbox"]
          R2 violations = [".form label { … }", ".login-form label { … }"]
          → axis test FAILED, positive control FAILED (guarded-rule set empty)
fixed   : R1 violations = []   R2 violations = []   → 6/6 ok

Second instrument — jsdom, computed style per shape (whole document, both trees):

shape n before after
.form label.check-line 5 flex / column flex / row
label.chip 8 flex / column inline-flex / row
label.checkbox 1 flex / row flex / row
label.checkbox-line 1 flex / row flex / row
.form label:not([class]) 23 flex / column flex / column
.login-form label:not([class]) 12 flex / column flex / column

The fix changes exactly the classed labels; the unclassed field labels are untouched in both trees.

Scope (stated honestly): the gate is lexical — it proves who is allowed to give a label its box, not the painted layout. That half is covered by the jsdom probe above and by headless-Chrome before/after screenshots.

Checklist

  • 分支命名符合约定 (fix/…)
  • Commit message 使用 Conventional Commits 格式
  • 单一职责,改动最小化

…-label rule

The generic field-label rule `.form label { display:flex; flex-direction:column }`
(specificity 0,1,1) outranked the component layer (0,1,0) for every label that
carries a component class, so the layout the class asked for never applied:

- `.check-line` — the settings notification checkboxes and the table-density
  radios rendered stacked (control above its caption, centred). #157 renamed the
  markup from `class="check"` to the prototype-canonical `.check-line` that #154
  introduced, but left the stylesheet's `.form label.check` rule behind; that
  rule was the only thing turning a checkbox label back into a row, and it
  stopped matching anything.
- `.chip` — the share-form weekday chips were pills; #32 turned them into
  `<label>`s, and the `display:inline-flex` #154 gave them was overridden.

Fix: guard the generic field-label rules with `:not([class])`, so a classed label
takes its box from its own class — the component layer wins as intended. Also
drop the now-dead `.density-options .check` rule (#157 renamed that markup too)
and give `.checkbox, .checkbox-line` back their own `display:flex`, which they
used to borrow from the generic rule.

Adds `src/layout_gate.rs` (test-only, compiled out of release builds, zero new
dependencies) with two derived rules: every class used on a `<label>` in
ui/index.html must declare its own `display`, and any rule that targets a bare
`label` and imposes a layout must carry a guard excluding classed elements.

Measured with jsdom over the whole document on both trees: check-line and chip
go from column to row, while the unclassed field labels stay column in both.
@argszero

Copy link
Copy Markdown
Owner Author

✅ Self-review (committer, allow_self_merge). CI: msrv pass, test / fmt / clippy pass on 36654784704; mergeStateStatus: CLEAN.

Checked:

  1. The rule change is the minimal shape of the fix. :not([class]) on the two generic field-label rules is what lets the component layer win; the alternative (!important per victim, or a .form label.X override per victim) only plugs the two holes that exist today. R1 of the new gate covers the next component class on arrival.
  2. Nothing else regressed. The jsdom sweep shows the unclassed field labels (.form ×23, .login-form ×12) are column in both trees, and label.checkbox / label.checkbox-line stay row — the latter two used to borrow display:flex from the generic rule, which the patch now grants them explicitly, so no consumer lost a box.
  3. The dead rule really is dead. .density-options .check had zero matching elements after feat(ui): redesign settings/admin/ops views on the landed component layer #157 renamed the markup; git grep finds no class="check" anywhere in ui/.
  4. The gate is derived, not a snapshot. Roster comes from ui/index.html, rules from ui/css/style.css; the positive control asserts both scans see something (≥4 classes, ≥2 guarded rules), so neither rule can pass vacuously on an empty set.
  5. Gate teeth, measured. Swapping only ui/css/style.css for upstream/main's reproduces R1 = ["checkbox"], R2 = [".form label …", ".login-form label …"]; restoring it gives 6/6.
  6. Comment-safe scanners. The stylesheet contains explanatory comments that quote selectors verbatim, so the gate masks comments and string literals with an equal-length mask before parsing — and a test asserts the masked corpus yields exactly the two real rules.

Scope, stated honestly: the gate is lexical. It proves who may give a label its box, not what the browser paints. That half is the jsdom table in the description plus the headless-Chrome screenshots taken during recon.

@argszero
argszero merged commit 1392d1a into main Sep 30, 2026
2 checks passed
@argszero
argszero deleted the fix/form-field-label-scope branch September 30, 2026 01:25
argszero added a commit that referenced this pull request Sep 30, 2026
…ing rule

The card-heading rule `.card h3 { font-size: 15px; margin-bottom: 14px;
color: var(--text) }` (specificity 0,1,1) outranked the component class
`.wallet-hero-label` (0,1,0), so all three of that class's declarations
lost.  The wallet page's balance caption has therefore always rendered as
an ordinary card heading — computed style before the fix: `15px /
var(--text) / 14px`, while the class asks for `12px / var(--text-mute) /
0` (the prototype writes `<h3 class="muted" style="font-size:12px">` at
line 588).  The class's `margin-bottom: 0` — the thing that pulls the big
number up against its caption — never applied either.  A whole class
whose every declaration loses is a `class=` that was never honoured.

Fix: guard the generic card-heading rule with `:not([class])`, so a h3
that carries a class keeps its own look and the generic rule serves only
unclassed headings — the same invariant #329 applied to the field-label
rules.

The gate in src/layout_gate.rs grows a third rule (R3) that makes this
shape derivable rather than a one-off: for every `<element class=...>`
site in ui/index.html, the class must not have *every* declaration it
makes be beaten by a rule that names the element type without using the
element's own classes.  R3 compares specificity and source order, counts
only rules that really match (markup nesting is tracked — without it a
single `.page-head p` produced 7 false dead classes), and skips `@media`
rules and state rules such as `[hidden]`.

Measured with jsdom over the whole document: the pre-fix tree has exactly
one dead class site in the entire document (`.wallet-hero-label` on
`<h3>`), the fixed tree has zero, and the other 27 `.card h3` headings are
unchanged at `15px / var(--text) / 14px`.
argszero added a commit that referenced this pull request Sep 30, 2026
…ing rule (#330)

The card-heading rule `.card h3 { font-size: 15px; margin-bottom: 14px;
color: var(--text) }` (specificity 0,1,1) outranked the component class
`.wallet-hero-label` (0,1,0), so all three of that class's declarations
lost.  The wallet page's balance caption has therefore always rendered as
an ordinary card heading — computed style before the fix: `15px /
var(--text) / 14px`, while the class asks for `12px / var(--text-mute) /
0` (the prototype writes `<h3 class="muted" style="font-size:12px">` at
line 588).  The class's `margin-bottom: 0` — the thing that pulls the big
number up against its caption — never applied either.  A whole class
whose every declaration loses is a `class=` that was never honoured.

Fix: guard the generic card-heading rule with `:not([class])`, so a h3
that carries a class keeps its own look and the generic rule serves only
unclassed headings — the same invariant #329 applied to the field-label
rules.

The gate in src/layout_gate.rs grows a third rule (R3) that makes this
shape derivable rather than a one-off: for every `<element class=...>`
site in ui/index.html, the class must not have *every* declaration it
makes be beaten by a rule that names the element type without using the
element's own classes.  R3 compares specificity and source order, counts
only rules that really match (markup nesting is tracked — without it a
single `.page-head p` produced 7 false dead classes), and skips `@media`
rules and state rules such as `[hidden]`.

Measured with jsdom over the whole document: the pre-fix tree has exactly
one dead class site in the entire document (`.wallet-hero-label` on
`<h3>`), the fixed tree has zero, and the other 27 `.card h3` headings are
unchanged at `15px / var(--text) / 14px`.
@argszero argszero mentioned this pull request Sep 30, 2026
12 tasks done
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant