Skip to content

fix(ui): report the reset success with a toast level the sheet declares - #290

Merged
argszero merged 1 commit into
mainfrom
fix/toast-level-vocabulary
Sep 22, 2026
Merged

argszero merged 1 commit into
mainfrom
fix/toast-level-vocabulary

Conversation

@argszero

Copy link
Copy Markdown
Owner

Summary

The forgot-password success branch reported its message with a toast level that no stylesheet declares:

if (r && r.status === "ok") {
  toast(T("forgot.done"), "ok");       // ui/js/app.js:3748

"ok" is the criterion literal of the very same if (the API response status), reused one line later as a level. toast() concatenates it into the element's class with no validation:

el.className = "toast" + (type ? " " + type : "");      // ui/js/app.js:38

and ui/css/style.css declares .toast.success / .toast.error / .toast.info only — plus the state class .toast.out, which the function body applies itself via classList.add("out"). .toast.ok exists only in the design prototype (docs/prototype/aitokenpool-console.html:321, whose own calls use ok/warn); the product never had that rule. So the app's only password-reset success message silently fell back to the bare .toast style while every other graded toast on the same card carried its real level.

Provenance is drift, not a trade-off: #40 (v1.16, "graded toasts") introduced the product vocabulary and migrated the existing call sites; 8ac018b (feat(auth): forgot/reset password, three days later) wrote this one new call site back in the prototype's words. The contract in ui/README.md has said toast(msg, "success" | "error" | "info") all along.

Related Issue

None — this came out of an internal audit of the toast vocabulary, not from an open issue. The linked-issue field is expected to stay empty by design; the PR is based on the default branch (main).

Changes

  • ui/js/app.js: "ok" → "success" (the word the running app already uses for a genuine success on the same auth card), with a comment saying why the second argument is a level.
  • ui/index.html: cache-bust app.js?v=20260922-12 → -13.
  • ui/README.md: extend the toast contract bullet — call sites must take their level from that one vocabulary — and record the new gate's scope.
  • src/state_gate.rs: new static gate every_toast_level_is_a_level_the_sheet_declares with three derived rules (no hand-written rosters):
    1. every literal level at a toast(...) call site is a literal and lies in the sheet's .toast.<x> rule set minus the state classes toast()'s own body applies via classList.add;
    2. that vocabulary equals, both ways, the contract line parsed out of ui/README.md;
    3. conversely, every graded rule has a call site (no dead level).
      Plus the_r92_rules_have_teeth and the_r92_rules_separate_the_variants, which show each rule has its own tooth and declare and print each leg's verdict.
  • - [x] No config/data-structure change, so no example file needed.

Tests

  • cargo test — 369 passed / 0 failed (baseline on main: 365; +4 = the three R92 tests above).
  • cargo fmt --check — rc 0.
  • cargo clippy --all-targets -- -D warnings — rc 0 (the mutant table needed a type alias for clippy::type_complexity).
  • New unit tests added (4).

Instruments (two, each seeing what the other cannot)

Compiled gate, A/B. The same new src/state_gate.rs compiled against two app trees materialized from git archive HEAD:

leg ui/js/app.js md5 every_toast_level_is_a_level_the_sheet_declares the other three R92 tests
base (unfixed, "ok") c6883a9545988102823c4b068b60d7e6 FAILED pass
fix (this PR) 908aeb05dbc0db9852ad40274e87b326 pass pass

jsdom probe (r92_probe.js, reused verbatim, md5 7eeb1b99c92bb566c40c751cf97e9274): real boot of ui/index.html + the four real scripts in jsdom, real #forgot-link → real fields → real submit, reading the rendered #toast-wrap children's className. Four variants × 13 checks, all as declared:

variant red legs (declared and observed)
base (unfixed) B1, B3, C3
fix (this PR) none
m_css (competitive fix: leave the call site, add a .toast.ok rule) B2, C3
m_css2 (same, body reordered so the duplicate-body detector goes quiet) C3

Note the deliberate asymmetry: the competitive fix makes the screen green (B1 passes — the toast does get .toast.ok), and the gate rejects it anyway, because the stylesheet then declares a level the documented contract does not. The gate is stricter than the probe on that one point; the probe is stricter on the value (C3 compares against the level the running app itself uses for a success, never a hard-coded spelling). Both are recorded, neither pretends the other is redundant.

Checklist

  • Branch name follows the convention (fix/…).
  • Commit message uses Conventional Commits (fix(ui): …).
  • Single responsibility, minimal change (one level word + one gate + its docs).

The forgot-password success branch called

    toast(T("forgot.done"), "ok");

reusing the API response's own status literal (`if (r.status === "ok")`, same
branch) as a toast *level*. `toast()` concatenates `type` into the element's
class with no validation:

    el.className = "toast" + (type ? " " + type : "");      // ui/js/app.js:38

and `ui/css/style.css` declares `.toast.success` / `.toast.error` /
`.toast.info` only — `.toast.ok` exists solely in the design prototype
(`docs/prototype/aitokenpool-console.html`, whose own calls use `ok`/`warn`).
So the app's only password-reset success message silently fell back to the
base `.toast` style. `#40` introduced the product vocabulary and migrated the
call sites; `8ac018b` (forgot/reset password, three days later) wrote one new
call site back in the prototype's words.

Fix: use `"success"`, the word the running app already uses for a genuine
success on the same auth card, and say why the argument is a level.

The new static gate `state_gate::every_toast_level_is_a_level_the_sheet_declares`
pins the shape with three derived rules (no hand-written rosters):
  1. every literal level at a `toast(...)` call site is in the sheet's
     `.toast.<x>` rule set minus the state classes the function body applies
     itself via `classList.add`;
  2. that vocabulary equals, both ways, the contract line in `ui/README.md`;
  3. conversely, every graded rule has a call site (no dead level).

`the_r92_rules_have_teeth` and `the_r92_rules_separate_the_variants` prove each
rule has its own tooth and declare each leg's verdict; the competitive fix
("leave the call site, add a `.toast.ok` rule") is rejected by rule 2, and the
jsdom probe rejects it too.
@argszero

Copy link
Copy Markdown
Owner Author

Self-review

Shape of the defect. One argument, one word, but it is the whole complaint: toast() never validates its type, so a level with no rule is not an error — it is a toast that quietly loses its colour. The word was borrowed from the neighbouring if (r.status === "ok") condition, which is why it looks plausible at the call site and why a source scan that only asks "is this a string?" would not notice.

Why a static gate and not only the probe. CI has no JS runner; cargo test is the only thing that can hold this shape over time. The probe proves the rendered className and can never be in CI. Conversely the gate is lexical — it proves the word and the rule share a source, not what the screen shows at that instant. Each is recorded with its own scope in ui/README.md; neither is claimed to subsume the other.

The three rules are all derived, deliberately. Rule 1 takes its vocabulary from ui/css/style.css minus the classes toast()'s own body applies via classList.add — so the non-level .toast.out needs no hand-written exemption and a future state class does not turn the gate red. Rule 2 parses the contract sentence out of ui/README.md instead of restating it. Rule 3 is the reverse direction (no dead level). A hand-written list of three words would have passed on today's tree and drifted tomorrow.

The competitive fix was measured, not assumed. "Leave the call site alone, add .toast.ok to the stylesheet" is the cheapest way to make the screen look right — and it does: the probe's B1 goes green. It is rejected because the stylesheet would then declare a level the documented contract does not name. That asymmetry is deliberate and is asserted in both instruments (m_css/m_css2 legs; gate rule 2), with the reason for the rejection asserted (r2_bad must name .toast.ok), so the boundary cannot decay into "the verdicts merely differ".

A bug found while building the gate, fixed in the same commit. The gate's JS scanner walks the source to find toast( call sites. Its first version was fooled by regex literals containing quotes and parens — /[&<>"']/g in esc(), /[",\r\n]/ in the CSV cell escaper — which opened a phantom string and swallowed the rest of the file: 74 call sites collapsed to 16 and the toast definition was never reached. The scan now skips regex literals (deciding "regex vs division" from the preceding significant character) in all three places that do paren/argument scanning, and the_r92_scanner_lands_on_the_real_toast_sites pins the count, the argument arity and the derived state-class set so the same class of derailment cannot return silently.

Also fixed in the same commit: the "competitive fix" mutant was declared on the fixed tree, which made the added .toast.ok rule dead and so tripped rule 3 as well as rule 2 — not the competing fix at all. It is now declared on the unfixed tree (call site still "ok"), which is what the comment above it had said all along, and that makes the rejection attributable to rule 2 alone.

@argszero
argszero merged commit e081ffb into main Sep 22, 2026
1 check passed
@argszero
argszero deleted the fix/toast-level-vocabulary branch September 22, 2026 14:44
@argszero argszero mentioned this pull request Sep 24, 2026
10 tasks
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