Skip to content

fix(ui): derive the pager's ellipsis guards from the window - #285

Merged
argszero merged 1 commit into
mainfrom
fix/tx-pager-window-no-swallowed-page
Sep 22, 2026
Merged

argszero merged 1 commit into
mainfrom
fix/tx-pager-window-no-swallowed-page

Conversation

@argszero

Copy link
Copy Markdown
Owner

Summary

The transactions-table pager renders a compact window — 1 … p-1 p p+1 … N — whenever there are more than 9 pages. Its two ellipsis guards were one notch tighter than the window they guard (page > 4 and page < pages - 3), so at page = 4 and at page = pages - 3 the pager printed two adjacent page numbers with no ellipsis between them: a real page silently vanished from the control.

Walking the pager with real clicks on the unfixed tree (100 rows / 10 per page ⇒ 10 pages), reading the rendered .pager children:

    page  4: 1 3 4 5 … 10        <-- gap 1->3, page 2 is gone
    page  7: 1 … 6 7 8 10        <-- gap 8->10, page 9 is gone

The ellipsis is a non-clickable <span> (user-select: none) and the repository has no prev/next control, so the pager is the only way to reach those pages: the number disappears with nothing on screen saying it still exists.

Fix — derive both guards from the loop that already defines the window instead of keeping two hand-tuned constants. The window is Math.max(2, page - 1) … Math.min(pages - 1, page + 1), i.e. page - A … page + B with A = B = 1:

  • left: the only number strictly between the pinned 1 and the window start page - 1 is 2 ⇒ a gap exists iff page - 1 > 2 ⟺ page > A + 2
  • right: the only number strictly between the window end page + 1 and the pinned pages is pages - 1 ⇒ a gap exists iff page + 1 < pages - 1 ⟺ page < pages - (B + 1)

Hence page > 3 / page < pages - 2. The same click-walk on the fixed tree renders page 4: 1 … 3 4 5 … 10 and page 7: 1 … 6 7 8 … 10, with zero invariant violations.

Related Issue

None — found while scanning the transactions view (same family as the server-paging change #135 / 052b60c: the pager was born in the static prototype and kept its hand-tuned constants).

Changes

  • ui/js/app.js — pagerButtons(): both ellipsis guards now follow the window boundaries (page > 3, page < pages - 2); the header comment states the derivation, so the next person to change the window width knows these two numbers are not free.
    The production change is two literals plus comments — no restructuring, no change to the rendered shape.
  • ui/README.md — a new section (交易表分页器窗口约定(R167)) records the convention: the window is defined by the loop, and the guards must be derived from its half-widths.
  • src/state_gate.rs — new gate the_pager_window_and_its_ellipsis_guards_agree. It is derivation-based, not a snapshot: it reads the window half-widths A / B out of the loop and requires the left literal to be A + 2 and the right literal to be B + 1. It therefore accepts any self-consistent window and rejects a gate that merely pins this edit's literals — a test that would go green on the unfixed tree is not a test.
  • ui/index.html — cache-bust token bumped for the app.js change.
  • No config / schema change, so no example-file update is needed.

Tests

  • cargo test — 347 passed / 0 failed (baseline e1cd51b: 342; +5 = the new gate plus its self-proof tests)
  • cargo fmt --check — clean (rc=0)
  • cargo clippy --all-targets -- -D warnings — clean (rc=0)
  • New tests added: state_gate::the_pager_window_and_its_ellipsis_guards_agree with its teeth self-proof (each rule has a mutant leg that must fail for its own reason).

Two independent instruments, both directions:

  • jsdom behavioural probe (real index.html + the four real scripts, a fixture that pages like the gateway, driven by real click() on the rendered buttons — no touching internal state). The legs are phrased as the invariant, so the unfixed tree declares failure. On a materialized pre-fix tree (git archive e1cd51b ui, app.js md5 947efc442ee8856555823febc254efec): 5 variants × 7 legs = 35 legs, 0 not-as-declared.
    • base — reproduces the defect exactly: A1 page4=["1->3"], A2 page7=["8->10"], B1 sweep 2/10
    • fix — all seven pass, sweep 0/10
    • competing "fixes" rejected: m_showall (just print every page) → rejected by the shape leg C2 (["1:10","2:10","3:10","4:10"]); m_left_only (fix only the left guard) → rejected by A2/B1 (the two boundaries are two separate literals, so pinning one page only proves half the fix); m_mark_only (leave the sequence alone, add an explanatory tooltip) → rejected by A1/A2/B1.
  • compile gate for the new Rust gate — RESULT: ALL LEGS AS DECLARED, negative control 16/16 / failed=0, rustfmt and clippy-driver -D warnings legs on both the pre-fix and the fixed tree.

Honest scope (recorded in ui/README.md): the Rust gate is lexical — it proves the guards follow the loop's window, not that the rendered DOM is contiguous; the latter is the jsdom probe's job (there is no JS runner in cargo test).

Checklist

  • Branch name follows the convention (fix/…)
  • Commit message uses Conventional Commits (fix(ui): …)
  • Single purpose, minimal change (UI pager only; the production change is two literals + comments)

The compact pager (pages > 9) renders `1 … p-1 p p+1 … N`, but its two
ellipsis guards were one notch tighter than the window they guard
(`page > 4` / `page < pages - 3`). At `page = 4` and `page = pages - 3`
two adjacent page numbers were printed with no ellipsis between them, so a
real page disappeared: at pages = 12, page = 4 the old code renders
`1 3 4 5 … 12` — page 2 is unreachable and nothing says so. The ellipsis
is a non-clickable `<span>` and the repo has no prev/next, so the pager was
the only way to reach that page.

Derive both guards from the loop that defines the window (half-widths
A = B = 1, clamped to [2, pages - 1]): a gap exists on the left iff
`page > A + 2`, on the right iff `page < pages - (B + 1)`.

Add `state_gate::the_pager_window_and_its_ellipsis_guards_agree`, which
reads the window half-widths out of the loop and requires the guard
literals to equal `A + 2` / `B + 1`. It is derivation-based, not a
snapshot: it accepts any self-consistent window and rejects a gate that
pins this edit's literals. `ui/README.md` records the convention.
@argszero

Copy link
Copy Markdown
Owner Author

Self-review (own PR — a plain comment, not a formal review).

Delta: pagerButtons() — the two ellipsis guards become derived (page > 3 / page < pages - 2) instead of hand-tuned constants; the rest of the change is comment, docs, a new gate, and a cache-bust bump. No shape change: the compact window still renders at most 7 tokens.

Verified on the landed tree (b29fffc):

  • cargo test — 347 passed / 0 failed (baseline e1cd51b = 342; +5 = the new gate + its self-proofs)
  • cargo fmt --check rc=0 · cargo clippy --all-targets -- -D warnings rc=0

Discrimination, both directions:

  • jsdom behavioural probe against a materialized pre-fix tree (git archive e1cd51b ui, app.js md5 947efc442ee8856555823febc254efec): 5 variants × 7 legs = 35 legs, 0 not-as-declared. base declares and reproduces the defect (page4=["1->3"], page7=["8->10"], sweep 2/10); fix is clean (sweep 0/10); each competing "fix" is rejected by its own leg — m_showall by the shape leg (C2), m_left_only by the right-boundary legs (A2/B1), m_mark_only by all three invariant legs (A1/A2/B1).
  • compile gate for the new Rust gate: ALL LEGS AS DECLARED; negative control 16/16, failed=0; rustfmt + clippy-driver -D warnings legs on both the pre-fix and the fixed tree.

Honest scope: the Rust gate is lexical — it proves the guards follow the loop's window half-widths and accepts any self-consistent window; it does not prove DOM contiguity (that is the jsdom probe's job, since there is no JS runner in cargo test). Recorded in ui/README.md.

Risk: minimal — the production delta in ui/js/app.js is two literals; the pager's rendered shape is asserted unchanged by the probe's shape leg.

@argszero
argszero merged commit 54e0c65 into main Sep 22, 2026
1 check passed
@argszero
argszero deleted the fix/tx-pager-window-no-swallowed-page branch September 22, 2026 02:23
@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