fix(fields,plugin-grid): percent surfaces take their width from scale, not precision - #9566
Conversation
`PercentCellRenderer` and the percent arm of `useColumnSummary`'s `formatSummaryLabel` both read `precision` and handed it to `Intl` as the minimum AND maximum fraction digits. `@objectstack/spec` declares `precision` as the "Total digits" of a decimal(p, s) column and `scale` as its "Decimal places", so a decimal(10, 2) percent field rendered `25.0000000000%` in the cell with `Sum: 25.0000000000%` in the footer beneath it — the same defect objectui#2131 removed from the currency arm and objectui#2134 from the number arm, one type over. Card: objectui#9295. Both percent surfaces move together, or the cell and the footer diverge on screen. An ABSENT `scale` stays 0 — matching the currency arm's spelling and today's behaviour — and deliberately not the number arm's `undefined` (min 0 / max 20), which would print binary residue here because this path multiplies by 100 first: measured, `0.07` scales to `7.000000000000001` and `0.29` to `28.999999999999996`. Three existing fixtures declared the width with `precision`; each is restated as `scale`. The objectui#9269 convergence case that pinned the old member keeps its claim and changes only the member it declares. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KSd9P5u2Mf4p8g4n4SD4Fx
✅ Console Performance Budget
The eager closure is every chunk the entry reaches through static imports — what the browser fetches and parses before the app renders. The entry chunk on its own is a small fraction of it. 📦 Bundle Size Report
Size Limits
|
The record summary chip's percent branch read `precision ?? 0` explicitly to mirror `PercentCellRenderer`, which its own comment named. When the cell moved to `scale`, the chip stayed behind and objectui#9167's pin went red on the assertion that exists for exactly that: chip and cell must be byte-equal. objectui#9167 routed this chip onto the LIST CELL as its authority, and its ACCEPT ruling turns on the two agreeing on every measured row. So the member was always incidental there — following the cell onto `scale` is what KEEPS that ruling, and staying on `precision` is what would have broken it. Card: objectui#9295. The pin's fixtures are restated from `precision: 2` to `scale: 2`, and two rows are added for the decimal(10, 2) case the card is named after: a declared `precision` beside a declared `scale` must not widen the chip, and a bare `precision` is not a width. Both of objectui#9167's assertions still hold. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KSd9P5u2Mf4p8g4n4SD4Fx
✅ Console Performance Budget
The eager closure is every chunk the entry reaches through static imports — what the browser fetches and parses before the app renders. The entry chunk on its own is a small fraction of it. 📦 Bundle Size Report
Size Limits
|
Merge-queue EVICTION — known signature, re-submitted as is, ⛔ not a test failure
The reading — the ref vanished while
|
| event | time |
|---|---|
added_to_merge_queue |
2026-09-15T16:00:31Z |
removed_from_merge_queue by github-merge-queue[bot] |
2026-09-15T16:21:37Z — 21 min 06 s in the queue |
The signature — measured on the queue branch, ⛔ not inferred
19 merge_group workflow runs on gh-readonly-queue/main/pr-9566-f1cd2903…:
- 18 of 19 →
success. - The 19th →
cancelled, jobs7 success · 2 skipped · 1 cancelled. - The cancelled job is
Test (shard 1/4), which ran 20.2 minutes and completed at 2026-09-15T16:21:05Z — 32 seconds before the queue removed the PR.
⇒ ⛔ Nothing failed. No job reports failure; the shard was cancelled by the removal, not red.
Ledger basis — this lane has measured this exact shape before
The domain:ui seat post records it on objectui#7696, in its own words:
attempt 1 —
added_to_merge_queue2026-09-14T11:48:29Z,removed_from_merge_queue2026-09-14T12:09:06Z= 20 min 37 s. Every completed merge-queue job was green; the single exception wasTest (shard 1/4), cancelled one second before the removal after running 20 min 14 s.
… the margin between this repo's shard duration and whatever the queue's wait window is measures about one minute.
attempt 2 — re-armedauto_merge, enqueued2026-09-14T12:25:27Z, merged in 17 min 10 s.
Same job name, same ~20-minute shard duration, same cancel-then-remove ordering, same all-green remainder. ⇒ known signature ⇒ re-submit as is (「已知 flaky ⇒ 原样重投」).
GET /branches/main/protection answers 403 Resource not accessible by integration for this seat, so the ~20-minute figure is inferred from two timestamps, exactly as the earlier record states.
Disposition
auto_merge re-armed exactly ONCE, and this comment says so. ⛔ No re-run of any job, ⛔ no empty commit, ⛔ no close-and-reopen, ⛔ no test skipped, disabled or quarantined, and ⛔ no change to the PR's content — the head is unchanged at 9ef3b967f, where CI reads 34 success / 3 skipped / 0 failed / 0 pending.
domain:ui execution seat · session_01KSd9P5u2Mf4p8g4n4SD4Fx (os-justin) · measured at 2026-09-15T16:28Z
Generated by Claude Code
Fixes #9295
Both percent surfaces take their fraction width from
scale, notprecision.What was wrong
PercentCellRenderer(@object-ui/fields) and thecolType === 'percent'arm offormatSummaryLabel(@object-ui/plugin-grid'suseColumnSummary) each readprecisionand handed it toIntlas BOTHminimumFractionDigitsandmaximumFractionDigits.@objectstack/specdeclares the pair in its own words onthe field face and again on the column face:
precisionis "Total digits(non-negative integer)",
scaleis "Decimal places (non-negative integer)". So adecimal(10, 2)percent field was padded out to the column's TOTAL width — itrendered
25.0000000000%in the cell, withSum: 25.0000000000%in the footerdirectly beneath it.
This is the identical repair objectui#2131 made on the currency arm and
objectui#2134 on the number arm, arriving one type later. In
useColumnSummarythe corrected percent arm now sits four lines below a currency arm it finally
agrees with, and the
⚠️comment that fenced the member as an open question isreplaced by the resolution rather than left standing beside it.
Both surfaces move in one change, or the cell and the footer beneath it
diverge on screen.
The absent-
scalecase is a DECISION, and it was measuredThe two already-repaired arms disagree with each other about an absent
scale,so mirroring each file's local convention would have reproduced that
disagreement one type over — which is the class of defect this card exists to
remove. The number cell renderer spells the absence
undefined(minimum 0,maximum 20); the footer's currency arm spells it
0.Measured, not argued. The percent path multiplies by 100 first
(
percentDisplayValue), andIntlrenders from the shortest decimalrepresentation of the resulting double:
So the number arm's convention is not transferable here — it can afford an
unbounded maximum only because it performs no arithmetic on the value. Both
percent surfaces therefore spell the absence
0, which is also byte-identicalto today's behaviour and matches the currency arm beside them. Two pinned cases
hold that line.
Behaviour change
Deliberately breaking, filed as
minorbecause this repo's fixed release groupforbids
major; the break is spelled out in the changeset.scalenow honours it:scale: 2movesfrom
25%to25.00%.precisionno longer pads to it:precision: 10moves from25.0000000000%to25%.CurrencyConfigSchema.precisionis untouched. It is a different surface with theopposite convention and its own
scalealias, and the spec warns againstconflating them at the field-face declaration itself.
Tests — fail before, pass after
Two new pins assert RENDERED output, not a helper's return value:
formatPercentwas never wrong, it formats to the width it is handed, so pinning it would have
stayed green on the defect.
PercentCellRenderer.scale-9295(@object-ui/fields)useColumnSummary.percentScale-9295(@object-ui/plugin-grid), which alsopins footer-vs-cell AGREEMENT by comparing the hook's own label against
formatPercentcomputed in the same run, plus an absolute-byte controlrefusing
Sum: 25.0000000000%— a two-surface comparison alone is blind to ajoint move.
Ablation — the implementation was committed first, then reverted to the
branch base with every test left in place. Mutation proven on disk by anchored
counts in both directions before the run (pre-fix text present, fix text absent;
column?.scale ?? 0back down to 1, the currency arm alone), and both file blobhashes confirmed different from HEAD:
The one file that stayed green is
useColumnSummary.test.tsx, whose pins coverthe currency arm this change does not touch. The must-not-change controls
(absent-
scale, the residue rows, the label prefix) stayed green in the red run,which is what makes the red meaningful. Restore proven byte-identical: both
restored blob hashes reproduce the HEAD blobs and
git diff HEADis empty.Three existing fixtures declared their width with
precisionand were triagedindividually rather than swept: two only ever needed "a percent field showing two
decimals" and are restated as
scale; objectui#9269's convergence case, whichpinned the retired member and fenced the question as NOT MEASURED, keeps its
claim and records the resolution. That file's header quotes the pre-9269 code as
history and is left verbatim, with a footnote that its first line has moved
again.
Gates
Derived from the actual diff and run from the repo root, each exit code captured
to a file before any pipe.
The lint narrowing is a measurement, not an omission: eslint here is NOT
type-aware (no
project, noprojectService, no typed-rule preset ineslint.config.js), so this diff cannot move the verdict of any file it did nottouch. The 7-file count is read from eslint's own
--format jsonoutput; 0errors, and the warnings are pre-existing
no-explicit-anynoise that thepackage
lintscripts do not enforce (lint coveragereports every packagelinted with no outstanding errors).
check:changeset-claimsis report-only and named three pending changesets thatmention these two files. Each was read: the
.decimalsretirement claim, theRELATIONAL_META_KEYSclaims, the objectui#9269 percent-convergence claim andthe objectui#9294 tenant-locale claim describe members and call sites this diff
does not touch. None is falsified; the
.scalepositive control in the.decimalsclaim is if anything more true.Acceptance notes
formatPercent's second positional parameter isstill named
precisionwhile every caller now resolves it fromscale. Thatname is the funnel the defect came through, but renaming an exported symbol's
parameter is a naming change rather than a defect, and a rename that stopped at
the module-private helper would be worse than none. The next seat editing
PercentCellRendererorformatPercentin that file will be standing on it.PercentFieldedit widget readspercentField?.precision ?? 2for its readonly display width and its slider /spinner step, so a
decimal(10, 2)percent field still shows25.0000000000%there and steps by1e-10. Same defect class, but NOT amechanical substitution and deliberately out of this diff: the widget family
has its own adjudicated convention — objectui#4361 ruled that
CurrencyField'sauthored
precisionIS a display width and wins — whileNumberFieldalreadymoved its step to
scale. Which precedent governsPercentFieldis a designcall, not this card. Note that the cell and that widget already disagree today
in the absent case (
12%against12.35%), so widget-vs-cell agreement is notan invariant this change breaks.
Verified against the contract at source in the
objectstackcheckout rather thanfrom the card.
🤖 Generated with Claude Code
https://claude.ai/code/session_01KSd9P5u2Mf4p8g4n4SD4Fx
Generated by Claude Code