Skip to content

Commit 8c10f4f

Browse files
fix(types,components): calendar date-value keys cross the JSON/TS boundary once (objectui#10293) (#10302)
Clause-②: yes Closes #10293 Part of #7759 (group D, ruling D1-(iii): comments 5617221514, 5617465269 and 5617614225) ## What changed | face | before | after | |:--|:--|:--| | `form.ts` `CalendarSchema.defaultValue` / `.value` (TS declaration) | `Date \| Date[]` | `Date \| string` | | `form.zod.ts` `CalendarSchema.defaultValue` / `.value` (mirror) | `z.union([z.string(), z.date()])` | unchanged. Only the `.describe()` text now names the ISO 8601 authoring type | | `ui:calendar` renderer (`packages/components/src/renderers/form/calendar.tsx`) | forwards `schema.value \|\| schema.defaultValue` to `DayPicker` untouched | coerces a string through `toDisplayDate` (`@object-ui/core`, the objectui#10183 helper). A `Date` or nothing passes through unchanged | | parity ledger (`zod-mirror-parity.test.ts`) | rows in `KnownDrift`, `WiderThanDeclared` and `WIDER_ARMS` | all three removed. Header figures re-derived by the file's own pins: KnownDrift 46/85 to 45/83, and Wider 19/29/36 to 18/27/32 (CONCRETE 24 to 22, unions 7 to 5), with history sentences | The docs page (`content/docs/components/form/calendar.mdx`) documents the ISO string. ## Per-key judgement (ruling 5617465269 asks for it) - **Does spec declare it?** No. `@objectstack/spec` 17.4.0 has no `ui:calendar` primitive. Its `calendar` hits are all the list-view type. So this is an objectui-owned key, and the read site governs. - **Does the `z.date()` arm stay?** Yes, following in-tree precedent. Every settled date-value key in `form.zod.ts` keeps it: `DatePickerSchema.defaultValue` / `.value` (TS `Date | string`, mirror `string | Date`) and this schema's own `minDate` / `maxDate`. JSON can never carry a `Date`. In-process callers that `safeParse` a `Date` keep working. - **The string arm stays `z.string()`.** No ISO-format refinement was added. No sibling date key has one, and it would narrow the mirror beyond what the ledger measured. This is listed as an open question in the report. ## The read site, measured before editing (base `5ea623eab`) This used a probe render of `ui:calendar` on a frozen clock `2026-09-03T10:35Z`, in forked vitest children: | value | UTC | America/Los_Angeles | |:--|:--|:--| | `new Date(2026, 8, 15)` | selects 15th | selects 15th | | `"2026-09-15"` (value or defaultValue) | selects 15th | **selects 14th** | | `"2026-09-15T12:00:00Z"` | selects 15th | selects 15th | | `"not a date"` | selects nothing | selects nothing | So a string already reached `DayPicker`: date-fns parses it with the engine's own `Date` parse. What was missing was the date-only convention. A date-only string is UTC midnight, read back in local time. After the change, the date-only row selects the 15th in both zones. Every other row is unchanged. ## Pins, and proof that they redden on revert - `packages/types/src/__tests__/calendar-date-value-boundary-10293.test.ts`: type-level equality of both faces with `Date | string | undefined`, plus a `@ts-expect-error` on `Date[]`. At runtime it checks that the mirror accepts date-only, date-time and `Date` values and refuses a list. - `packages/components/src/renderers/form/__tests__/calendar.dateValueZone-10293.test.tsx`: an ISO string selects the same day as the equivalent `Date`. The file has UTC cases, driven west cases (LA, with a rig case and a fixture-validity case) and driven east control cases (Shanghai). It is registered as a fifth pin in `scripts/__tests__/date-only-zone-pins-10183.test.ts`, the forks-pool driver that PR #10266 built. - **Renderer ablation** (committed first, then `ablation-replace.mjs` in WRAP mode, anchor `selected={toSelected(...)}` replaced by the raw read). The anchor went from 1 hit to 0, and the blob went from `6e729e18d454` to `3323137ca363`. The driver went red: `Tests 2 failed | 4 passed (6)`. The calendar pin failed on exactly one case, the west date-only case. Restore: the blob equals HEAD and `git diff HEAD` is empty. - **Declaration ablation** (`value?: Date | string` back to `Date | Date[]`). `tsc -p tsconfig.test.json` exited 2 with 7 errors: 4 in the new pin and 3 in `zod-mirror-parity.test.ts`, where `form.zod.ts#CalendarSchema` became "not assignable to type 'never'" in the ledger assertions. Restore: the blob equals HEAD and `git diff HEAD` is empty. ## Local gates, at `248f23de5` - `pnpm --filter @object-ui/types type-check`: VERDICT command-exit 0. - `vitest run packages/types/` plus the calendar pin plus the zone driver: `Test Files 217 passed (217)`, `Tests 4939 passed | 7 skipped`. The 7 skipped are the driven zone cases in the normal run. The driver itself passed, so they ran in the forked child. - `pnpm --workspace-concurrency=2 --filter '@object-ui/components^...' build` (8 of 47 projects): exit 0. Then `pnpm --filter @object-ui/components type-check`, and `vitest run packages/components/src/renderers/form/`: `62 passed`, `427 passed | 7 skipped`. - `eslint --no-inline-config --format json` on the 7 changed lintable files: 7 files, 0 errors, 15 warnings. All 15 are existing `no-explicit-any` hits (`calendar.tsx` had 3 on `origin/main` and still has 3). Type-aware linting is not configured (`eslint.config.js` has no `parserOptions.project`), so this diff cannot change the verdict for any untouched file. - `check-changeset-presence`, `check:spec-symbols`, `check:new-line-citations` (0 new), `check:doc-types`, `check:doc-fences`, `check:doc-example-ids`, `check:changeset-claims`, `check:pending-changeset-literals`, `check:control-bytes`, `check:component-surface-parity`, `check:test-path-roots`, `check-changeset-no-major`: all exit 0. - NOT MEASURED: `check:doc-snippets` and `check:doc-examples`, which exit 2 with PRECONDITION NOT MET. They need a build of 34 packages. They are left to CI. ## Acceptance notes - ⚠️ **The declaration gives up `Date[]`.** The issue said the declaration was `Date`. On `main` it was `Date | Date[]`. The ruling text and the dispatch both name `string | Date`, so `Date[]` left. The effect is TypeScript-only. The mirror never accepted a list, no in-tree caller passes one, and the renderer still forwards an array unchanged, so `multiple` mode with an in-process `Date[]` still renders. The changeset states this. If the list arm should stay, the fix is to keep `Date[]` in the declaration and keep a `KnownDrift` row for it. The report carries this as an open question. - The calendar does not navigate to a selected date outside the current month: there is no `defaultMonth`. That was true before this change and is unchanged, so the pins freeze the clock inside the month under test. - Two findings on the same node are outside this card. They are reported to the seat for filing, not fixed here: a scalar `value` under `mode: 'multiple'` throws at render, and every value the mirror accepts under `mode: 'range'` selects nothing. - Commit `248f23de5` carries a harness-shaped `Co-Authored-By` trailer that names the model. The os-dev contract asks for model-free trailer pairs. History is not rewritten; this is reported only. Session: `https://claude.ai/code/session_01877XiBYSaRCk2CU7cMSg3S` --- _Generated by [Claude Code](https://claude.ai/code/session_01877XiBYSaRCk2CU7cMSg3S)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 86982ac commit 8c10f4f

9 files changed

Lines changed: 274 additions & 25 deletions

File tree

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
'@object-ui/types': minor
3+
'@object-ui/components': minor
4+
---
5+
6+
`CalendarSchema.defaultValue` / `.value` cross the JSON/TS boundary once (objectui#10293, objectui#7759 ruling D1-(iii)).
7+
8+
- `@object-ui/types`: the declaration is now `Date | string`, the same set the zod mirror accepts. JSON authors an ISO 8601 date string; TypeScript callers may still pass a `Date`. Breaking for TypeScript only: the `Date[]` arm is gone from the declaration, so a `Date[]` passed to a `multiple`-mode calendar no longer type-checks. The mirror never accepted a list, and the renderer still forwards one unchanged at runtime.
9+
- `@object-ui/components`: the `ui:calendar` renderer coerces a string `value` / `defaultValue` to a `Date` at its read site, through `toDisplayDate` from `@object-ui/core`. A date-only string such as `2026-09-15` now selects the 15th in every time zone. Before, it selected the 14th west of UTC. `Date` values and date-time strings select the same day as before.

‎content/docs/components/form/calendar.mdx‎

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,14 @@ The Calendar component allows users to select dates in various modes including s
2222

2323
<SchemaExample id="components-form-calendar/multiple-dates" />
2424

25+
## Selected Date
26+
27+
`value` and `defaultValue` take an ISO 8601 date string in JSON (`"2026-09-15"` or `"2026-09-15T09:00:00Z"`); a TypeScript caller may also pass a `Date`. A date-only string selects the day it names in every time zone.
28+
29+
```json
30+
{ "type": "ui:calendar", "mode": "single", "defaultValue": "2026-09-15" }
31+
```
32+
2533
## Schema
2634

2735
```plaintext
@@ -30,8 +38,8 @@ interface CalendarSchema {
3038
3139
// Selection
3240
mode?: 'single' | 'multiple' | 'range'; // Selection mode (default: 'single')
33-
defaultValue?: Date | Date[]; // Default selected date(s)
34-
value?: Date | Date[]; // Controlled selected date(s)
41+
defaultValue?: Date | string; // Default selected date (ISO 8601 string in JSON)
42+
value?: Date | string; // Controlled selected date (ISO 8601 string in JSON)
3543
3644
// Constraints
3745
minDate?: Date | string; // Minimum selectable date
Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
/**
2+
* ObjectUI
3+
* Copyright (c) 2024-present ObjectStack Inc.
4+
*
5+
* This source code is licensed under the MIT license found in the
6+
* LICENSE file in the root directory of this source tree.
7+
*/
8+
9+
/**
10+
* objectui#10293 — the `ui:calendar` primitive selects the day an ISO string
11+
* names, the same day the equivalent `Date` selects.
12+
*
13+
* ── What was measured ───────────────────────────────────────────────────────
14+
* The renderer handed `schema.value || schema.defaultValue` to `DayPicker`
15+
* untouched. `DayPicker` compares through date-fns, which parses a string
16+
* with the engine's own `Date` parse, so a date-only string was UTC midnight
17+
* read back in local time: `2026-09-15` selected the 14th in
18+
* `America/Los_Angeles` and the 15th in UTC, while `new Date(2026, 8, 15)`
19+
* selected the 15th in both. A date-time string already selected its local
20+
* day. The read site now coerces a string through `toDisplayDate`
21+
* (`@object-ui/core`, the objectui#10183 convention).
22+
*
23+
* ── ⚠️ The zone cases run ONLY when driven, in a FORKS child ────────────────
24+
* `process.env.TZ` written inside a test of the normal run does not move the
25+
* zone (the root config runs `pool: 'threads'`), so the zone cases are
26+
* skipped there and `scripts/__tests__/date-only-zone-pins-10183.test.ts`
27+
* runs them on the forks pool and fails unless every one ran and passed. Each
28+
* zone opens with a rig case, so a child whose zone did not move reds instead
29+
* of going quietly green. The UTC cases below run in the normal run too; they
30+
* cannot tell the repair from its absence, because UTC is the one offset where
31+
* the two parses agree, and they are here for the `Date` / date-time faces.
32+
*
33+
* `Asia/Shanghai` is the control: east of UTC the UTC-midnight parse already
34+
* landed on the named day, and an hour-offset "repair" would break it.
35+
*/
36+
import React from 'react';
37+
import { describe, it, expect, vi, afterEach } from 'vitest';
38+
import { render, cleanup } from '@testing-library/react';
39+
import { ComponentRegistry } from '@object-ui/core';
40+
// Registers the renderers at module scope, NOT inside a hook (objectui#3010).
41+
import '../../../renderers';
42+
43+
const DRIVEN = process.env.OBJECTUI_DATE_ZONE_CHILD === '1';
44+
45+
/** UTC-7 in September — the card's direction. */
46+
const WEST = 'America/Los_Angeles';
47+
/** UTC+8 — the control. */
48+
const EAST = 'Asia/Shanghai';
49+
50+
/** Frozen inside September 2026, so the calendar opens on the month under test. */
51+
const CLOCK = '2026-09-03T10:35:00.000Z';
52+
const DATE_ONLY = '2026-09-15';
53+
/** A fixed instant: 21:00 on the 15th in the west, 12:00 on the 16th in the east. */
54+
const INSTANT = '2026-09-16T04:00:00.000Z';
55+
56+
/** Freeze the clock, moving this forked process into `zone` first when given. */
57+
function enter(zone?: string): void {
58+
if (zone) process.env.TZ = zone;
59+
vi.useFakeTimers({ toFake: ['Date'] });
60+
vi.setSystemTime(new Date(CLOCK));
61+
}
62+
63+
afterEach(() => {
64+
cleanup();
65+
vi.useRealTimers();
66+
});
67+
68+
/** The `data-day` of every selected cell the calendar paints. */
69+
function selectedDays(extra: Record<string, unknown>): string[] {
70+
const Calendar = ComponentRegistry.get('ui:calendar')!;
71+
const { container } = render(<Calendar schema={{ type: 'ui:calendar', mode: 'single', ...extra }} />);
72+
const days = Array.from(container.querySelectorAll('td[data-selected="true"]'))
73+
.map((td) => td.getAttribute('data-day') ?? '');
74+
cleanup();
75+
return days;
76+
}
77+
78+
describe('ui:calendar selected date, in the suite zone (objectui#10293)', () => {
79+
it('a local `Date` selects the day it names (the reference face)', () => {
80+
enter();
81+
expect(selectedDays({ value: new Date(2026, 8, 15) })).toEqual(['2026-09-15']);
82+
});
83+
84+
it('a date-only string selects that same day, through `value` and `defaultValue`', () => {
85+
enter();
86+
expect(selectedDays({ value: DATE_ONLY })).toEqual(['2026-09-15']);
87+
expect(selectedDays({ defaultValue: DATE_ONLY })).toEqual(['2026-09-15']);
88+
});
89+
90+
it('an invalid string still selects nothing, as it did before the coercion', () => {
91+
enter();
92+
expect(selectedDays({ value: 'not a date' })).toEqual([]);
93+
});
94+
});
95+
96+
describe.runIf(DRIVEN)('ui:calendar date-only value west of UTC (objectui#10293)', () => {
97+
it('rig: the zone really moved', () => {
98+
enter(WEST);
99+
expect(Intl.DateTimeFormat().resolvedOptions().timeZone).toBe(WEST);
100+
expect(new Date(INSTANT).getHours()).toBe(21);
101+
});
102+
103+
it('fixture validity: the engine parse of the string lands on the day before here', () => {
104+
enter(WEST);
105+
// Without this the cases below would be green for free.
106+
expect(new Date(DATE_ONLY).getDate()).toBe(14);
107+
});
108+
109+
it('`2026-09-15` selects the 15th, the day `new Date(2026, 8, 15)` selects', () => {
110+
enter(WEST);
111+
expect(selectedDays({ value: new Date(2026, 8, 15) })).toEqual(['2026-09-15']);
112+
expect(selectedDays({ value: DATE_ONLY })).toEqual(['2026-09-15']);
113+
expect(selectedDays({ defaultValue: DATE_ONLY })).toEqual(['2026-09-15']);
114+
});
115+
116+
it('an instant keeps selecting its local day, the 15th here', () => {
117+
enter(WEST);
118+
expect(selectedDays({ value: INSTANT })).toEqual(['2026-09-15']);
119+
});
120+
});
121+
122+
describe.runIf(DRIVEN)('ui:calendar date-only value east of UTC, the control (objectui#10293)', () => {
123+
it('rig: the zone really moved', () => {
124+
enter(EAST);
125+
expect(Intl.DateTimeFormat().resolvedOptions().timeZone).toBe(EAST);
126+
expect(new Date(INSTANT).getHours()).toBe(12);
127+
});
128+
129+
it('`2026-09-15` selects the 15th, as it already did', () => {
130+
enter(EAST);
131+
expect(selectedDays({ value: DATE_ONLY })).toEqual(['2026-09-15']);
132+
});
133+
134+
it('an instant keeps selecting its local day, the 16th here', () => {
135+
enter(EAST);
136+
expect(selectedDays({ value: INSTANT })).toEqual(['2026-09-16']);
137+
});
138+
});

‎packages/components/src/renderers/form/calendar.tsx‎

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,15 +6,27 @@
66
* LICENSE file in the root directory of this source tree.
77
*/
88

9-
import { ComponentRegistry } from '@object-ui/core';
9+
import { ComponentRegistry, toDisplayDate } from '@object-ui/core';
1010
import type { CalendarSchema } from '@object-ui/types';
1111
import { Calendar } from '../../ui';
1212

13+
/**
14+
* The selected-date read (objectui#10293). JSON authors an ISO 8601 string
15+
* and `DayPicker` compares `Date`s, so a string is coerced here, through the
16+
* shared parse step: a date-only string is rebuilt at LOCAL midnight of the
17+
* day it names, where the engine's own parse read it as UTC midnight and
18+
* selected the day before west of UTC (the objectui#10183 convention). Any
19+
* other value — a `Date`, or nothing — reaches `DayPicker` unchanged.
20+
*/
21+
function toSelected(value: unknown): unknown {
22+
return typeof value === 'string' ? toDisplayDate(value) : value;
23+
}
24+
1325
ComponentRegistry.register('calendar',
1426
({ schema, className, ...props }: { schema: CalendarSchema; className?: string; [key: string]: any }) => (
1527
<Calendar
1628
mode={(schema.mode || "single") as any}
17-
selected={(schema.value || schema.defaultValue) as any}
29+
selected={toSelected(schema.value || schema.defaultValue) as any}
1830
className={className}
1931
{...props}
2032
/>
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
/**
2+
* ObjectUI
3+
* Copyright (c) 2024-present ObjectStack Inc.
4+
*
5+
* This source code is licensed under the MIT license found in the
6+
* LICENSE file in the root directory of this source tree.
7+
*/
8+
9+
/**
10+
* objectui#10293 — `CalendarSchema.defaultValue` / `.value` cross the JSON/TS
11+
* boundary once (objectui#7759, ruling D1-(iii)).
12+
*
13+
* - The MIRROR's authoring type is an ISO 8601 date string. Its `z.date()`
14+
* arm stays for in-process callers, the shape every settled date-value key
15+
* in `form.zod.ts` already has (`DatePickerSchema`'s `defaultValue` /
16+
* `value`, and this schema's own `minDate` / `maxDate`).
17+
* - The DECLARATION keeps `Date` and gains the ISO string, so the two faces
18+
* are the same set: `Date | string`. It was `Date | Date[]`, disjoint from
19+
* the mirror on the string arm and wider on the list arm, and ledgered in
20+
* `zod-mirror-parity.test.ts` until this card removed the rows.
21+
* - The `calendar` renderer coerces the string (its own pin sits beside it in
22+
* `packages/components`, `calendar.dateValueZone-10293.test.tsx`).
23+
*
24+
* The type-level half reddens under `tsc -p tsconfig.test.json` (this
25+
* package's `type-check`), not under vitest; the runtime half reddens here.
26+
*/
27+
import { describe, it, expect, expectTypeOf } from 'vitest';
28+
import type { z } from 'zod';
29+
import { CalendarSchema, UiCalendarSchema } from '../zod/form.zod';
30+
import type { CalendarSchema as TsCalendarSchema } from '../form';
31+
32+
type MirrorInput = z.input< typeof CalendarSchema >;
33+
34+
describe('CalendarSchema date-value keys: one face on each side of the boundary (objectui#10293)', () => {
35+
it('the declaration and the mirror admit the same set, `Date | string`', () => {
36+
expectTypeOf< TsCalendarSchema['value'] >().toEqualTypeOf< Date | string | undefined >();
37+
expectTypeOf< TsCalendarSchema['defaultValue'] >().toEqualTypeOf< Date | string | undefined >();
38+
expectTypeOf< MirrorInput['value'] >().toEqualTypeOf< TsCalendarSchema['value'] >();
39+
expectTypeOf< MirrorInput['defaultValue'] >().toEqualTypeOf< TsCalendarSchema['defaultValue'] >();
40+
});
41+
42+
it('a TypeScript author can write the ISO string the mirror accepts', () => {
43+
const node: TsCalendarSchema = { type: 'calendar', value: '2026-09-15', defaultValue: '2026-09-15T09:00:00Z' };
44+
expect(CalendarSchema.safeParse(node).success).toBe(true);
45+
});
46+
47+
it('the mirror accepts an ISO date-only string, an ISO date-time string and a `Date`', () => {
48+
for (const value of ['2026-09-15', '2026-09-15T09:00:00Z', new Date(2026, 8, 15)]) {
49+
expect(CalendarSchema.safeParse({ type: 'calendar', value }).success, String(value)).toBe(true);
50+
expect(CalendarSchema.safeParse({ type: 'calendar', defaultValue: value }).success, String(value)).toBe(true);
51+
expect(UiCalendarSchema.safeParse({ type: 'ui:calendar', value }).success, String(value)).toBe(true);
52+
}
53+
});
54+
55+
it('the mirror refuses a list — the arm the declaration gave up', () => {
56+
const list = ['2026-09-15', '2026-09-17'];
57+
const parsed = CalendarSchema.safeParse({ type: 'calendar', value: list });
58+
expect(parsed.success).toBe(false);
59+
expect(parsed.error?.issues.map((issue) => issue.path.join('.'))).toEqual(['value']);
60+
// @ts-expect-error — `Date[]` left the declaration with objectui#10293.
61+
const listNode: TsCalendarSchema = { type: 'calendar', value: [new Date(2026, 8, 15)] };
62+
expect(listNode.type).toBe('calendar');
63+
});
64+
});

‎packages/types/src/__tests__/zod-mirror-parity.test.ts‎

Lines changed: 13 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -154,7 +154,12 @@
154154
* a delta to this number; count the registry. Nothing asserts it against a written
155155
* one, so this line is prose and can rot; the pin that cannot is the one
156156
* comparing the two halves to each other.
157-
* - **49 entries** in `KnownDrift`, **90 keys** across them — 46 / 85 until
157+
* - **48 entries** in `KnownDrift`, **88 keys** across them — 49 / 90 until
158+
* objectui#10293 settled `form.zod.ts#CalendarSchema`'s `defaultValue` and `value`
159+
* under objectui#7759 D1-(iii): the declaration gained the ISO string the mirror
160+
* accepts and gave up the `Date[]` arm the mirror refused, so both keys measure
161+
* clean and the entry, its two keys and nothing else LEFT — entry count and key
162+
* total moved together, by one and by two. It was 46 / 85 until
158163
* objectui#7759 group E turned the four bare `z.function()` arms into
159164
* `handlerKeyRefusal()` arms. THREE entries are NEW (`TableColumnSchema.cell`,
160165
* `FieldConstraintsSchema.validate`, and `FormFieldSchema.validation`, which
@@ -405,8 +410,12 @@
405410
* spelled "six" rots exactly as fast as one spelled `6`, it is just harder to
406411
* point a regex at. ⛔ Do not spell a live figure out again, and ⛔ do not
407412
* restate one without checking that the pin's spelling still reaches it.
408-
* - **12 entries** in `WiderThanDeclared`, **18 keys** across them, and **22 arms**
409-
* under those keys — split **5** SCHEMA-NODE, **13** CONCRETE, **0** MIXED, **4** unions.
413+
* - **11 entries** in `WiderThanDeclared`, **16 keys** across them, and **18 arms**
414+
* under those keys — split **5** SCHEMA-NODE, **11** CONCRETE, **0** MIXED, **2** unions.
415+
* It read 12 / 18 / 22 — 5 / 13 / 0 / 4 — until objectui#10293 made
416+
* `form.zod.ts#CalendarSchema`'s `defaultValue` and `value` declare the ISO string their
417+
* mirror accepts: the entry and both of its two-arm keys left together, so `arms` fell
418+
* by four where `keys` fell by two and `unions` by two.
410419
* It read 17 / 26 / 30 — 5 / 21 / 0 / 4 — until objectui#7759 group E replaced the
411420
* four bare `z.function()` arms (`renderCellEditor`, `cell`, `validate`, `custom`)
412421
* with `handlerKeyRefusal()`. EIGHT keys left, one CONCRETE arm each, and five whole
@@ -592,7 +601,7 @@
592601
*
593602
* ## KNOWN_DRIFT is a ratchet, not a waiver
594603
*
595-
* 49 of the registered pairs carry TYPE drift TODAY (measured, not assumed). Each is
604+
* 48 of the registered pairs carry TYPE drift TODAY (measured, not assumed). Each is
596605
* pinned to its EXACT drifted key set, so the entry fails when new drift appears on
597606
* that mirror AND when the recorded drift is fixed — a stale entry cannot rot
598607
* quietly. Correcting them is not one change: the pairs below split into DISJOINT
@@ -2033,8 +2042,6 @@ interface KnownDrift {
20332042
* to the DOM `<button>` (`onClick` is on `SDUI_DOM_PASS_THROUGH_KEYS`).
20342043
*/
20352044
'form.zod.ts#ButtonSchema': 'onClick';
2036-
/** DISJOINT: TS `Date | Date[]`, mirror `string | Date`. The mirror refuses `Date[]`; the TS side refuses the ISO string the mirror accepts. (`onChange` is NOT here: the `calendar` renderer spreads it onto `DayPicker`, whose callback is `onSelect`, so nothing reads it — both faces retire it.) */
2037-
'form.zod.ts#CalendarSchema': 'defaultValue' | 'value';
20382045
/** RUNTIME SLOT (objectui#6124): the `checkbox` renderer calls `props.onChange(checked)` after `SchemaRenderer`'s spread. */
20392046
'form.zod.ts#CheckboxSchema': 'onChange';
20402047
/** RUNTIME SLOT (objectui#6124): `plugin-editor` reads `onChange ?? schema.onChange`. */
@@ -3100,8 +3107,6 @@ interface WiderThanDeclared {
31003107
// fourth. ⛔ Do not re-add them for the class labels they carried (F,
31013108
// "inline shape against a named declaration"; D, "disjoint") — a row this
31023109
// ledger records must be a measured widening.
3103-
/** CONCRETE and DISJOINT — the mirror admits a string, the declaration a list of dates; also in `KnownDrift`. */
3104-
'form.zod.ts#CalendarSchema': 'defaultValue' | 'value';
31053110
/**
31063111
* CONCRETE. `layout` is the clearest single instance in this ledger: the mirror
31073112
* is `z.enum(['vertical', 'horizontal', 'grid'])` and the declaration states the
@@ -3280,8 +3285,6 @@ const WIDER_ARMS: Readonly< Record< string, readonly WiderArmClass[] > > = {
32803285
'complex.zod.ts#DashboardComponentSchema::dateRange': ['CONCRETE'],
32813286
'complex.zod.ts#FilterBuilderSchema::fields': ['CONCRETE'],
32823287
'complex.zod.ts#FilterFieldSchema::operators': ['CONCRETE'],
3283-
'form.zod.ts#CalendarSchema::defaultValue': ['CONCRETE', 'CONCRETE'],
3284-
'form.zod.ts#CalendarSchema::value': ['CONCRETE', 'CONCRETE'],
32853288
'form.zod.ts#FormSchema::layout': ['CONCRETE'],
32863289
'form.zod.ts#FormSchema::mode': ['CONCRETE'],
32873290
'layout.zod.ts#ContainerSchema::maxWidth': ['CONCRETE', 'CONCRETE'],

‎packages/types/src/form.ts‎

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1151,13 +1151,17 @@ export interface DatePickerSchema extends BaseSchema {
11511151
export interface CalendarSchema extends BaseSchema {
11521152
type: 'calendar';
11531153
/**
1154-
* Default selected date(s)
1154+
* Default selected date: an ISO 8601 date string (the JSON authoring type)
1155+
* or a `Date` (in-process callers). The `calendar` renderer coerces a string
1156+
* to a `Date` at its read site; a date-only string (`2026-09-15`) selects
1157+
* the day it names in every zone (objectui#10293).
11551158
*/
1156-
defaultValue?: Date | Date[];
1159+
defaultValue?: Date | string;
11571160
/**
1158-
* Controlled selected date(s)
1161+
* Controlled selected date: an ISO 8601 date string or a `Date`, read as
1162+
* {@link CalendarSchema.defaultValue} is.
11591163
*/
1160-
value?: Date | Date[];
1164+
value?: Date | string;
11611165
/**
11621166
* Selection mode
11631167
* @default 'single'

‎packages/types/src/zod/form.zod.ts‎

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -549,8 +549,13 @@ export const DatePickerSchema = BaseSchema.extend({
549549
*/
550550
export const CalendarSchema = BaseSchema.extend({
551551
type: z.literal('calendar'),
552-
defaultValue: z.union([z.string(), z.date()]).optional().describe('Default value'),
553-
value: z.union([z.string(), z.date()]).optional().describe('Controlled value'),
552+
// objectui#10293: the string arm is the JSON authoring type, an ISO 8601
553+
// date string, which the `calendar` renderer coerces to a `Date`. The
554+
// `z.date()` arm stays for in-process callers, as on `DatePickerSchema`.
555+
defaultValue: z.union([z.string(), z.date()]).optional()
556+
.describe('Default selected date, an ISO 8601 date string'),
557+
value: z.union([z.string(), z.date()]).optional()
558+
.describe('Controlled selected date, an ISO 8601 date string'),
554559
mode: z.enum(['single', 'multiple', 'range']).optional().describe('Selection mode'),
555560
minDate: z.union([z.string(), z.date()]).optional().describe('Minimum date'),
556561
maxDate: z.union([z.string(), z.date()]).optional().describe('Maximum date'),

0 commit comments

Comments
 (0)