fix(ui): show a plan's label, not its id, in the sharing form - #268
Conversation
|
Self-review (committer, Verified on the pushed commit
In-tree A/B, 11 legs, all as declared (mutate the tree, run the three affected gate tests, restore Two things worth a reviewer's attention, both recorded in the description rather than smoothed over:
The third part of this change (the |
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.
Summary
A sharing row stores only
keys.plan— a plan id such asdeepseek-paygo. The up-listing dropdown and thesuccess toast already render that plan's label (
API (pay-as-you-go)) through the sharedplanLabel()resolver, but the sharing table cell (
#share-body) and the dashboard card (#dash-sharings) rendered the rawid. 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
zhsession 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 everyrender 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:
i18n_pack:backend_neutral_data_labels_are_localized_in_the_clientasserted the toast statementcontains the literal substring
planLabel(. Routing the toast throughplanLabelById(id)— the sameresolver, 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).
state_gate:renderDashboard()'s render closure now readsLive.plans, whose only writer wasloadSharing()— a sharing-view loader, absent from the dashboard branch's loader closure(
loadDashboard()∪loadSession()). That is the shape the gateevery_view_branch_loads_each_slot_its_renderer_readsexists to catch, and the shape of the earlier walletdefect (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 bothloadSharing()andloadDashboard()—the same shape the wallet fix used (
refreshDashboard()).Related Issue
Changes
ui/js/app.js— two module-level helpers besideplanLabel():planById(id)— the one id→plan resolution ((Live.plans || D.PLANS).find(pl => pl.id === id)), replacingthree inlined copies (up-listing dropdown, success toast, plan hint).
planLabelById(id)— renders a stored plan id: resolves viaplanById, and passes the resolved object toplanLabel(). Unknown id (a stale id after a config change) returns the id verbatim — language-neutral, asbefore.
ui/js/app.js—sharingsToView()derivesplan:throughplanLabelById(s.plan); the two render points(
#share-bodycell,#dash-sharingscard) now render that derived value (esc(s.plan)) instead ofre-applying the id fallback (
esc(s.plan || "API")).ui/js/app.js—refreshPlans()becomes the single writer ofLive.plans;loadSharing()andloadDashboard()each call it.src/i18n_pack.rs— the resolver discriminant inbackend_neutral_data_labels_are_localized_in_the_clientacceptsplanLabel(orplanLabelById(.ui/index.html—app.jscache-bust token bumped.ui/README.md— documents both invariants: (a) a resolver discriminant must cover every resolver of thething 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):
data.js > PLANS[].nameholds hard-coded Chinese names.planLabel()prefersname, so on the fallback path the label is now built from the language-neutraltype(T("share.planName.<type>"))for fallback rows, keeping an
ensession free of CJK. WhenLive.plansis present — the live path — thebrand
namestill wins, unchanged. (The hard-codeddata.jsname itself is a separate, still-open item; thischange routes around it rather than deciding it.)
"API"fallback for an emptyplanis kept verbatim.sharing::createrejects an empty or unknownplan, so
s.plan === ''is unreachable through the API; localizing that literal would change nothingobservable.
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
Keycolumn fallback or thedata.jsnamequestion — both are other axes.
Tests
cargo test— 300 passed / 0 failed (mainat the time of this change: 299). The+1is the newdiscriminant 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)md5): 11 legs, every declared red set reproduced, and the red sets are disjoint —
ui/js/app.jsreverted tomain: onlyi18n_packred;esc(s.plan || "API"): onlyi18n_packred (each render point is guarded);plan.namedirectly: onlyi18n_packred — the widened discriminant kept its teeth(the widening removed a false positive, not the assertion);
name: onlyi18n_packred;refreshPlans()dropped fromloadDashboard(): onlystate_gatered — the two rules guard genuinelydifferent things;
loadDashboard()call for a second inline writer ofLive.planskeeps every gate green. The slot-closure gate asks "does any loader in this branch write theslot", 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.runtime, and jsdom is not installed here (
npm install jsdomtimed out — no network). Everything above istherefore lexical: it proves the render points consume the derived value and that
sharingsToViewderives 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_pack23/23 andstate_gate22/22 with both fixes,22/23 and 21/22 without them, red on exactly these rules.
Checklist
fix/…)