Skip to content

fix(ui): reset the session overlays when the session ends - #278

Merged
argszero merged 1 commit into
mainfrom
fix/session-overlay-reset
Sep 21, 2026
Merged

argszero merged 1 commit into
mainfrom
fix/session-overlay-reset

Conversation

@argszero

Copy link
Copy Markdown
Owner

Summary

exitGuest() — the identity boundary — only did three things: clear the session caches, add hidden
to #app, and reveal #login-view. But the panels that only exist while a session is live —
the shortcut help panel (#help-panel), the consume simulator (#chat-modal) and the first-login
tour (#tour-overlay / #tour-ring / #tour-pop) — are siblings of #app, not children
(ui/index.html: #app at :137, the panels at :812 / :822 / :840-842). Hiding #app therefore
does not hide them: they stay on screen over the login form, and are inherited by the next
session
.

The strongest face is cross-session. Open the help panel on the Settings view, log out, and let
someone else log in: the panel is still there, and #help-context still reads View: Settings
(renderHelp() only runs when the panel is opened), so the new user sees a panel captioned with the
previous user's view — and nothing ever repaints it.

The boundary already knows how to close these panels: the very same file closes them on Escape
(ui/js/app.js:4260 if (e.key === "Escape" && tourOpen) { closeTour(); return; }, :4262 help).
This change is therefore the missing half of the boundary, not new product behaviour — it hands
each #app-external panel back to the closer that already exists (toggleHelp(false) /
closeChat() / closeTour()), in one named function.

Severity, stated honestly — this is not a data leak. The #chat-modal face appears on the 401
hook (window.__atpLogout), i.e. the same person's token expiring, so the balance shown there is
their own. The cross-session face discloses which view the previous user was on (#help-context's
{view}) — not balances and not content. The defect is UI state surviving the identity boundary
(wrong container still mounted + stale context), which is why it is fixed at the boundary.

Root cause and provenance — drift, not a deliberate trade-off

The boundary's shape is older than the panels it fails to close, and each panel brought its own
closer when it was added:

commit PR what it added
ef258ef #23 exitGuest() itself (guest browse mode)
9a3ff3e #22 #chat-modal + closeChat()
8c49025 #59 #help-panel + toggleHelp(false)
91fb61d #63 #tour-* + closeTour() (+ the Escape handler at :4211)
0e4acc2 #251 the last change to the boundary: it added resetSessionCaches() for the very same reason — "one session's state must not be inherited by the next" — and still only handled #app

So the author's intent is unambiguous and it is the one this PR implements: #251 extended the
boundary in exactly this direction one PR ago and simply did not consider the sibling panels. There
is no reading of the history under which "the panels are supposed to survive logout" was ever
decided.

Changes

# File Change
E1 ui/js/app.js (identity boundary) add resetSessionOverlays() (one named roster: toggleHelp(false), closeChat(), closeTour()) and call it from exitGuest() right after resetSessionCaches()
E2 ui/index.html:858 cache-bust the app bundle ?v= by procedure — read the live token and write a strictly greater one (do not copy a literal from any sheet)
E3 ui/README.md new item 3 in the "per-session caches" section: the boundary must also close the panels outside #app, plus the gate's stated scope
E4 src/state_gate.rs new invariant the_identity_boundary_closes_the_panels_outside_the_app (4 rules) + its ruler self-check the_c2171_overlay_extractors_have_teeth (synthetic inputs)
E5 zero new i18n keys; no new product behaviour (all three closers are existing, in-use paths)
  // 身份边界(C2171):`#app` 之外的浮层同样属于这个会话,随边界一起收起
  function resetSessionOverlays() {
    toggleHelp(false);
    closeChat();
    closeTour();
  }

The roster is deliberately named and singular: the gate derives the required element set from
ui/index.html and compares it against the closure of this one function, so adding a sixth panel
without teaching the boundary about it turns CI red.

The gate (E4) — four rules, each with its own tooth

  • Rule 4 (positive control). The derived set must be non-empty, must not contain login-view
    or toast-wrap, and must equal the known overlay set. #toast-wrap is a permanent container with
    no hidden token, so the derivation excludes it rather than the author remembering to; #login-view
    sits before #app, so the "after #app" cut excludes it.
  • Rule 1 (main tooth). closure_hidden_elements(app.js) == overlays_outside_app(index.html):
    every derived element must have, inside the reset's transitive call closure, both a literal
    "#<id>" and an operation that adds hidden.
  • Rule 2. The boundary's code body must call resetSessionOverlays().
  • Rule 3 (reverse). The boundary must not be hollowed out: it must still call
    resetSessionCaches(), still hide #app, still reveal #login-view — this blocks the "delete the
    boundary so rule 2 passes" shortcut.

Stated scope of the gate (recorded here on purpose, pitfall #341)

The gate is lexical. It proves "every element derived from ui/index.html has a closer that
claims to hide it, and that closer is inside the boundary's call closure"
— it does not prove
that anything is actually hidden on screen at runtime. That half is the DOM probe's job
(c2171-probe.js); this repo's CI has no JS runner, so the two instruments are deliberately
disjoint and neither pretends to cover the other.

Known blind spots, listed rather than hidden: ① the derivation reads only column-0 (top-level)
elements of ui/index.html — an indented panel would be invisible to it; ② "adds hidden" is
literal matching, so classList.toggle("hidden", false) (a two-argument hide) would not count
(nobody writes it that way today); ③ only literal "#<id>" selectors are recognised, not dynamic
ones (`#${id}`). Exemption list: zero.

Evidence — A/B

DOM probe (c2171-probe.js, jsdom, real index.html + the four real scripts)

Stubs and accounts for fetch only; drives the real login form, the real Log out
button, the real ? shortcut, real nav clicks and the real 401 hook. Every leg declares its
expectation up front, and the harness prints that declaration next to the PASS/FAIL verdict (#339).

tree mode result
unfixed (c6da6b8 app.js 68faa626…) --base (axis legs declared "defect present") 13/13 as declared, axis legs exactly {H3,H4,C3,C4,T2}
the shipped tree (ui/js/app.js cd0aa602…) post-fix 13/13 green
c2171-app-m1-only2.js (c7d4b154…) — closes only the two reachable panels post-fix 12/13, T2 red
c2171-app-m2-noclose-tour.js (7af478ee…) — reset exists but skips the tour post-fix 12/13, T2 red

Observed on the unfixed tree: H3 help-panel visible over the login view = true,
H4 panel visible in the NEW session = true ctx="View: Settings · Theme: Dark",
C3 modal visible after the boundary = true,
T2 tour-overlay visible after the boundary = true. The legs' declarations come from the
invariant ("the panel is closed after the boundary"), the observed values are printed as a second,
independent fact; the N1 control (a fresh boot has all panels hidden) shows the staleness is the
boundary's doing.

Gate A/B and isolating mutation legs (c2171_gate_mut_ab.py, 12 legs, run against the shipped ui/js/app.js)

Both take the real src/state_gate.rs, make the include_str! paths absolute, append
c2171-gate-test.rs and compile with rustc --test --edition 2021 — i.e. the gate is compiled and
run
before it is ever pasted into the repo (the C2168 precedent: hand-written, never-compiled Rust
usually has errors, and finding them at fix time costs a whole round).

leg declared actual
unfixed tree (C2171 absent) rule 1 ✅ rule 1
fixed GREEN ✅ GREEN (28/0)
m1-only2 rule 1, left {} ✅ rule 1
m2-noclose-tour rule 1, left {chat-modal, help-panel} (non-empty) ✅ rule 1
m_rule2 — boundary stops calling the reset rule 2 only ✅ rule 2
m_rule3a — boundary stops clearing the session caches rule 3a only ✅ rule 3a
m_rule3b — boundary stops hiding #app rule 3b only ✅ rule 3b
m_rule3c — boundary stops revealing #login-view rule 3c only ✅ rule 3c
m_rule4a — nothing outside #app carries hidden (empty derivation) rule 4a only ✅ rule 4a
m_rule4b — a new overlay the reset does not close rule 4b only ✅ rule 4b
m_rule1 — reset closes only two of the five rule 1 only ✅ rule 1
n_loginview#login-view (before #app) gains hidden GREEN (negative control) ✅ GREEN

Two independent instruments each reject both competing half-fixes: the probe red on T2, the gate
red on rule 1. The two ways of "passing" without fixing anything — hiding the panels by deleting the
data-*/hidden markers from index.html, or deleting the boundary — are covered by rule 4b and
rule 3 respectively.

Tests

  • cargo test passes: 318 passed / 0 failed on the shipped tree (316 on main c6da6b8, so
    +2 — exactly the invariant and its self-check added here).
  • cargo fmt --check passes. ⚠️ Not free: the new // ==== box comment is a column-0 block that
    rustfmt re-indents into mod tests (rustfmt also gives up on the two assert_eq! calls whose
    message string is longer than max_width, so those were hand-normalised). Run cargo fmt first,
    then cargo fmt --check, then re-run cargo test.
  • cargo clippy --all-targets -- -D warnings clean. Note CI runs 1.97.1 while the local toolchain
    is 1.95.0 (pitfall #344), so clippy is run on the spliced tree, not only via rustc.
  • New tests added: the gate and its ruler self-check, with synthetic inputs covering the
    "mentions the id but only removes hidden" (renderer) shape and the "adds hidden but never
    mentions the id" shape.

What this PR deliberately does NOT do

  • It does not call location.reload(), and it does not inline the three closers into
    exitGuest() — an inline version has no closure for the gate to derive, and it would drift from
    the shape of the neighbouring resetSessionCaches() call.
  • It does not close #toast-wrap (a permanent container, not a panel — closing it would swallow
    toasts) and it does not touch #login-view (it sits before #app and the boundary is supposed
    to reveal it).
  • It does not touch C2170's edit (resetSessionCaches's definition, which gains
    resetTxView()): the two anchors are disjoint (the boundary's call body vs. the function's
    definition), so either landing order works.
  • ⚠️ The ui/index.html cache-bust token is the one line shared with C2167/E2 and C2170/E4
    ⇒ it must be re-read and incremented at landing time, never copied from a sheet (R98 made this a
    procedure; there is no literal token anywhere in the queue).

Related Issue

None — reported directly from reconnaissance of the identity-boundary family (C2132 → C2142 →
C2145 → C2170 → C2171). No issue to close.

Checklist

  • Branch naming follows the convention (fix/) — checked against CONTRIBUTING.md's whitelist
    (feat / fix / docs / refactor), not against habit
  • Commit message uses Conventional Commits (fix(ui): …)
  • Single responsibility, minimal change — UI-only, no new i18n keys, no new product behaviour

`exitGuest()` is the identity boundary, but it only hid `#app`. The
panels that exist only while a session is live — `#help-panel`,
`#chat-modal` and the first-login tour — are *siblings* of `#app` in
`ui/index.html`, so hiding `#app` left them on screen over the login
form, and the next session inherited them (with `#help-context` still
naming the previous user's view, and nothing ever repainting it).

The boundary already owns the closers: the same file closes these
panels on Escape (`app.js:4260`/`:4262`). This adds one named function,
`resetSessionOverlays()`, that hands each `#app`-external panel back to
its own closer, and calls it from `exitGuest()` right after
`resetSessionCaches()` — the same direction #251 took one PR ago.

The new invariant `the_identity_boundary_closes_the_panels_outside_the_app`
derives the required element set from `ui/index.html` (top-level elements
after `#app` whose `class` carries the standalone token `hidden`) and
compares it against the transitive call closure of that one function.
Zero exemption list: adding a sixth panel turns CI red.
@argszero

Copy link
Copy Markdown
Owner Author

Self-review (Committer, allow_self_merge: true — own-approve is rejected by the API, so this is a comment).

Re-ran everything on the tree that is actually being merged, not on the drafting notes:

Product change — one named function plus one call site (ui/js/app.js, +14 lines). The roster
(toggleHelp(false) / closeChat() / closeTour()) is deliberately not a hand-written list: the gate
derives the required set from ui/index.html, so a sixth panel turns CI red instead of silently leaking.

DOM probe (c2171-probe.js, jsdom, real index.html + the four real scripts; stubs fetch only) — 13 legs, each declaring its expectation up front:

tree md5 result
unfixed (c6da6b8) 68faa626… 13/13 as declared, axis legs exactly {H3,H4,C3,C4,T2}
the shipped tree cd0aa602… 13/13 green
m1-only2 (closes only the reachable panels) c7d4b154… 12/13, T2 red
m2-noclose-tour (reset exists but skips the tour) 7af478ee… 12/13, T2 red

Observed on the unfixed tree: H3 … = true, H4 panel visible in the NEW session = true ctx="View: Settings · Theme: Dark", C3 … = true, T2 … = true. The N1 control (a fresh boot has every panel hidden) shows the staleness is the boundary's doing, not the fixture's.

Gate A/B (c2171_gate_mut_ab.py, 12 legs, rustc --test on the real src/state_gate.rs + the gate block, include_str! made absolute)RESULT: ALL LEGS AS DECLARED. Every rule now has an isolating leg: m_rule1 / m_rule2 / m_rule3a / m_rule3b / m_rule3c / m_rule4a / m_rule4b each open exactly one rule, fixed is GREEN (28/0), and the n_loginview negative control stays GREEN (flagging #login-view, which sits before #app, must change nothing — otherwise the "after #app" cut is doing no work). The three product-level arms (unfixed, m1-only2, m2-noclose-tour) all go red on rule 1; m2's left-hand set is {chat-modal, help-panel}, i.e. non-empty, so the teeth are in the set comparison, not in "is it empty".

Pre-flight anchor verifier (c2171_verify_edits.py) re-run against a clean c6da6b8 materialisation: ALL CLEAN — every anchor unique, C == D == {help-panel, chat-modal, tour-overlay, tour-ring, tour-pop}, login-view/toast-wrap excluded, and renderTourStep absent from the closure (it is the real false positive: it reveals #tour-ring, which is why "mentions the id" alone is not the predicate).

Scope, stated so nobody reads more into it than is there (pitfall #341): the gate is lexical. It proves "every element derived from index.html has a closer that claims to hide it and that closer is inside the boundary's call closure". It does not prove anything is hidden on screen at runtime — that half belongs to the DOM probe, and this repo's CI has no JS runner, so the two instruments are deliberately disjoint. Known blind spots are listed in the body (column-0 elements only; literal "#<id>" only; two-argument classList.toggle("hidden", false) not recognised). Exemption list: zero.

Honest severity: not a data leak. The #chat-modal face is the same person's 401. The cross-session face discloses which view the previous user was on, nothing more.

One thing worth flagging for the record: cargo fmt --check failed on first run — the new // ==== box comment is a column-0 block that rustfmt re-indents into mod tests. Fixed by running cargo fmt (whitespace-only change, verified by comparing the two files after stripping leading whitespace), then cargo test re-run: 318 passed / 0 failed (main is 316, so +2 = exactly the invariant and its self-check). clippy --all-targets -- -D warnings rc=0.

@argszero
argszero merged commit fa6c399 into main Sep 21, 2026
1 check passed
@argszero
argszero deleted the fix/session-overlay-reset branch September 21, 2026 16:30
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