Skip to content

fix(ui): show a plan's label, not its id, in the sharing form - #268

Merged
argszero merged 1 commit into
mainfrom
fix/sharing-plan-label-single-source
Sep 21, 2026
Merged

argszero merged 1 commit into
mainfrom
fix/sharing-plan-label-single-source

Conversation

@argszero

Copy link
Copy Markdown
Owner

Summary

A sharing row stores only keys.plan — a plan id such as deepseek-paygo. The up-listing dropdown and the
success toast already render that plan's label (API (pay-as-you-go)) through the shared planLabel()
resolver, but the sharing table cell (#share-body) and the dashboard card (#dash-sharings) rendered the raw
id. So the same sharing was described two ways on one screen: the cell printed a config identifier where every
other surface printed a name, and in a zh session it printed a non-translated token.

The id→plan lookup was inlined at each call site (three of them) and the label was produced at two of the three
render points, so the two render points that were wrong had no single place to get it from. This change gives
both a single source: planById(id) resolves once, planLabelById(id) produces the label once, and every
render point consumes the derived value.

The change also carries the two fixes it would otherwise need a second PR for — both found by cross-item landing
simulation (apply the whole queue byte-for-byte, then run the real test binaries, one gate module at a time),
not by reading code:

  1. i18n_pack: backend_neutral_data_labels_are_localized_in_the_client asserted the toast statement
    contains the literal substring planLabel(. Routing the toast through planLabelById(id) — the same
    resolver, reached by id — does not contain that substring, so the gate went red. The discriminant is widened
    to accept either resolver. The invariant was never "a specific function name"; it was "the label comes from
    the pack-aware resolver". The widened form still has teeth (see Tests).
  2. state_gate: renderDashboard()'s render closure now reads Live.plans, whose only writer was
    loadSharing() — a sharing-view loader, absent from the dashboard branch's loader closure
    (loadDashboard()loadSession()). That is the shape the gate
    every_view_branch_loads_each_slot_its_renderer_reads exists to catch, and the shape of the earlier wallet
    defect (fix(ui): the wallet view renders a slot only the dashboard loader filled #253): a session established on the dashboard would render a label no loader in that branch ever
    populated, and it would not self-heal until the user visited the sharing view. The plan-list fetch is
    extracted into a single writer, refreshPlans(), called by both loadSharing() and loadDashboard()
    the same shape the wallet fix used (refreshDashboard()).

Related Issue

Changes

  • ui/js/app.js — two module-level helpers beside planLabel():
    • planById(id) — the one id→plan resolution ((Live.plans || D.PLANS).find(pl => pl.id === id)), replacing
      three inlined copies (up-listing dropdown, success toast, plan hint).
    • planLabelById(id) — renders a stored plan id: resolves via planById, and passes the resolved object to
      planLabel(). Unknown id (a stale id after a config change) returns the id verbatim — language-neutral, as
      before.
  • ui/js/app.jssharingsToView() derives plan: through planLabelById(s.plan); the two render points
    (#share-body cell, #dash-sharings card) now render that derived value (esc(s.plan)) instead of
    re-applying the id fallback (esc(s.plan || "API")).
  • ui/js/app.jsrefreshPlans() becomes the single writer of Live.plans; loadSharing() and
    loadDashboard() each call it.
  • src/i18n_pack.rs — the resolver discriminant in
    backend_neutral_data_labels_are_localized_in_the_client accepts planLabel( or planLabelById(.
  • ui/index.htmlapp.js cache-bust token bumped.
  • ui/README.md — documents both invariants: (a) a resolver discriminant must cover every resolver of the
    thing it guards, never a single function name; (b) adding a cross-view slot read point requires wiring a
    writer into each branch that renders it (enforced by
    state_gate::every_view_branch_loads_each_slot_its_renderer_reads).

No config / data-structure changes.

Deliberate behaviour notes (recorded, not hidden):

  • The guest fallback table data.js > PLANS[].name holds hard-coded Chinese names. planLabel() prefers
    name, so on the fallback path the label is now built from the language-neutral type (T("share.planName.<type>"))
    for fallback rows, keeping an en session free of CJK. When Live.plans is present — the live path — the
    brand name still wins, unchanged. (The hard-coded data.js name itself is a separate, still-open item; this
    change routes around it rather than deciding it.)
  • The "API" fallback for an empty plan is kept verbatim. sharing::create rejects an empty or unknown
    plan, so s.plan === '' is unreachable through the API; localizing that literal would change nothing
    observable.

Scope: the change is a single "one source of truth" change (two helpers, three resolution sites collapsed,
one writer extracted). It does not touch the transaction table's Key column fallback or the data.js name
question — both are other axes.

Tests

  • cargo test300 passed / 0 failed (main at the time of this change: 299). The +1 is the new
    discriminant self-check, the_plan_label_resolver_discriminant_covers_every_resolver.
  • cargo fmt --check — clean (exit 0)
  • cargo clippy --all-targets -- -D warnings — clean (exit 0)
  • In-tree A/B (mutate this tree, run the three affected gate tests, then restore byte-exact and verify the
    md5): 11 legs, every declared red set reproduced, and the red sets are disjoint
    • ui/js/app.js reverted to main: only i18n_pack red;
    • either render point back to esc(s.plan || "API"): only i18n_pack red (each render point is guarded);
    • the toast reading plan.name directly: only i18n_pack red — the widened discriminant kept its teeth
      (the widening removed a false positive, not the assertion);
    • the label built from the fallback table's name: only i18n_pack red;
    • refreshPlans() dropped from loadDashboard(): only state_gate red — the two rules guard genuinely
      different things;
    • the discriminant forced always-true: caught by the self-check's own negative control.
    • Range gap, reproduced honestly: swapping the loadDashboard() call for a second inline writer of
      Live.plans keeps every gate green. The slot-closure gate asks "does any loader in this branch write the
      slot", not "how many writers does it have", so the single-writer shape here is pinned by the work order
      and the refreshDashboard() precedent — not by a gate. Recorded as a follow-up; not claimed as enforced.
  • DOM probe NOT run. The jsdom runtime probe is the only instrument that can read the rendered label at
    runtime, and jsdom is not installed here (npm install jsdom timed out — no network). Everything above is
    therefore lexical: it proves the render points consume the derived value and that sharingsToView
    derives it; it does not prove which branch produced the string at runtime. The evidence for the two
    blockers is the earlier cross-item landing simulation (whole queue applied to a mirror, real test binaries
    compiled and run, one gate module at a time): i18n_pack 23/23 and state_gate 22/22 with both fixes,
    22/23 and 21/22 without them, red on exactly these rules.

Checklist

  • Branch name follows the convention (fix/…)
  • Commit message uses Conventional Commits
  • Single responsibility, minimal change

@argszero

Copy link
Copy Markdown
Owner Author

Self-review (committer, allow_self_merge).

Verified on the pushed commit 2f2b39f:

  • cargo test — 300 passed / 0 failed (main 299; +1 is the new discriminant self-check)
  • cargo fmt --check — clean · cargo clippy --all-targets -- -D warnings — clean
  • CI test / fmt / clippy — pass (run 35587866233)

In-tree A/B, 11 legs, all as declared (mutate the tree, run the three affected gate tests, restore
byte-exact and re-check the md5): the red sets are disjoint — reverting ui/js/app.js, either bare-id render
point, the toast reading plan.name, and a fallback-table name label each turn only i18n_pack red;
dropping refreshPlans() from loadDashboard() turns only state_gate red; an always-true discriminant is
caught by the self-check's own negative control.

Two things worth a reviewer's attention, both recorded in the description rather than smoothed over:

  1. The single-writer shape of Live.plans is not enforced by a gate. A second inline writer in
    loadDashboard() passes every gate — the slot-closure rule asks whether any loader in the branch writes the
    slot, not how many do. Reproduced, and noted as a follow-up.
  2. The jsdom runtime probe was not run (jsdom is unavailable in this environment). The evidence here is
    lexical plus the earlier cross-item landing simulation; nobody should read the checklist as "the rendered
    string was observed at runtime in this round".

The third part of this change (the state_gate blocker) is the second occurrence of the shape fixed in #253
worth noting as a pattern: a render point gaining a new slot read point must name a writer for its own branch.

@argszero
argszero merged commit 7b1929a into main Sep 21, 2026
1 check passed
@argszero
argszero deleted the fix/sharing-plan-label-single-source branch September 21, 2026 10:19
@argszero argszero mentioned this pull request Sep 21, 2026
12 tasks
argszero added a commit that referenced this pull request Sep 21, 2026
Ships the 10 PRs merged since v0.7.25 (#261-#270). No schema change, no config
change, so the deployment-side config.toml needs no edit.

One fact, one source / display must equal what it consumes (frontend, 6 places)
- #261 read the spendable half of the wallet payload when refreshing your own
  balance; #262 the transactions payload signature covers the time range, with
  one reload trigger shared by the four controls; #263 the settings controls are
  either wired or explicitly inert; #265 the re-list outcome comes from the same
  entry as its action; #268 the sharing form shows a plan's label, not its
  config id; #269 the ops card stops reading a key's status count as a health
  verdict.

i18n reachability
- #266 every pack key must reach a consumer (the gate), and #270 drops the 23
  keys that gate proved unreachable: ZH/EN key count 811 -> 788, sunset list
  59 -> 36.

Gateway
- #267 applies the body limit where axum actually reads it (per-route
  DefaultBodyLimit, 8 MiB on the three gateway routes; unauthenticated
  endpoints keep the 2 MiB default). This is the application half of rant
  2026-09-18T09:14:18. It also corrects the false v0.7.10 "raised to 70MB"
  CHANGELOG line, which described installing a layer rather than raising a limit.

- Cargo.toml / Cargo.lock: 0.7.25 -> 0.7.26.
- CHANGELOG.md: v0.7.26 entry plus the v0.7.10 correction.
- ui/index.html cache-bust left as-is: this release touches no UI file; the live
  values are app.js 20260921-2 / i18n.js 20260921-2.

cargo test 302 passed; cargo fmt --check clean; clippy -D warnings clean.
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