Repository navigation
fix(plugin-form): derive the numeric form step from scale, not precision (objectui#9574) - #9873
Conversation
…cision` (objectui#9574) `@objectstack/spec` declares `precision` as "Total digits (non-negative integer)" and `scale` as "Decimal places (non-negative integer)". The auto-generated form field derived its `step` from `precision`, so a `decimal(10, 0)` field was handed `1e-10` instead of `1` and a `decimal(10, 2)` got `1e-10` instead of `0.01`. `NumberField` states the same rule verbatim one layer down and already moved; this is the producer one layer up catching up with it. Two further consequences of the same expression: - `scale: 0` is honoured — the test is `typeof`, not truthiness. - an undeclared `scale` resolves to `step="any"`, not to no attribute at all: an absent `step` is HTML's default of 1, which marks every decimal `:invalid` and blocks the submit, a granularity nobody declared. Same tail `NumberField` uses. Where it is observable, measured through a real render rather than assumed: the registered `field:*` widgets DROP this key (their metadata carrier is the raw object-schema field and their DOM whitelist does not forward `step`), so deleting the assignment outright left every `<input>` on that route byte-identical. The key IS read on the renderer's unregistered-widget fallback, where the leftover field props are spread onto the `<input>`. The new pin measures both routes side by side and labels which of its assertions is load-bearing for this change. Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Xm4WFhEe5mwcgyqHjxR2hn
|
changeset-claim-re-read
|
✅ 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
|
|
What that body claims: that the top-level What this PR changes in that file, counted rather than characterised: the whole diff to - formField.step = field.precision ? Math.pow(10, -field.precision) : undefined;
+ formField.step = typeof field.scale === 'number' ? Math.pow(10, -field.scale) : 'any';— the remaining 33 added lines are the comment above it. The line sits inside the branch that has already resolved ⛔ No correction written, and ⛔ none owed: the sentence is still true. Generated by Claude Code |
Fixes #9574
Clause-②: no — this card neither widens an accepted set nor enlarges a published surface; it pulls one consumer back onto a member the contract already declares. My measurements did not contradict that: no declaration moved, and the only key whose value changes is one
ObjectFormalready wrote.The change
packages/plugin-form/src/ObjectForm.tsx, the auto-generated numeric form field:Three properties, each deliberate:
scale, notprecision. The installed@objectstack/spec(17.4.0, read in this worktree'snode_modules) declares them apart —precisionis"Total digits (non-negative integer)",scaleis"Decimal places (non-negative integer)"— andNumberFieldstates the consequence verbatim one layer down: "Step followsscale(decimal places), notprecision(total digit count)".typeof, not truthiness.scale: 0is a valid declaration meaning "steps by 1"; the spec's own prose uses ascale: 0ordinal integer as its example.'any', notundefined, for an undeclaredscale. An absentstepattribute is HTML's default of 1, which marks every decimal:invalidand blocks the submit — a granularity the author never declared.'any'is the tailNumberFieldalready uses for the same case. Had this stayedundefined, a field declaring onlyprecisionwould have moved from "accepts decimals" to "refuses them", i.e. the fix would have introduced a new defect.Where this key is read — measured, not assumed
The same file carries a tombstone (objectui#8438) about a sibling key written here that reaches nothing, so liveness was measured rather than argued. Rendering a real
ObjectFormover a mock data source, on this branch's base:stepnumber,scale: 01NumberField's own derivationnumber,scale: 20.01NumberField's own derivationcurrency,precision: 100.0000000001CurrencyField's ownMath.pow(10, -precision).toFixed(precision)percent,precision: 100.0000000001PercentField's ownMath.pow(10, -precision).toFixed(precision)number,scale: 0, unregisteredwidget1e-10The spelling is the lit control that tells the two producers apart: this file's value is a bare number, so React writes
1e-10, while the widgets'.toFixed(precision)writes0.0000000001.Ablation on the base: deleting the assignment outright and re-rendering left every rendered INPUT element on the registered route byte-identical (the mutation was verified on disk — anchor hits 1 then 0, injected marker 1 — and the file restored to its
HEADblob hash afterwards). So the registeredfield:*widgets genuinely drop it: their metadata carrier isformField.field(the raw object-schema field, assigned a few lines above) and their DOM whitelist does not forwardstep. What the key DOES reach is the form renderer's unregistered-widget fallback branch, where the leftover field props are spread onto the INPUT element — the branch that renderer's own comment describes as the normal path for an app that declares a widget it never registered.⇒ the card's headline example over-states one thing and under-states another, and the report on the issue says so: for a plain
numberfield on the ordinary route the browser already stepped by1, becauseNumberFieldhad already moved; the1e-10a user actually meets on that route comes fromCurrencyField/PercentField, which are not this PR's surface.Reverse verification
With the fix committed, the old expression was reinstated in place (on-disk mutation verified, then restored to the
HEADblob andgit diff HEADempty):The one test that stayed green under the reinstated defect is the block labelled
CHAIN PIN, not a pin of this card— it pins the same user-visible rule through the registered route and says in its own name that it passes on both configurations. That is the label being honest, not a gap.The percent seam (objectui#9810) — what I measured and what I left alone
objectui#9810 claims
scalemeans stored decimals to the write path and displayed decimals to the display path, ~100x apart for a fraction-stored percent. Read, and not treated as settled. What I can say from this checkout:percentScaleOfin the installed spec confirms the storage convention: apercentfield stores a 0–1 fraction unless it declaresmax > 1.scaleblock states that it is enforced at write time (Number.isInteger(def.scale) && def.scale >= 0).@objectstack/objectqlis not installed in this repo (onlyclient,core,formula,lint,sdui-parser,spec,typesare present under.pnpm), so themax_scalebranch itself was NOT re-derived here — a dead instrument, not an absence. The lit control is that@objectstack/specIS present and itsFieldSchema/percentScaleOfsymbols do hit.PercentField, which drops this key). So10^-scalethere is the stored-decimal granularity, which is the quantity a write-timescalecheck judges. Before this change that same box offered1e-10, i.e. finer than any reading of the declared scale would accept. ⇒ this change moves the percent affordance toward the write path under either reading of finding(core/spec seam):scalemeans STORED decimals to the write path and DISPLAYED decimals to the display path — ~100x apart for a fraction-stored percent, so the UI offers a stepmax_scalewill reject #9810; it cannot start offering a step the validator rejects.PercentFieldandCurrencyField(packages/fields, out of this card's surface — the percent edit widget is objectui#9568's, delivered in PR objectui#9804), and the display-vs-stored question itself, which is a contract ruling and not a patch.Verification (at
4c44f718)pnpm exec vitest run packages/plugin-form/→Test Files 98 passed (98),Tests 928 passed | 1 skipped (929), exit 0 (via the shared verify lock).pnpm exec turbo run type-check --filter=@object-ui/plugin-form --concurrency=2→Tasks: 13 successful, 13 total, exit 0. The package's own task reportedcache miss, executing, so this is a real run and not a cross-worktree cache hit; it runstsc --noEmit && tsc -p tsconfig.test.json, so the new pin is type-checked too.eslint .inpackages/plugin-form(the package's own lint script, the invocation turbo runs) → 128 files, 0 errors, 853 warnings, exit 0. Narrowed to this package on the ground that this config enables no type-aware linting (noparserOptions.project/projectServiceanywhere ineslint.config.js), so a file's verdict depends on that file plus the config, and this diff cannot move the verdict of any untouched file. The repo-wide run is CI's. (A--no-inline-configvariant reports one error insectionFields.spec-parity.test.ts, a file this branch does not touch, from an inline suppression that flag disables.)node scripts/check-changeset-presence.mjs→ OK, 1 source file of 1 released package, 1 changeset.node scripts/check-changeset-no-major.mjs→ OK (this changeset ispatch).node scripts/check-control-bytes.mjs→ OK (7936 tracked text files).node scripts/check-new-cross-file-line-citations.mjs→0 new citation(s).node scripts/check-vi-mock-specifiers.mjsandcheck-vi-mock-inherit.mjs→ OK.scripts/pm/dispatch-gates.mjsderives nothing for this repo by design (it refuses when asked forobjectstack-ai/objectuifrom theobjectstackcheckout), so the gate list above is hand-derived from this repo's ownpackage.jsonscripts and.github/workflows/and is not authoritative for the whole farm — CI's is.Acceptance notes
Out-of-scope observations, noted and not filed by me (dev seats do not file on this lane):
CurrencyFieldderives its input step fromprecision—Math.pow(10, -precision).toFixed(precision)— which is the same rule this card names, on a file inpackages/fieldsthat no open card covers as far as this seat read. It is the producer of the0.0000000001a currency field actually shows today. Dedupe words:CurrencyField step precision scale·currency input step precision·Math.pow(10, -precision).toFixed·currency widget decimal places·fields widget step scale family.@object-ui/typesdeclaressteponNumberFieldMetadataandNumberFieldreads it as an override, but the installed@objectstack/specFieldSchemadeclares nostepon the object-field surface (onlyInlineGridColumnand the slider node have one) — so an author who writesstepon a platform field would have it dropped at publish while the renderer-side type says it is supported. Not verified beyond this reading, and deliberately not copied intoObjectForm: this PR derives fromscaleonly and leaves step-override handling to the widget.CurrencyFieldMetadatain@object-ui/typesdeclaresprecisionbut noscale, whileNumberFieldMetadataandPercentFieldMetadatadeclare both. Observation only.Session:
session_01Xm4WFhEe5mwcgyqHjxR2hn.Generated by Claude Code