Repository navigation
[core][fields] A percent renders as 1.234,5 % in a list cell and 1.234,5% as a dashboard measure — two percent conventions for one number #4576
Description
Activity
CLAIM — session_017Qqyix2QcnpUC9XeYVDzx3, branch
claude/issue-4576-one-number-home. Dispatching a dev agent now. This card is ruled TOGETHER with PR #4577's open question 1 (the formatDisplayNumber down-move) — the split this card reports is the live symptom of the two-homes drift that question describes, so they are one fix, not two.PM ruling (delegated decision authority; maintainer veto window open — record objections here):
- The down-move lands first, in this PR (PR fix(core): dashboard measures follow the display locale (#4566) #4577's option B, adopted):
formatDisplayNumber,shouldGroupDisplayNumberandDisplayNumberFormatOptionsMOVE from @object-ui/i18n into @object-ui/core (core is the lower, React-free layer — the function is pure and has no business behind a React/i18next package); @object-ui/i18n RE-EXPORTS them under the same names so every existing import path (fields, components, plugin-dashboard) keeps compiling byte-compatible. i18n gains a dependency on core — measure that this creates no cycle (core must not import i18n anywhere; fix(core): dashboard measures follow the display locale (#4566) #4577 already verified core's dependency list). - Then the percent split closes on the moved home: formatMeasure's percent path routes through the SAME percent policy formatPercent uses (Intl style:'percent' with its locale-correct NBSP), replacing the literal '%' suffix — restoring percentDisplayValue's doc promise that a percent 'can never drift' between a list cell and a measure. The measured cost is DECLARED, not hidden: Intl percent moves en at absurd magnitudes (fix(core): dashboard measures follow the display locale (#4566) #4577 measured 24/32,760 combinations, all ≥ ~1e20%). That en move is the fix's declared behavior change (the fix(fields): formatPercent groups and follows the display locale — the last tooltip channel (#4553) #4565 precedent — consistency wins; pin the new forms red-first, note the old forms in the changeset). Ordinary-magnitude en percent must stay byte-identical — pin the threshold.
- Pins: fix(fields): formatPercent groups and follows the display locale — the last tooltip channel (#4553) #4565's formatPercent pins untouched; fix(core): dashboard measures follow the display locale (#4566) #4577's formatMeasure en pins updated ONLY where the percent path moves (authorized pin moves, declared in pins_moved, with the ordinary-magnitude cases proven unmoved); [plugin-report][app-shell] Dataset measures still render in the MACHINE locale — the consumers #4566 could not reach #4575/fix(plugin-report): report and preview measures follow the display locale (#4575) #4582's consumer pins green; the re-export identity pinned (import from i18n === import from core, type-identical); integers-stay-String(v) and the other fix(core): dashboard measures follow the display locale (#4566) #4577 deliberate preservations untouched.
- Changesets: '@object-ui/core' MINOR (entry gains the moved exports + the percent behavior move); '@object-ui/i18n' per its measured .d.ts (a re-export swap — if declaration-identical, patch; if the declaration moved, minor with the fix(types): the DataSource contract carries deleteView's per-home outcomes (#4564) #4569 precedent's reasoning); consumers per their diffs. Never major.
- STOP conditions: a dependency cycle; any consumer that imports the moved symbols by a path the re-export cannot preserve; the percent unification measurably breaking an ordinary-magnitude en form.
Mutual exclusion: ⛔ in flight: #4563 (apps/console), #4535 (types+ObjectGrid), #4550 (packages/react navigation). packages/core, packages/i18n, packages/fields are claimed by nobody — verify with a fresh fetch. #4577/#4565/#4582 are landed context; their test files move only under authorized pin moves.
Generated by Claude Code
Generated by Claude Code
- The down-move lands first, in this PR (PR fix(core): dashboard measures follow the display locale (#4566) #4577's option B, adopted):
- added a commit that references this issue
on Aug 13, 2026 PM step-7 复核: ACCEPT — and the stage-2 mechanism substitution is CONFIRMED (option A,
style: 'percentPoints'as implemented in PR #4589).复核 checked: the 15-file diff matches the declared surface exactly (the only fields/app-shell touches are test files, both in the authorized pin-move class); changeset
'@object-ui/core': minor+'@object-ui/i18n': minorfollows the #4544 (entry-visible growth) and #4569 (declaration-moved) precedents;packages/i18n/src/utils/number-display.tsis an honest re-export with a reference-identity pin so a second copy cannot silently return; CI converged green on head2e1b87ccc(18 success + 2 skipped, 0 failures); #4590 filed with measurements and acceptance criteria.Why A and not the ruling's named mechanism (B): the ruling's stop conditions outrank its named mechanism. The dev measured first — as instructed — and the measurement disproved the ruling's premise: the
style: 'percent'divergence is rounding-tie-keyed, not magnitude-keyed (smallest mover 0.175; 27,581 of 1,200,013 ordinary-magnitude en forms), which is STOP condition 3 verbatim. Implementing B was therefore forbidden by the ruling itself. A delivers the ruling's stated goal exactly — byte-identical percent affix tostyle: 'percent'across all 171 locale tags tested — while moving zero numerals at any magnitude, so even the pre-authorized extreme-magnitude en cost is not incurred. And'percent'(value is a fraction) vs'percentPoints'(value is already in points) names the exact trap this family of cards keeps rediscovering, at the call site, in the type system. Not C: the defect was filed and its blast radius was growing.The remaining tie-level gap with
formatPercentis deliberately NOT closed here: the measure is the faithful surface and the cell is the artefact, so the fix belongs at the artefact's home — that is #4590, actionable the moment this merges.Veto window open as with every delegated ruling: reverting to the literal mechanism (B) is a two-line swap plus the tie/extreme pins per the report, at the cost of re-incurring the en movement the original ruling forbade.
Flipping PR #4589 ready and arming auto-merge now.
Generated by Claude Code
Generated by Claude Code
- added a commit that references this issue
on Sep 3, 2026 - added a commit that references this issue
on Sep 9, 2026
Found and measured while implementing objectui#4566. Filed rather than fixed there: changing it moves output in a direction that card's ruling explicitly excluded, so it needs its own decision.
The divergence
The two formatters build the percent sign differently:
formatPercent/formatPercentBody(@object-ui/fields) goes throughIntlwithstyle: 'percent'since objectui#4553 / PR fix(fields): formatPercent groups and follows the display locale — the last tooltip channel (#4553) #4565, so it gets the locale's percent CONVENTION — German writes a no-break space before the sign.formatMeasure(@object-ui/core,utils/dataset-format.ts) appends a LITERAL%to a decimal-formatted body, so it gets no space in any locale.Measured, de-DE, the same stored value:
formatPercent)1.234,5 %(U+00A0 before the sign)formatMeasure)1.234,5%English is identical on both paths (
1,234.5%), so this is invisible in an en session. Affects every locale whose percent convention has a space: de, fr, es, ar were all measured to differ; en and zh do not.This directly contradicts
percentDisplayValue's own doc comment, which says the shared scaling exists so that "a percent renders identically as a row value and as an aggregated metric — the two surfaces can never drift". The SCALING no longer drifts; the CONVENTION now does.Why #4566 did not fix it
Two measured reasons, both worth keeping on the record:
Intl'sstyle: 'percent're-scales by 100, and the round trip loses precision at the top of the range:100,000,000,000,000,000,000,000%becomes99,999,999,999,999,990,000,000%in en-US (24 of 32,760 measured combinations move, all at 1e21 and MAX_SAFE_INTEGER). [core] formatMeasure renders every dashboard measure in the MACHINE locale — the last parallel implementation outside the display-locale channel #4566's ruling made en byte-identity the discriminator for a correct mapping, so this change could not ride along on it.The decision this needs
Which convention is the correct one for a dashboard measure, given both surfaces show the same underlying number:
formatMeasure's percent branch throughIntlstyle: 'percent'so both surfaces agree on the locale's convention. Costs the precision artefact above at extreme magnitudes (arguably irrelevant for a percentage, which is not normally 1e21).formatPercentinstead, i.e. keep the literal sign on both. Cheaper and en-stable, but it deliberately renders German against its own convention, which is what [plugin-gantt] tooltip number / currency / percent rows render in the MACHINE locale, next to date rows that follow the display locale #4553 set out to stop.A is the direction the one-resolver rule points; B contradicts a ruling already made on #4553; C is only defensible if the two surfaces are judged never to be read side by side, which the dataset preview and the report renderer make doubtful.
Worth deciding together with objectui#4575, which threads the remaining
formatMeasureconsumers — if A wins it should land before those surfaces multiply the inconsistency.Generated by Claude Code