You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
finding(fields): CurrencyField/PercentField lean entirely on browser number-input sanitization, which happy-dom does not implement #6765
Filed unassigned by the dev seat implementing objectui#6715, as an out-of-scope finding. Not fixed there — that card is fenced to LocationField. Recording, not choosing.
Dedup before filing: repo-scoped REST list of open issues updated since 2026-08-15 (90 issues), grepped for CurrencyField / PercentField / type="number" / number input / sanitiz. Only objectui#6452 and objectui#6318 matched, neither about this. A control word (LocationField) hit three cards in the same sweep (objectui#6715, objectui#6744, objectui#6755), so the read was sound rather than empty.
What
Two sibling widgets hand parseFloat(e.target.value) straight to onChange with no whole-string guard of their own, measured on b76ca6764:
packages/fields/src/widgets/CurrencyField.tsx:89 and :118
packages/fields/src/widgets/PercentField.tsx:45
That is the same reading objectui#6715 has just removed from LocationField, where it truncated 12abc into a plausible wrong coordinate. Here the assumed guard is different: both widgets render type="number" inputs, and the HTML value-sanitization algorithm is supposed to make the value an empty string when the text is not a valid floating-point number, so parseFloat should never see residue.
The measurement
happy-dom — this package's test environment — does not implement that sanitization. Measured directly:
Two consequences, and the second is the one worth recording:
The widgets' correctness rests on a browser behaviour that nothing in this repo asserts. If it holds, the residue case degrades to a silent drop (the value becomes an empty string, onChange receives null, and nothing is said) — which is the objectui#6716 defect class rather than the objectui#6715 one.
Any test written for these two widgets exercises the unsanitized path, not the shipped one. A suite can go green on a code path no browser takes, and a pin can fix in place a truncating behaviour that never actually reaches a user. The oracle and the product disagree, and nothing says so.
What is NOT measured
Real-browser behaviour of either widget. No dogfood or Playwright run was made against them; the reasoning above about sanitization is from the HTML spec, not from an observation.
Whether a silent drop on a currency or percent field is worth announcing, the way objectui#6716 decided it was for a coordinate.
Whether either widget has a user-visible defect at all. This is an observation about an unasserted dependency and a misleading test environment, not a reproduced failure.
Related
objectui#6715 (the same parseFloat reading in LocationField, where the input is type="text" so there is no sanitization to lean on) · objectui#6716 (the silent-refusal class) · objectui#6272 (the plausible-wrong-value precedent)
Scope — measure first; the fork resolves on the measurement, not on policy
CurrencyField.tsx and PercentField.tsx hand parseFloat(e.target.value) straight to onChange with no whole-string guard, leaning entirely on the browser's number-input value-sanitization. The card measured that happy-dom, this package's test environment, does not implement that sanitization — so any test written for these two widgets exercises a code path no browser takes.
Step 1 is the missing measurement, and it is the one the card explicitly did not make: real-browser behaviour of both widgets. Chromium and Playwright are available in this container (PLAYWRIGHT_BROWSERS_PATH=/opt/pw-browsers; ⛔ do not run playwright install). Measure what a real browser actually hands these two widgets for 12abc, 1.2.3, 0x10, and the empty string.
Then the fork is determined:
If sanitization holds in a real browser — the widgets are safe, but their oracle disagrees with the product. The deliverable is to make that disagreement visible: assert the dependency, or move the assertion to where it is true. A green suite over a path no user takes is the defect here, and a pin that freezes truncating behaviour in place would be worse than no pin.
Four sibling devs hold packages/plugin-timeline/, packages/components/src/renderers/, packages/react/src/ and apps/console/ this round.
Escape hatch
The card is honest that it has not reproduced a user-visible defect in either widget. If step 1 shows there is none, stop and report the measurement — recording that the test environment lies, with the browser reading that proves it, is the whole value and is a complete result.
{
"issue": 6765,
"status": "needs_decision",
"branch": "claude/issue-6765-number-input-sanitization",
"pr": "https://github.com/objectstack-ai/objectui/pull/6777",
"premise_still_valid": true,
"summary": "The card's premise holds and its predicted consequence does not. Verified on origin/main b98352a15: happy-dom does not sanitize a type=\"number\" input, so every existing unit test for these two widgets drives a string no browser can deliver. Step 1 measured (Chromium 141.0.7390.37 / Playwright 1.62.1, real widgets mounted from source, keyboard + real-clipboard paste + insertText + programmatic set): sanitization HOLDS, but 12abc / 1.2.3 / 0x10 never reach the empty-string route the card predicted. The browser FILTERS the keystroke or paste before the change event, so what arrives is a different well-formed number - 12abc gives \"12\", 1.2.3 gives \"1.23\", 0x10 gives \"010\". Residue therefore never reaches these widgets at all, which makes #6715's anchored WHOLE_NUMBER_TEXT a provable no-op here: it would accept every string these boxes can produce and reject only strings the test environment fabricates. It is deliberately NOT copied. The oracle-vs-product disagreement is real and now pinned - six of ten measured cases differ, e.g. 0x10 emits 0 under happy-dom and 10 in Chromium. A user-visible defect WAS reproduced, but not the hypothesised one: typing 1e leaves Chromium visibly DISPLAYING 1e while .value reads \"\", so onChange gets null with aria-invalid=\"false\" and zero diagnostics (screenshot taken); pasting 1e fires no event at all, so the model silently keeps its old value. That is #6716's class and it is ESCALATED rather than fixed - see open_questions. Shipped: comment-only records at the three parseFloat sites (78 inserted lines, 0 deletions, every line a comment), one pin test, one empty-frontmatter changeset.",
"tests": "All heavy runs serialized through scripts/pm/os-verify-lock.sh. Union re-run on the shipping commit 816e3b824, parts joined with && so the wrapper verdict certifies all of them (VERDICT command-exit 0). (1) `pnpm exec vitest run packages/fields/` -> `Test Files 119 passed (119)` / `Tests 1972 passed (1972)`. (2) `pnpm exec vitest run packages/fields/src/__tests__/NumberInputWidgets.environmentDivergence.test.tsx` -> `Tests 16 passed (16)`. (3) packages/fields `tsc --noEmit` AND `tsc -p tsconfig.test.json` -> both clean, empty output; NOT MEASURED on the first attempt, which reported 100+ TS2307 'Cannot find module @object-ui/components' - a fresh-worktree prerequisite failure, cleared by building the closure first with `pnpm --workspace-concurrency=2 --filter \"@object-ui/fields^...\" build`. The test project was confirmed to actually cover the new file: its 3 errors on the first pass named that file by path. (4) check:control-bytes -> 'OK (scanned 5631 tracked text file(s))'. (5) check:vi-mock-specifiers -> 'OK (3953 tracked source file(s))'. (6) check-lint-coverage.mjs -> 'lint coverage: 46/46 packages linted, 0 with outstanding errors (0 total)'. (7) check-changeset-presence.mjs -> '3 source file(s) of 1 released package(s) changed, and this change declares 1 changeset(s)', empty frontmatter accepted as the explicit exemption. (8) check-changeset-no-major.mjs -> 'No changeset declares a major bump'. Every result above is quoted from the gate's own verdict line; exit codes were captured before any pipe. Lint: eslint reports 0 errors on all three changed files (3 pre-existing no-explicit-any warnings on the new test, matching the sibling tests' pattern); the repo gate is per-package `turbo run lint`, and check-lint-coverage is its authority - an ad-hoc root-level `eslint .` shows 76 error files across other packages, which is an artifact of that invocation, not a red. ABLATION, run on the shipping commit to prove the new pin measures the widget rather than restating a constant table: swapped parseFloat for Number in CurrencyField's change handler. Mutation confirmed ON DISK by grep counts before anything was read (parseFloat-form 1->0, Number-form 0->1), never by the editor's exit code. No rebuild needed and none skipped - the test imports '../widgets/CurrencyField', a relative SOURCE import with no dist/ in the resolution path (verified: packages/fields/dist absent at the time). Inner vitest exit 1 (captured directly, not from the wrapper, whose verdict was 0 for the script). Red on exactly the four predicted currency cases: 'expected NaN to be 12', 'expected NaN to be 1.2', 'expected 16 to be +0' (the Number('0x10') reading #6715 rejected on measurement), 'expected NaN to be 1'; percent untouched, as predicted. Restore ran from a `trap ... EXIT INT TERM` using absolute paths and `git checkout HEAD -- \"$REPO_ROOT/...\"`, and was proven by an empty `git diff HEAD` plus the grep count returning to 1 - not by an exit code. One `exit 99` queue-timeout occurred on the closure build; recorded as NOT MEASURED and re-run on the parked slot, never counted as red or green.",
"mcp_calls": "2 - create_pull_request and this comment. All reads went through the zero-quota REST channel (probed 200 on this public repo); gh CLI is absent from this container.",
"open_questions": [
{
"question": "The silent drop is now REPRODUCED, not hypothesised: Chromium visibly displays `1e` in the box while `.value` reads the empty string, so CurrencyField and PercentField emit null with aria-invalid=\"false\" and no diagnostic; pasting `1e` into an empty box fires no change event at all, so the model silently keeps its previous value. #6716 ruled that such a drop was worth announcing for a coordinate. Should it be announced here - and if so, at what scope? Two facts constrain the answer and are why this was not simply implemented. (a) The drop is NOT specific to these two widgets: NumberField and GeolocationField read the box the same way, so this is one ruling about the `type=\"number\"` widget class, not a patch to the two widgets this card happens to name. (b) The truncating rows cannot be refused by ANY widget-side guard: pasting `1.2.3` into a currency field stores 1.23 and pasting `0x10` stores 10, and the information is already gone before handleChange runs - only abandoning `type=\"number\"` recovers it.",
"options": [
"A - Announce on `e.target.validity.badInput` across the whole type=\"number\" widget class (Currency, Percent, Number, Geolocation), reusing #6716's refusalError shape. Small and uses the platform's OWN predicate rather than a renderer-side dialect. Uniquely testable here: badInput is the one signal happy-dom and Chromium were MEASURED to agree on, so the oracle stops lying for exactly this guard. Does not cover the paste-into-empty case in onChange (React fires nothing when the value stays empty), so it needs an onBlur arm too - PercentField has no onBlur today. Leaves the filtering truncation silent.",
"B - Do nothing; document the drop as native type=\"number\" behaviour and close. Zero risk, zero cost, and honest given that no user report exists. Leaves a money field that can display one thing and store another with nothing said.",
"C - Move both widgets to `type=\"text\"` plus their own anchored parsing, exactly LocationField's shape. The only option that refuses BOTH the silent drop and the filtering truncation, because it takes the text before the browser rewrites it. Cost: reverses objectui#2572's deliberate min/max/step affordances, and loses the spinner and the mobile numeric keyboard on every currency and percent field in the product."
],
"recommendation": "A, scoped as ONE card over the four type=\"number\" widgets - not as a patch to the two this card names - and shipped only together with a written note that the filtering truncation stays silent. Four axes. Real business need: the drop is measured and reproducible on money fields, which is real; what is NOT measured is anyone pasting `1.2.3` or `0x10` into a currency box, so C's large cost currently rests on a route with no demand evidence behind it - the same reason #6715's ruling declined degree/hemisphere parsing. Long-term soundness: A asks the platform what it can read instead of inventing a second renderer-side notion of a number, which is contract-first (AGENTS.md #0.1); C is architecturally the soundest end state but reversing a deliberate prior decision needs a maintainer, not a dev. Making AI-written metadata apps hard to get wrong: silence is exactly where wrong values hide, so announcing beats not announcing - but this is also A's one real hazard, and it must be stated rather than glossed, because a widget that warns on `1e` while silently storing 1.23 for `1.2.3` teaches users that no warning means the value is right, which is objectui#6272's own principle pointing back at us. Startup scope discipline: A is a bounded change across four widgets in one package; C is a rewrite of four widgets plus an affordance regression on every numeric field in the product, and should wait for a measured user report."
}
],
"out_of_scope_findings": [
"NOT filed as a separate issue, deliberately: NumberField.tsx and GeolocationField.tsx share the same silent drop (both read the box with `Number(...)` guarded only against the empty string). Filing it separately would split one ruling across two cards, so it is folded into open_questions #1 as the scope argument. PM to file it as its own card if the ruling should be taken separately."
]
}
Reviewed by the domain:ui execution seat (PM session session_01CRJge11jso9TpXRWFt1Z49), round R12. Green on the full table — 30 rows read against total_count: 30, 27 success + 3 skipped, zero in_progress, zero failure.
This card said "Recording, not choosing." The recording is what shipped, and it is complete. The choice it surfaced is filed separately as #6780.
⭐ The premise holds; the predicted consequence does not
This card predicted that sanitization turns residue into an empty string. Measured in Chromium 141.0.7390.37 via Playwright — real widgets mounted from source, keyboard + real-clipboard paste + insertText + programmatic set:
Sanitization holds, but by FILTERING, not by emptying.
input
predicted
actual
12abc
""
"12"
1.2.3
""
"1.23"
0x10
""
"010"
The browser filters the keystroke or paste before the change event, so residue never reaches these widgets at all.
⭐ ⇒ #6715's anchored whole-string guard is a provable no-op here — it would accept every string these boxes can produce and reject only strings the test environment fabricates. It was deliberately not copied. That is the correct outcome of the fence against inventing a second dialect, resolving in the opposite direction from the one the dispatch order expected.
The defect that WAS reproduced — and it is not the hypothesised one
Typing 1e leaves Chromium visibly displaying 1e while .value reads "". onChange receives null, aria-invalid stays "false", nothing is said (screenshot taken). Pasting 1e into an empty box fires no change event at all, so the model silently keeps its previous value.
⇒ #6716's class, on a money field. ⛔ Escalated rather than fixed, on two facts that make it one ruling rather than a patch: NumberField and GeolocationField read the box the same way, and the truncating rows cannot be refused by any widget-side guard because the information is gone before handleChange runs. Filed as #6780, scoped across all four type="number" widgets.
What earns the accept
The oracle-vs-product disagreement is now pinned: 6 of 10 measured cases differ — 0x10 emits 0 under happy-dom and 10 in Chromium. Every existing unit test for these widgets drives a string no browser can deliver, and that is now asserted rather than folklore.
⭐ The ablation proves the pin measures the widget rather than restating a constant table: swapping parseFloat for Number in CurrencyField reddens exactly the four predicted currency cases — including expected 16 to be +0, which is the Number('0x10') reading #6715 rejected on measurement — while percent stays untouched, as predicted. Mutation confirmed on disk by grep counts before anything was read; restore proven by empty git diff HEAD plus the count returning, under a trap with absolute paths.
⭐ It also handled an exit 99 queue timeout correctly: recorded as NOT MEASURED and re-run, never counted as red or green.
Shipped: comment-only records at the three parseFloat sites (78 inserted lines, 0 deletions, every line a comment), one pin test, one empty-frontmatter changeset — declared as releasing nothing, because nothing observable moved.
⭐ A scoping judgement worth endorsing
NumberField / GeolocationField were deliberately not filed as a separate card, on the grounds that doing so would split one ruling across two cards. This seat agrees, and #6780 is scoped to all four accordingly. Splitting a ruling is how two cards end up answering the same question differently.
Closing as completed — and why, given the implementer chose otherwise
Written by the domain:ui execution seat (PM session session_01CRJge11jso9TpXRWFt1Z49).
PR #6777 says Part of #6765, deliberately not Fixes, and the implementer stated its reasoning in the PR body rather than leaving it implied. ⭐ That was the right instinct and I am not overriding it silently — this comment is the record of the disagreement and its resolution.
The reason I am closing it anyway is the implementer's own principle. It declined to file NumberField / GeolocationField as a separate card because "filing it separately would split one ruling across two cards." Leaving this card open alongside #6780 does precisely that: two open cards, one question, and the next reader has to work out which is authoritative.
The division that resolves it:
This card asked to RECORD — its own body says "Recording, not choosing." That shipped: the real-browser measurement, the pinned 6-of-10 oracle divergence, and the comment-only records at the three parseFloat sites. Nothing measured remains undone here.
⇒ This card is complete. #6780 is where the ruling happens, and it is on the maintainer's inbox.
⚠️ Left open deliberately elsewhere: the state this card leaves behind is that the truncating rows (1.2.3 → 1.23, 0x10 → 10) are unrefusable by any widget-side guard and remain silent. That is stated in #6780 as the hazard any "announce" option must ship a written note about — it is not lost by closing this card.
Activity
os-sales commentedon Aug 29, 2026
Claim:
domain:uiexecution seat, PM sessionsession_01CRJge11jso9TpXRWFt1Z49, round R12. Branch:claude/issue-6765-number-input-sanitization.Scope — measure first; the fork resolves on the measurement, not on policy
CurrencyField.tsxandPercentField.tsxhandparseFloat(e.target.value)straight toonChangewith no whole-string guard, leaning entirely on the browser's number-input value-sanitization. The card measured that happy-dom, this package's test environment, does not implement that sanitization — so any test written for these two widgets exercises a code path no browser takes.Step 1 is the missing measurement, and it is the one the card explicitly did not make: real-browser behaviour of both widgets. Chromium and Playwright are available in this container (
PLAYWRIGHT_BROWSERS_PATH=/opt/pw-browsers; ⛔ do not runplaywright install). Measure what a real browser actually hands these two widgets for12abc,1.2.3,0x10, and the empty string.Then the fork is determined:
LocationFieldaccepts a partly-numeric coordinate, emitting a plausible wrong location #6715's defect exactly, and finding(fields):LocationFieldaccepts a partly-numeric coordinate, emitting a plausible wrong location #6715 (PR fix(fields): parse LocationField coordinates as strict whole-string numbers #6766) has just landed the answer:parseFloat's own grammar, anchored, as a whole-string guard. Reuse it; do not invent a second dialect.onChangereceivingnullwith nothing said, that is finding(fields):LocationFieldrefuses input silently — the validation slot it wires has no producer #6716's class, and finding(fields):LocationFieldrefuses input silently — the validation slot it wires has no producer #6716 already decided such a drop was worth announcing for a coordinate. Follow the precedent or say measurably why a currency differs.Fences
packages/fields/src/widgets/CurrencyField.tsxandPercentField.tsxplus their tests.LocationField.tsx— finding(fields):LocationFieldaccepts a partly-numeric coordinate, emitting a plausible wrong location #6715 landed hours ago and its guard is the reference you are reusing, not a file to re-edit.Number()is not the guard. finding(fields):LocationFieldaccepts a partly-numeric coordinate, emitting a plausible wrong location #6715 rejected it on measurement:'0x10'becomes 16 and''becomes 0. If you reach for it, you are re-litigating a decided question.packages/plugin-timeline/,packages/components/src/renderers/,packages/react/src/andapps/console/this round.Escape hatch
The card is honest that it has not reproduced a user-visible defect in either widget. If step 1 shows there is none, stop and report the measurement — recording that the test environment lies, with the browser reading that proves it, is the whole value and is a complete result.
Generated by Claude Code
os-sales commentedon Aug 29, 2026
os-dev-report
{ "issue": 6765, "status": "needs_decision", "branch": "claude/issue-6765-number-input-sanitization", "pr": "https://github.com/objectstack-ai/objectui/pull/6777", "premise_still_valid": true, "summary": "The card's premise holds and its predicted consequence does not. Verified on origin/main b98352a15: happy-dom does not sanitize a type=\"number\" input, so every existing unit test for these two widgets drives a string no browser can deliver. Step 1 measured (Chromium 141.0.7390.37 / Playwright 1.62.1, real widgets mounted from source, keyboard + real-clipboard paste + insertText + programmatic set): sanitization HOLDS, but 12abc / 1.2.3 / 0x10 never reach the empty-string route the card predicted. The browser FILTERS the keystroke or paste before the change event, so what arrives is a different well-formed number - 12abc gives \"12\", 1.2.3 gives \"1.23\", 0x10 gives \"010\". Residue therefore never reaches these widgets at all, which makes #6715's anchored WHOLE_NUMBER_TEXT a provable no-op here: it would accept every string these boxes can produce and reject only strings the test environment fabricates. It is deliberately NOT copied. The oracle-vs-product disagreement is real and now pinned - six of ten measured cases differ, e.g. 0x10 emits 0 under happy-dom and 10 in Chromium. A user-visible defect WAS reproduced, but not the hypothesised one: typing 1e leaves Chromium visibly DISPLAYING 1e while .value reads \"\", so onChange gets null with aria-invalid=\"false\" and zero diagnostics (screenshot taken); pasting 1e fires no event at all, so the model silently keeps its old value. That is #6716's class and it is ESCALATED rather than fixed - see open_questions. Shipped: comment-only records at the three parseFloat sites (78 inserted lines, 0 deletions, every line a comment), one pin test, one empty-frontmatter changeset.", "tests": "All heavy runs serialized through scripts/pm/os-verify-lock.sh. Union re-run on the shipping commit 816e3b824, parts joined with && so the wrapper verdict certifies all of them (VERDICT command-exit 0). (1) `pnpm exec vitest run packages/fields/` -> `Test Files 119 passed (119)` / `Tests 1972 passed (1972)`. (2) `pnpm exec vitest run packages/fields/src/__tests__/NumberInputWidgets.environmentDivergence.test.tsx` -> `Tests 16 passed (16)`. (3) packages/fields `tsc --noEmit` AND `tsc -p tsconfig.test.json` -> both clean, empty output; NOT MEASURED on the first attempt, which reported 100+ TS2307 'Cannot find module @object-ui/components' - a fresh-worktree prerequisite failure, cleared by building the closure first with `pnpm --workspace-concurrency=2 --filter \"@object-ui/fields^...\" build`. The test project was confirmed to actually cover the new file: its 3 errors on the first pass named that file by path. (4) check:control-bytes -> 'OK (scanned 5631 tracked text file(s))'. (5) check:vi-mock-specifiers -> 'OK (3953 tracked source file(s))'. (6) check-lint-coverage.mjs -> 'lint coverage: 46/46 packages linted, 0 with outstanding errors (0 total)'. (7) check-changeset-presence.mjs -> '3 source file(s) of 1 released package(s) changed, and this change declares 1 changeset(s)', empty frontmatter accepted as the explicit exemption. (8) check-changeset-no-major.mjs -> 'No changeset declares a major bump'. Every result above is quoted from the gate's own verdict line; exit codes were captured before any pipe. Lint: eslint reports 0 errors on all three changed files (3 pre-existing no-explicit-any warnings on the new test, matching the sibling tests' pattern); the repo gate is per-package `turbo run lint`, and check-lint-coverage is its authority - an ad-hoc root-level `eslint .` shows 76 error files across other packages, which is an artifact of that invocation, not a red. ABLATION, run on the shipping commit to prove the new pin measures the widget rather than restating a constant table: swapped parseFloat for Number in CurrencyField's change handler. Mutation confirmed ON DISK by grep counts before anything was read (parseFloat-form 1->0, Number-form 0->1), never by the editor's exit code. No rebuild needed and none skipped - the test imports '../widgets/CurrencyField', a relative SOURCE import with no dist/ in the resolution path (verified: packages/fields/dist absent at the time). Inner vitest exit 1 (captured directly, not from the wrapper, whose verdict was 0 for the script). Red on exactly the four predicted currency cases: 'expected NaN to be 12', 'expected NaN to be 1.2', 'expected 16 to be +0' (the Number('0x10') reading #6715 rejected on measurement), 'expected NaN to be 1'; percent untouched, as predicted. Restore ran from a `trap ... EXIT INT TERM` using absolute paths and `git checkout HEAD -- \"$REPO_ROOT/...\"`, and was proven by an empty `git diff HEAD` plus the grep count returning to 1 - not by an exit code. One `exit 99` queue-timeout occurred on the closure build; recorded as NOT MEASURED and re-run on the parked slot, never counted as red or green.", "mcp_calls": "2 - create_pull_request and this comment. All reads went through the zero-quota REST channel (probed 200 on this public repo); gh CLI is absent from this container.", "open_questions": [ { "question": "The silent drop is now REPRODUCED, not hypothesised: Chromium visibly displays `1e` in the box while `.value` reads the empty string, so CurrencyField and PercentField emit null with aria-invalid=\"false\" and no diagnostic; pasting `1e` into an empty box fires no change event at all, so the model silently keeps its previous value. #6716 ruled that such a drop was worth announcing for a coordinate. Should it be announced here - and if so, at what scope? Two facts constrain the answer and are why this was not simply implemented. (a) The drop is NOT specific to these two widgets: NumberField and GeolocationField read the box the same way, so this is one ruling about the `type=\"number\"` widget class, not a patch to the two widgets this card happens to name. (b) The truncating rows cannot be refused by ANY widget-side guard: pasting `1.2.3` into a currency field stores 1.23 and pasting `0x10` stores 10, and the information is already gone before handleChange runs - only abandoning `type=\"number\"` recovers it.", "options": [ "A - Announce on `e.target.validity.badInput` across the whole type=\"number\" widget class (Currency, Percent, Number, Geolocation), reusing #6716's refusalError shape. Small and uses the platform's OWN predicate rather than a renderer-side dialect. Uniquely testable here: badInput is the one signal happy-dom and Chromium were MEASURED to agree on, so the oracle stops lying for exactly this guard. Does not cover the paste-into-empty case in onChange (React fires nothing when the value stays empty), so it needs an onBlur arm too - PercentField has no onBlur today. Leaves the filtering truncation silent.", "B - Do nothing; document the drop as native type=\"number\" behaviour and close. Zero risk, zero cost, and honest given that no user report exists. Leaves a money field that can display one thing and store another with nothing said.", "C - Move both widgets to `type=\"text\"` plus their own anchored parsing, exactly LocationField's shape. The only option that refuses BOTH the silent drop and the filtering truncation, because it takes the text before the browser rewrites it. Cost: reverses objectui#2572's deliberate min/max/step affordances, and loses the spinner and the mobile numeric keyboard on every currency and percent field in the product." ], "recommendation": "A, scoped as ONE card over the four type=\"number\" widgets - not as a patch to the two this card names - and shipped only together with a written note that the filtering truncation stays silent. Four axes. Real business need: the drop is measured and reproducible on money fields, which is real; what is NOT measured is anyone pasting `1.2.3` or `0x10` into a currency box, so C's large cost currently rests on a route with no demand evidence behind it - the same reason #6715's ruling declined degree/hemisphere parsing. Long-term soundness: A asks the platform what it can read instead of inventing a second renderer-side notion of a number, which is contract-first (AGENTS.md #0.1); C is architecturally the soundest end state but reversing a deliberate prior decision needs a maintainer, not a dev. Making AI-written metadata apps hard to get wrong: silence is exactly where wrong values hide, so announcing beats not announcing - but this is also A's one real hazard, and it must be stated rather than glossed, because a widget that warns on `1e` while silently storing 1.23 for `1.2.3` teaches users that no warning means the value is right, which is objectui#6272's own principle pointing back at us. Startup scope discipline: A is a bounded change across four widgets in one package; C is a rewrite of four widgets plus an affordance regression on every numeric field in the product, and should wait for a measured user report." } ], "out_of_scope_findings": [ "NOT filed as a separate issue, deliberately: NumberField.tsx and GeolocationField.tsx share the same silent drop (both read the box with `Number(...)` guarded only against the empty string). Filing it separately would split one ruling across two cards, so it is folded into open_questions #1 as the scope argument. PM to file it as its own card if the ruling should be taken separately." ] }Generated by Claude Code
os-sales commentedon Aug 29, 2026
✅ ACCEPT — PR #6777, merged in
c6732825dReviewed by the
domain:uiexecution seat (PM sessionsession_01CRJge11jso9TpXRWFt1Z49), round R12. Green on the full table — 30 rows read againsttotal_count: 30, 27success+ 3skipped, zeroin_progress, zerofailure.This card said "Recording, not choosing." The recording is what shipped, and it is complete. The choice it surfaced is filed separately as #6780.
⭐ The premise holds; the predicted consequence does not
This card predicted that sanitization turns residue into an empty string. Measured in Chromium 141.0.7390.37 via Playwright — real widgets mounted from source, keyboard + real-clipboard paste +
insertText+ programmatic set:Sanitization holds, but by FILTERING, not by emptying.
12abc"""12"1.2.3"""1.23"0x10"""010"The browser filters the keystroke or paste before the change event, so residue never reaches these widgets at all.
⭐ ⇒ #6715's anchored whole-string guard is a provable no-op here — it would accept every string these boxes can produce and reject only strings the test environment fabricates. It was deliberately not copied. That is the correct outcome of the fence against inventing a second dialect, resolving in the opposite direction from the one the dispatch order expected.
The defect that WAS reproduced — and it is not the hypothesised one
Typing
1eleaves Chromium visibly displaying1ewhile.valuereads"".onChangereceivesnull,aria-invalidstays"false", nothing is said (screenshot taken). Pasting1einto an empty box fires no change event at all, so the model silently keeps its previous value.⇒ #6716's class, on a money field. ⛔ Escalated rather than fixed, on two facts that make it one ruling rather than a patch:
NumberFieldandGeolocationFieldread the box the same way, and the truncating rows cannot be refused by any widget-side guard because the information is gone beforehandleChangeruns. Filed as #6780, scoped across all fourtype="number"widgets.What earns the accept
The oracle-vs-product disagreement is now pinned: 6 of 10 measured cases differ —
0x10emits0under happy-dom and10in Chromium. Every existing unit test for these widgets drives a string no browser can deliver, and that is now asserted rather than folklore.⭐ The ablation proves the pin measures the widget rather than restating a constant table: swapping
parseFloatforNumberinCurrencyFieldreddens exactly the four predicted currency cases — includingexpected 16 to be +0, which is theNumber('0x10')reading #6715 rejected on measurement — while percent stays untouched, as predicted. Mutation confirmed on disk by grep counts before anything was read; restore proven by emptygit diff HEADplus the count returning, under atrapwith absolute paths.⭐ It also handled an
exit 99queue timeout correctly: recorded as NOT MEASURED and re-run, never counted as red or green.Shipped: comment-only records at the three
parseFloatsites (78 inserted lines, 0 deletions, every line a comment), one pin test, one empty-frontmatter changeset — declared as releasing nothing, because nothing observable moved.⭐ A scoping judgement worth endorsing
NumberField/GeolocationFieldwere deliberately not filed as a separate card, on the grounds that doing so would split one ruling across two cards. This seat agrees, and #6780 is scoped to all four accordingly. Splitting a ruling is how two cards end up answering the same question differently.Generated by Claude Code
os-sales commentedon Aug 29, 2026
Closing as completed — and why, given the implementer chose otherwise
Written by the
domain:uiexecution seat (PM sessionsession_01CRJge11jso9TpXRWFt1Z49).PR #6777 says
Part of #6765, deliberately notFixes, and the implementer stated its reasoning in the PR body rather than leaving it implied. ⭐ That was the right instinct and I am not overriding it silently — this comment is the record of the disagreement and its resolution.The reason I am closing it anyway is the implementer's own principle. It declined to file
NumberField/GeolocationFieldas a separate card because "filing it separately would split one ruling across two cards." Leaving this card open alongside #6780 does precisely that: two open cards, one question, and the next reader has to work out which is authoritative.The division that resolves it:
parseFloatsites. Nothing measured remains undone here.type="number"widget can display one value and store another with nothing said — announce it, and at what scope? #6780 asks to CHOOSE — whether atype="number"widget that displays one value and stores another should say so, scoped across all four widgets in the class rather than the two this card names. It carries the measurement, the three options, the four-dimension analysis, and the implementer's recommendation.⇒ This card is complete. #6780 is where the ruling happens, and it is on the maintainer's inbox.
1.2.3→1.23,0x10→10) are unrefusable by any widget-side guard and remain silent. That is stated in #6780 as the hazard any "announce" option must ship a written note about — it is not lost by closing this card.Generated by Claude Code