Skip to content

fix(ui): make the settings controls either wired or explicitly inert - #263

Merged
argszero merged 1 commit into
mainfrom
fix/settings-inert-controls
Sep 15, 2026
Merged

argszero merged 1 commit into
mainfrom
fix/settings-inert-controls

Conversation

@argszero

Copy link
Copy Markdown
Owner

Summary

The three configuration cards in Settings (account / notifications / preferences)
each render a control that looks operable but has no consumer anywhere:

Control The half it is missing
#settings-nickname Editable, filled from the real /api/me, but there is no write path for a nicknameUPDATE users only touches dept_id / verified / password_hash — and no listener, so an edit is silently discarded on the next renderSettings.
#prefs-model Filled from the real /api/models and it looks selectable, but there is no listener, no storage key and no reader (openChat() is only ever called with an explicit model).
the three notification toggles Rendered checked, with no id, no name and no reader — the repository has no notification subsystem at all.

None of them is a missing feature in disguise: each family can point at the half it
lacks in its own card or elsewhere in the repo, which is what makes this drift
rather than a choice.

  • The sibling #settings-email is already readonly with a hint — commit 09e4121
    (feat(ui): redesign settings/admin/ops views on the landed component layer #157, the same redesign) deleted the dead "save" button next to it, marked the
    email inert, and missed the nickname.
  • The three preference controls in the same card (#prefs-langatp_lang,
    #prefs-themeatp-theme, density radios → atp-density) all persist and
    take effect.
  • The repository's own idiom for "capability not open" is #withdraw-btn:
    disabled plus a short note.

The fix is therefore not "make them work" — that would need a backend route and a
consumer that do not exist — it is: declare the control inert and say why, in both
language packs.

Changes

  • #settings-nicknamereadonly + a hint; #prefs-modeldisabled + a hint;
    the three notification toggles → disabled + a card-level hint (they also lose the
    misleading checked). Their fill sites are untouched: the nickname still shows the
    real /api/me value and the dropdown is still sourced from the real catalogue.
  • Three new i18n keys, added to both packs.
  • New static gate
    state_gate::settings_controls_are_either_live_or_marked_inert, with the scope
    explicitly registered as those three cards (the API-key card is out of scope —
    its wiring already has its own gates — so the gate needs no exemption list):
    1. inert ⟺ ¬consumed, both directions — an unwired control must be marked
      inert (this catches all three faces), and a wired control must not be
      (this catches the "just disable everything" over-correction, which would kill
      lang / theme / density).
    2. A card holding an inert control must carry a hint, and that key must exist in
      both packs (a missing pack shows the raw key name — worse than no hint).
    3. Empty-set guards on both the inert and the wired collection.
  • Companion extractor self-test
    the_settings_control_extractors_have_teeth: "filled" is not "consumed" (ref
    #338), and neither is "persisted" — a listener that only writes the value into
    localStorage has no reader at all, so it is exactly the half-fix this axis must
    reject. It also pins the identifier-token boundary (themeSel.value is not
    sel.value, ref #333), the radio-group name handle path, the button case, and
    "a control with no id / name can never be recognised, so it is never wired" —
    that last rule is what holds the three toggles.
  • ui/README.md convention section; ui/index.html cache-bust i18n.js?v=…-4;
    src/i18n_pack.rs positive-control constants recalibrated to the values the gates
    reported (not hand-computed).

Related Issue

None.

Tests

  • cargo test — 290 → 292 passed, 0 failed
  • cargo fmt --check clean
  • cargo clippy --all-targets — only the pre-existing protocol.rs:662 warning
  • New unit tests added (src/state_gate.rs), with a self-verifying extractor

A/B, gate (in-place mutation, restored byte-exactly, md5 verified each leg)

tmp/c2148_gate_ab.py — 6/6 legs as declared:

Leg Result Which rule spoke
V0 unfixed tree RED rule 1 — settings-nickname is unmarked and unwired
M1 over-correction (#prefs-lang also disabled) RED rule 1, reverse direction
M2 half-fix (#prefs-model re-enabled + listener that only writes localStorage) RED rule 1 — persistence is not consumption
M3 mark without explaining (notify hint deleted) RED rule 2
M4 hint key in only one pack RED rule 2
FIX current tree GREEN

The unfixed tree also goes red on the axis rule only (the full state_gate:: run
reports exactly one failing test), so the gate is not carried by an unrelated rule.

A/B, probe (tmp/c2148_probe.js, jsdom booting the real index.html + the four real scripts, stubbing and logging fetch, driving the real controls)

4/4 legs as declared:

  • BASE (unfixed index.html) — exactly the 7 axis legs red, all 7 precondition /
    control legs green.
  • ALLOFF competitor ① (the real preference controls also disabled) — rejected by
    control leg D1.
  • PERSIST competitor ② (re-enable + write localStorage only) — rejected by axis
    leg A2.
  • FIX current tree — 14/14.

The probe pins the screen-side facts (the fields still carry the real values, the
dropdown is still sourced from the real catalogue, the hints are localized — switched
via the real language control, no CJK left in the en pack). CI has no JS runner, so the
gate above is what keeps the convention.

The three configuration cards in Settings (account / notifications /
preferences) each render a control that looks operable but has no consumer
anywhere:

- `#settings-nickname`: editable, filled from the real `/api/me`, but there is
  no write path for a nickname at all -- `UPDATE users` only touches
  `dept_id` / `verified` / `password_hash` -- and no listener, so an edit is
  silently discarded on the next `renderSettings`. Its own card sibling
  `#settings-email` is already `readonly` with a hint: commit 09e4121 (#157)
  did both for the email and missed the nickname.
- `#prefs-model`: filled from the real `/api/models`, but no listener, no
  storage key and no reader (`openChat` is only ever called with an explicit
  model). Its three card siblings (lang / theme / density) all persist and
  take effect.
- the three notification toggles: rendered *checked*, with no id, no name and
  no reader -- the repository has no notification subsystem at all.

The fix is not "make them work" (that needs a backend route and a consumer that
do not exist); it is the repository's own idiom for "capability not open"
(`#withdraw-btn`: `disabled` plus a short note): declare the control inert and
say why, in both language packs. Wired siblings are left untouched.

- `#settings-nickname` and `#prefs-model` get `readonly` / `disabled` plus a
  hint; the three notification toggles get `disabled` plus a card-level hint
  (they also lose the misleading `checked`).
- Three new i18n keys, both packs.
- New static gate
  `state_gate::settings_controls_are_either_live_or_marked_inert`, scoped
  explicitly to those three cards: a control that is not consumed must be
  marked inert, a control that is consumed must not be, and a card holding an
  inert control must carry a hint whose key exists in both packs.
- Companion extractor self-test: "filled" is not "consumed", and neither is
  "persisted" -- writing the value to `localStorage` with no reader is the
  half-fix this gate rejects.
- `ui/README.md` convention section; `ui/index.html` cache-bust
  `i18n.js?v=...-4`; `src/i18n_pack.rs` positive-control constants recalibrated
  to the values the gates reported.
@argszero

Copy link
Copy Markdown
Owner Author

Self-review

What this change is: it removes a promise the UI was making and could not keep. All
three families are marked inert in the repository's own existing idiom, so no new
product behaviour is invented; the fill sites are deliberately untouched.

Evidence the direction is right, not merely the diff consistent

  1. The gate goes red on the unfixed tree on the axis rule only — the full
    state_gate:: run reports exactly one failing test, so the gate is not riding on an
    unrelated rule.
  2. Both competitors are rejected by independent legs: the "disable everything"
    over-correction by control leg D1 in the probe, and the attractively-small
    "re-enable and persist to localStorage" half-fix by axis leg A2. That second one
    is the reason the gate draws the line at the value reaching product code rather
    than at an event listener existing.
  3. The nickname's own card supplies the counter-example in the same diff: the email is
    readonly with a hint since 09e4121 (feat(ui): redesign settings/admin/ops views on the landed component layer #157), and that commit also removed the dead
    save button — it just missed the nickname. Drift, not a choice.

Honest limits of the gate

  • The gate is lexical. It proves that a consumed control's value is read and passed
    into some non-storage call; it does not prove that call does anything. A listener
    calling a no-op function would pass. Runtime behaviour is covered by the probe on the
    tree the probe runs against, and CI has no JS runner — the same trade-off already
    recorded for the other gates in state_gate.rs.
  • STORAGE_METHODS is a four-name list (setItem / getItem / removeItem /
    clear). A consumer routed through a wrapper (e.g. savePref(sel.value) where
    savePref itself calls localStorage.setItem) would be judged consumed and would
    then need its own inert marking. That is the conservative direction (it would demand a
    fix, not wave one through), and no such shape exists in the tree today.
  • Scope is registered, not derived. SETTINGS_CARDS names the three cards
    explicitly; the API-key card is out of scope because its wiring already has gates. This
    is stated in the constant and in the README, so a future reader sees the scope boundary
    rather than inferring it from what happens to be scanned.
  • The three notification toggle labels are still translated and still describe features
    that do not exist
    ("Remind me when points balance is below threshold"). That is the
    same shape as #withdraw-btn, which keeps its label and adds a note — so I matched the
    house idiom rather than rewriting copy. If a reviewer prefers the toggles removed
    instead of disabled, that is a product call, not a defect fix, and I would rather take
    that instruction than guess.

Not touched, deliberately

  • Making the nickname truly editable needs a new backend write route, and consuming the
    default model needs a consumer that does not exist — both are features.
  • The data.js > MARKET[].id third identity and the transactions column-sort behaviour
    are other axes and stay out of this PR.

@argszero
argszero merged commit ad72020 into main Sep 15, 2026
1 check passed
@argszero
argszero deleted the fix/settings-inert-controls branch September 15, 2026 05:55
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