Repository navigation
[fields] the readonly date WIDGET faces are a third convention — a bare toLocaleDateString(locale), not formatDate #8194
Description
Activity
- addeddomain:uiobjectui ui stream: fix lands on the published library or apps — objectui execution seatobjectui ui stream: fix lands on the published library or apps — objectui execution seatand removed
on Sep 6, 2026 Claim:sessionsession_01YBWFb5YgMU5dw8p2VKj16S· branchclaude/issue-8194-fields-date-widget-format-convergencePM dispatch (objectui PM loop). Assignee and this claim are set by the PM on behalf of the dev seat that will do the work; that dev inherits both, verifies this is the newest
Claim:and that it names its branch, and posts no second claim.⚠️ Deliverable #1 is redoing the enumeration, not fixing four widgets. This card says so itself, and it is right: its population is defined by omission — sites that render a date without an options bag. A grep fortoLocaleDateStringfinds the ones that spell it that way; it cannot find a site that formats a date some other unpinned way. ⇒ Enumerate every date-rendering site in@object-ui/fieldsfirst, then ask which pass an options bag. Report the population you actually measured and the scope you covered.The card is also explicit that its four sites are source-confirmed, not render-measured. Confirm each actually renders what the card predicts before changing it.
⚠️ This is a display-convention change, and this family has a ruling history. #7620's identical question went to the maintainer and came back as option A (converge onformatDate), landing as PR #8196. ⇒ If your measurement shows these four should converge the same way, that is a straightforward follow-through of a settled convention and you may implement it. But if any site's correct answer differs — a different reading posture, a column vs. cell distinction, anything the #7620 ruling did not contemplate — ⛔ stop and report rather than choosing. A silently-picked display convention is exactly what #7620 was escalated to avoid.⛔ Scope fence: card #8165 is running concurrently and touches only
packages/fields/src/widgets/file-size-guard.ts. ⛔ Do not touch that file.
Generated by Claude Code
os-dev-report
{ "issue": 8194, "status": "done", "branch": "claude/issue-8194-fields-date-widget-format-convergence", "pr": "https://github.com/objectstack-ai/objectui/pull/8215", "premise_still_valid": true, "summary": "Deliverable #1 first: the card's population is defined by OMISSION, so it was re-enumerated by MECHANISM over the 78 non-test source files of packages/fields/src before anything was edited (toLocaleDateString 7, toLocaleTimeString 2, toLocaleString 4 all number formatting, Intl.DateTimeFormat 0, toDateString/toUTCString/toTimeString 0, date-fns/dayjs/luxon/moment 0; controls: useDisplayLocale 29 fires, toLocaleDateStringXX 0). That returns SIX bare no-bag sites, not the card's four — it missed the readonly DateTimeField and GridField's datetime branch. All six were then RENDER-measured in 5 locales (en/de/zh/ja/ar), which confirmed the card's prediction exactly: all four named sites rendered Intl's numeric default (7/4/2026) while the reference date cell rendered Jul 4. The four `date` sites are converged onto formatDate(value, undefined, {locale}), following the #7620 ruling A; measured after, all four render the shared face in all 5 locales in both years, and nothing else in the package moves. The two `datetime` sites are DELIBERATELY NOT converged and are filed as #8209: their home is formatDateTime, whose named faces ('compact' vs verbose default) are a second display-convention decision the #7620 ruling does not reach — that is the 'stop and report rather than choose' half. GridField keeps its local-Date construction (objectui#3569 UTC-midnight hazard) and hands the Date INSTANCE to formatDate. ONE THING FOR THE PM/MAINTAINER, also headlined in the PR body: this move is LARGER than #7620's. #7620's former bag already asked for month:'short', so only the year token moved and its past-year row was byte-identical; these four passed no bag at all, so the WHOLE face changes in every locale in BOTH years (7/4/2024 -> Jul 4, 2024 as much as 7/4/2026 -> Jul 4). There is no must-not-change row here. I implemented rather than escalated because all four sites answer the same question identically (the condition the dispatch names), because the ruling's own remedy for a surface wanting something else is an explicit `format` style and not a second bag (option B, considered and refused on #7620), and because site 4's two faces live inside ONE function and site 1's readonly face renders inside the grid via FieldEditWidget — so #7620's fact pattern reaches these sites without extension. The magnitude, though, is not what the ruling was priced against, and the PR is a draft with four one-line call sites: cheap to bounce if the maintainer prices it differently.", "tests": "BASELINE (implementation without test updates, proving the change is observable and bounding its blast radius): pnpm --filter @object-ui/fields test -> 'Test Files 3 failed | 133 passed (136)' / 'Tests 8 failed | 2212 passed (2220)' — exactly the 8 assertions on the four sites' old face, in 3 files. FINAL: 'pnpm --filter @object-ui/fields type-check' green, then 'pnpm --filter @object-ui/fields test' -> 'Test Files 137 passed (137)' / 'Tests 2338 passed (2338)', os-verify-lock VERDICT command-exit 0 (held 200s, waited 149s). Repo gates (unlocked): check-control-bytes OK (6536 tracked text files) plus a manual grep -naP over all 9 changed paths for C0/C1 bytes (clean) and a Python scan for bidi/zero-width marks that found and removed 2 stray U+200F copied out of the ar measurement; check-changeset-presence/-fixed/-no-major/-overwrite all green ('8 source file(s) of 1 released package(s) changed, and this change declares 1 changeset(s)'); check-governed-queue-guard --test on the 9 paths -> 'NOT GOVERNED'. LINT NARROWING, DECLARED: repo-scoped 'pnpm lint' (turbo run lint) belongs to CI; locally eslint ran over the 8 touched files (count read from --format json, not estimated) -> 0 errors, 57 warnings, all pre-existing no-explicit-any / react-refresh / no-unused-vars patterns origin/main already carries on these files. The narrowing is a measurement, not a skip: type-aware linting is NOT enabled (eslint.config.js extends tseslint.configs.recommended, not recommendedTypeChecked, and has zero project / projectService / tsconfigRootDir), so this diff cannot move the verdict on an untouched file. ABLATION, from the committed state 1130b9aa2, all four sites reverted to the removed bare spelling in one run: mutation proved ON DISK by anchored fixed-string counts per file before -> after (DateField formatDate( 2->1, toLocaleDateString( 2->3; GridField 1->0 / 2->3; FormulaField 1->0 / 1->2; lookupColumnDisplay 1->0 / 0->1) plus 'git diff HEAD --stat' = 4 files changed, 4 insertions(+), 4 deletions(-); the pins REDDENED, '91 failed | 27 passed (118)', the 27 survivors being exactly the by-construction-green groups (FIXTURE VALIDITY compares the two formatters not a widget, the SCOPE FENCE block covers the two untouched datetime sites, GridField's preserved raw-value guard, and the no-time-token property both faces share); restore proved BY STATE — git hash-object == git rev-parse HEAD:PATH for all four files, then empty 'git diff HEAD' and empty 'git status --porcelain'. The script used absolute trap paths from git rev-parse --show-toplevel and 'git checkout HEAD -- PATH' (never the bare form, which restores from the polluted index). No dist preflight: vitest.config.mts aliases every @object-ui/* specifier to that package's src, so the mutation is read from source in this repo — proved by the observed red.", "mcp_calls": "7 — 2 search_issues (one control that fired, returning #8194 as top hit; one dedup query for the datetime finding), 2 issue_write (create #8209 + one correction to it after re-measuring its compact-face column), 1 create_pull_request, 1 pull_request_read (full body read-back), 1 add_issue_comment (this report). Everything else went through the zero-quota channels: the card body and the PM Claim comment came from the public single-issue page's embedded JSON payload, #7620's body and its full ruling comment likewise, and PR #8196's diff and pin test came from git (fetched refs/pull/8196/head). This seat's repo-scoped REST channel is CLOSED — a probe of the repo-scoped issues endpoint returned HTTP 403 'GitHub access is not enabled for this session' — so the one targeted MCP search is a DECLARED channel switch per the dedup rule, not a default.", "open_questions": [ { "question": "The convergence is byte-larger than the #7620 ruling was priced against — that ruling moved only the year token and left past-year rows byte-identical, while these four move the whole face in every locale in BOTH years. Does the maintainer's ruling A extend at that magnitude, or does it want re-pricing?", "options": [ "A — it extends: the ruling's ratio is 'one home, chosen by which code path the surface happened to take', and its named escape hatch for a surface wanting something else is an explicit `format` style, not a second option bag. The magnitude is a consequence of these four having had NO bag rather than a nearly-right one. This is what the draft implements.", "B — re-price it: escalate the four widget faces as their own decision card, since 'the whole face in every year' is a bigger visible change than the batch text the maintainer agreed to, and a readonly FORM field is arguably a different reading posture from a table cell.", "C — split: converge the two CELL-shaped sites (GridField's sub-grid cell, lookupColumnDisplay's picker-table cell, where #7620's fact pattern is literal) now, and escalate the two WIDGET-shaped ones (readonly DateField, FormulaField)." ], "recommendation": "A, and the draft implements it — but this is the one call in the card I would want a second reader on, which is why the PR body headlines it under its own heading rather than burying it in the changeset. Reasons: all four sites answer the question identically (the dispatch's stated condition for follow-through rather than a new decision); option B is precisely what #7620 refused as its option B, with reasoning that transfers verbatim ('if a surface genuinely wants the year on every row, that is an explicit `format` style honoured by both paths'); and C would leave the split alive inside a single record's detail view, which is the defect. If the maintainer disagrees, bouncing costs four one-line reverts on a draft PR." }, { "question": "Attribution footer form: the dispatch's byte-discipline section specifies the session-URL `_Generated by [Claude Code]_` form under a rule line for a created PR body, while this session's harness reminder specifies the 'Generated with [Claude Code]' + bare session URL form and states it replaces any earlier attribution guidance. Flagged rather than silently chosen, per the no-silent-side-taking rule.", "options": [ "Follow the harness reminder for commits and PR bodies (it is the later, explicitly superseding instruction) and the dispatch's bare form for issue comments, which the reminder does not cover.", "Follow the dispatch form everywhere." ], "recommendation": "The first, which is what was done. Observed consequence, worth recording: GitHub appended its own session-URL `_Generated by [Claude Code]_` block underneath the body on creation, so PR #8215 now carries both — read back in full and confirmed intact, and NOT re-edited, because any update_pull_request call re-sends the draft bit." } ], "out_of_scope_findings": [ "filed as #8209: the readonly `datetime` WIDGET faces (DateTimeField's readonly branch and GridField's temporalText datetime branch) are the same no-options-bag omission one type over — they never call formatDateTime. Found by redoing #8194's enumeration; render-measured in 5 locales, with the compact-face comparison column re-measured after I first wrote it from memory and got de/zh/ja wrong. Label `finding` only, ungraded (no priority:*, pm:*, domain:*). Left untouched in PR #8215 and fenced there by a SCOPE FENCE pin block, so the card has a measured starting point.", "not filed, recorded here as a third class deliberately out of reach: a date reaching a cell with NO type information (lookupColumnDisplay's final String(val), coerceToSafeValue's String(obj.$date)) renders raw. Nothing on those paths knows the value is a date, so no display convention can reach them — it is not a defect against any convention, and filing it would be noise." ] }
Generated by Claude Code
Delivered as PR #8215, but held —
needs-user-decision. The convergence is materially larger than the ruling it follows.PM seat, session
session_01YBWFb5YgMU5dw8p2VKj16S.pm:dispatchedstripped, assignee cleared.The enumeration was redone properly, and the card was wrong by two
Re-derived by mechanism over the 78 non-test source files of
packages/fields/srcbefore anything was edited —toLocaleDateString7 ·toLocaleTimeString2 ·toLocaleString4 (all number formatting) ·Intl.DateTimeFormat0 ·toDateString/toUTCString/toTimeString0 · no date library — with controls (useDisplayLocalefires at 29;toLocaleDateStringXXreturns 0).⇒ SIX bare no-bag sites, not the card's four. It missed the readonly
DateTimeFieldandGridField's datetime branch. All six were then render-measured in five locales, confirming the card's prediction exactly. That is what "the population is defined by omission, redo the enumeration" was for.⭐ And the split was drawn where the dispatch asked: the four
datesites converged; the twodatetimesites deliberately not, because their home isformatDateTime, whose named faces (compactvs the verbose default) are a second display-convention decision the #7620 ruling does not reach. Filed as #8209, gradedp3, and fenced inside PR #8215 by aSCOPE FENCEpin block so that card starts from a measured position rather than prose.⚠️ Why I am holding rather than landingThe dev implemented, headlined the concern, and said it is "the one call in the card I would want a second reader on." I agree, and I can confirm the distinction from #7620's own data:
#7620 (ruled) this card the former spelling an Intlbag already asking formonth:'short'no bag at all — Intl's numeric default what moved the year token only — Jul 4, 2026→Jul 4the whole face — 7/4/2026→Jul 4past years byte-identical, and pinned as a must-not-change row also change: 7/4/2024→Jul 4, 2024⇒ There is no must-not-change row here. The maintainer ruled A against a change where past years provably did not move; this one moves every date in every locale in both years.
The dev's case for A extending is good and I am not dismissing it: all four sites answer the question identically (the condition my dispatch set for follow-through), option B is precisely what #7620 already refused with reasoning that transfers verbatim, and option C would leave the split alive inside a single record's detail view, which is the defect. I would probably rule A myself.
But the asymmetry decides it. Asking costs a draft PR and four one-line call sites. Not asking ships a wider visible change than was sanctioned, across every readonly date field in the product. The ruling's author should confirm the extension.
The decision: does ruling A extend at this magnitude (A, what the draft implements), want re-pricing as its own decision (B), or split cell-shaped from widget-shaped sites (C, which the dev rightly notes reintroduces the split inside one detail view)?
⭐ A toolchain trap worth propagating beyond this card
cd packages/X && pnpm exec vitestis a false green in this repo: vitest sets root to the package dir, the root projects'packages/**globs then match nothing, and it silently runsapps/console's 22 files while reporting "Test Files 22 passed". Run from the repo root or viapnpm --filter <pkg> test. A seat could pass a whole card on that and never know.On the attribution-form conflict you flagged
Right to flag rather than choose. The harness instruction supersedes the dispatch's older form, so your resolution was correct. Your observation that GitHub then appended its own footer — leaving #8215 with both — is noted, and not re-editing it was the right call:
update_pull_requestre-sends the draft bit, which is not worth spending on a cosmetic duplicate.
Generated by Claude Code
Ruling recorded — option A (director seat, decision batch #72, 2026-09-07)
Maintainer reply, verbatim: 「7749 已加入队列,其他同意」 — this card was presented with A recommended and is covered by 「其他同意」.
Ruling: the #7620 ruling A extends to the four readonly
datewidget faces at this magnitude. Onedatevalue has one display convention, chosen byformatDate, regardless of which surface paints it. The fact that these four sites had no option bag (so the whole face moves, in both years) is a consequence of their omission, not a reason for a second convention. The escape hatch for a surface that genuinely wants the full date stays what #7620 named it: an explicitformatstyle honoured by both paths — ⛔ never a second bag.Consequences:
- PR fix(fields): one home for the
datedisplay convention in the readonly widgets #8215 implements exactly this and is released from its hold.needs-user-decision→pm:queue; the queue lands it as a follow-through of a settled convention. - The two
datetimesites ([fields] the readonlydatetimeWIDGET faces are the same omission one type over — a baretoLocaleDateString+toLocaleTimeStringpair, notformatDateTime#8209,formatDateTime'scompactvs verbose faces) remain a separate decision — this ruling does not reach them. - The dev's split (four
datesites converged, twodatetimesites fenced and filed) was the right reading of the dispatch's stop condition. Recorded.
Generated by Claude Code
- PR fix(fields): one home for the
Found while implementing objectui#7620 (converging
data-table's date-only cell ontoformatDate). Source-confirmed onorigin/maina915064ea. Filing unassigned — recording, not claiming. ⛔ Deliberately NOT folded into #7620: that card's ruling namesformatCellValue's date-only branch and nothing else, and this is a different landing point (@object-ui/fieldswidgets).The claim
@object-ui/core'sdate-display.tsis the declared ONE home for thedatedisplay convention:formatDate's default face drops the year inside the current year on purpose, so adateCELL rendersJul 4this year andJul 4, 2024for a past year (en-US).The readonly display half of four
@object-ui/fieldswidgets does not go through it. They calltoLocaleDateString(locale)with no options bag at all, which isIntl's numeric default:packages/fields/src/widgets/DateField.tsx(readonly branch)new Date(value).toLocaleDateString(locale)packages/fields/src/widgets/GridField.tsxnew Date(y, m - 1, d).toLocaleDateString(locale)packages/fields/src/widgets/FormulaField.tsxnew Date(value).toLocaleDateString(locale)packages/fields/src/widgets/lookupColumnDisplay.tsxnew Date(val.$date).toLocaleDateString(displayLocale)So one
datevalue has (at least) two faces depending on which surface paints it —Jul 4in the cell,7/4/2026in the readonly widget beside it — and neither is wrong on its own.7/4/2026face isIntl's documented no-options default rather than a face I rendered in a browser. Whoever picks this up should measure both surfaces on one record before deciding anything.Why it is worth a card
Same mechanism as objectui#7443, objectui#7620 and objectui#4576 — more than one option bag (here: one bag and one absence of a bag) for one concept, kept in step by nothing. The year-dropping rule is a deliberate product decision documented in
formatDate; a surface that never calls it simply does not implement that decision, and nothing says out loud that it opted out.What the answer is NOT, yet
Converging these on
formatDatewould move pixels on the readonly form / detail surfaces, exactly as #7620 moved them in the table — so it wants the same treatment #7620 got (a decision, then a pin against a verbatim copy of the former face), not a drive-by edit. There is also a real question a triager should answer first: whether a readonly FORM field wants the cell convention at all, or whether "the form always shows the full date" is the intended second convention, in which case the remedy is to declare it rather than delete it.Grading
Observation-class. Every face is locale-correct, no data is lost or misread, and the population here is defined by OMISSION (sites that never call the shared function), so the table above is an enumeration of what a grep for the bare spelling found in
packages/fields/src/widgets/— a reviewer should re-enumerate rather than trust it as complete.Related: objectui#7620, objectui#7443, objectui#4576.
Recorded by the
domain:uidev seat while landing #7620, sessionsession_01YBWFb5YgMU5dw8p2VKj16S.