From 5ec9ee4f244d1fb28e47fea2df9b639660b441ea Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 9 Sep 2026 10:04:58 +0000 Subject: [PATCH 1/4] feat(types): declare ObjectCalendarSchema.colorField and .allDayField (#8466) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `ObjectCalendar.tsx`'s `getCalendarConfig` reads five flat field-name keys off the node and `plugin-calendar/README.md` teaches all five in one sentence, but only three were declared. `colorField` and `allDayField` reached the renderer through `BaseSchema`'s `[key: string]: any` on the TypeScript face and its `.passthrough()` on the zod mirror — admitted, never examined — so a misspelling left the calendar silently colourless while every published gate passed. `colorField` derives from the spec's `CalendarConfig`, the same type the `calendar` block carries, so the flat spelling cannot drift from the block spelling (the objectui#6051 pattern, same key name and mechanism, same file). `allDayField` is objectui-local with no `CalendarConfig` twin, so it is declared as `string`; it is load-bearing in the renderer since objectui#8026. Declaring widens no accept set. Measured on spec 17.3.0, `ComponentPropsMap['object-calendar']` refuses ALL FIVE flat keys with `unrecognized_keys` — including the three that have shipped declared for releases — so the flat face is objectui's own lane, and under an index signature and a `.passthrough()` that already admit any value a declaration only narrows. Neither key joins the registration `inputs`, where the spec refusal WOULD bite. Both faces move together so the zod-mirror-parity ratchet stays at zero drift. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Jmxdo7bmeqCQHLSfmLVX9w --- .../8466-calendar-color-allday-fields.md | 52 +++ packages/plugin-calendar/README.md | 7 +- .../calendar-flat-color-allday-8466.test.ts | 353 ++++++++++++++++++ packages/types/src/objectql.ts | 47 +++ packages/types/src/zod/objectql.zod.ts | 21 ++ 5 files changed, 479 insertions(+), 1 deletion(-) create mode 100644 .changeset/8466-calendar-color-allday-fields.md create mode 100644 packages/types/src/__tests__/calendar-flat-color-allday-8466.test.ts diff --git a/.changeset/8466-calendar-color-allday-fields.md b/.changeset/8466-calendar-color-allday-fields.md new file mode 100644 index 0000000000..c950c9d77d --- /dev/null +++ b/.changeset/8466-calendar-color-allday-fields.md @@ -0,0 +1,52 @@ +--- +'@object-ui/types': minor +--- + +Declare the two flat calendar field-name keys the renderer already reads and the +package README already teaches — `colorField` and `allDayField` on +`ObjectCalendarSchema` (objectstack-ai/objectui#8466). + +`ObjectCalendar.tsx`'s `getCalendarConfig` reads FIVE flat keys off the node, and +`packages/plugin-calendar/README.md` teaches all five in one sentence — "point +`titleField` / `startDateField` / `endDateField` / `allDayField` / `colorField` +at your own fields when they differ." Only three of the five were declared. The +other two reached the renderer through `BaseSchema`'s `[key: string]: any` on the +TypeScript face and its `.passthrough()` on the zod mirror: admitted, never +examined. A misspelling therefore left the calendar silently colourless while +every published gate passed. + +`colorField` is derived from the spec's `CalendarConfig`, the same type the +`calendar` block carries, so the flat spelling cannot drift from the block +spelling — the pattern objectstack-ai/objectui#6051 established for +`ObjectGanttSchema.colorField` on this same file, for the same key name and the +same mechanism. `allDayField` is objectui-local and has no `CalendarConfig` twin +to derive from, so it is declared as `string`; it is load-bearing in the renderer +since objectstack-ai/objectui#8026. + +Declaring `allDayField` widens no accept set, which is why Commandment #0.1 is +not engaged. Measured on `@objectstack/spec` 17.3.0: +`ComponentPropsMap['object-calendar']` refuses ALL FIVE flat keys with +`unrecognized_keys` — `titleField`, `startDateField` and `endDateField` included, +and those three have shipped declared here for releases. The flat face is +objectui's own lane, kept deliberately: the mirror's `.passthrough()` names this +very key as its reason. Under an index signature and a `.passthrough()` that +already admit any value, a declaration cannot widen anything — it only narrows. + +Both members are optional on both faces, so no node that omits them changes +verdict, and nothing that validated before is refused now. What does change +verdict is a WRONG-TYPED value at a correctly spelled key, which the index +signature and `.passthrough()` used to admit unexamined: `colorField: 0xff0000` +and `allDayField: true` — the field-name-versus-value confusion these keys invite +— are now refused at authoring time and through `safeValidateSchema`, the path +the CLI's `validate` / `check` take. That is why this is a `minor` and not a +`patch`. + +Neither key is added to `plugin-calendar`'s registration `inputs`, deliberately: +the forward direction of `apps/console`'s registry/spec parity gate refuses an +`inputs` entry the spec props schema does not accept, and all five flat keys are +refused there. The three sibling keys are absent from `inputs` for the same +reason. + +The `BaseSchema` index-signature ceiling measured by +objectstack-ai/objectui#7927 is unchanged — a MISSPELLED key is still admitted on +both faces, and the accompanying pin asserts that rather than claiming otherwise. diff --git a/packages/plugin-calendar/README.md b/packages/plugin-calendar/README.md index fcc26a3d92..0097191725 100644 --- a/packages/plugin-calendar/README.md +++ b/packages/plugin-calendar/README.md @@ -268,7 +268,10 @@ import type { ObjectCalendarSchema } from '@object-ui/types'; // What this annotation buys, and what it does not - measured, objectui#7925. // It type-checks the VALUES of the declared keys: `defaultView: 'agenda'` and // `titleField: 42` are both compile errors, and `check:doc-snippets` re-runs -// that check on every commit. It does NOT check key NAMES - this interface +// that check on every commit. Since objectui#8466 that cover reaches all five +// flat field-name keys - `allDayField` and `colorField` were reachable only +// through `BaseSchema`'s index signature until then, so this block is also +// what proves they are declared. It does NOT check key NAMES - this interface // extends `BaseSchema`, whose `[key: string]: any` admits any spelling, so a // misspelt key still compiles clean. Read the block as type-checked values, // never as a guarded key set. @@ -278,6 +281,8 @@ const schema: ObjectCalendarSchema = { titleField: 'name', startDateField: 'startDate', endDateField: 'endDate', + allDayField: 'isAllDay', + colorField: 'statusColor', defaultView: 'month' }; ``` diff --git a/packages/types/src/__tests__/calendar-flat-color-allday-8466.test.ts b/packages/types/src/__tests__/calendar-flat-color-allday-8466.test.ts new file mode 100644 index 0000000000..48cd368c3d --- /dev/null +++ b/packages/types/src/__tests__/calendar-flat-color-allday-8466.test.ts @@ -0,0 +1,353 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * objectui#8466 — `ObjectCalendarSchema.colorField` and + * `ObjectCalendarSchema.allDayField` declared, on both faces. + * + * ## The defect + * + * `ObjectCalendar.tsx`'s `getCalendarConfig` reads FIVE flat field-name keys off + * the node, and `plugin-calendar/README.md` teaches all five in one sentence — + * "point `titleField` / `startDateField` / `endDateField` / `allDayField` / + * `colorField` at your own fields when they differ." Only THREE of the five were + * declared. The other two reached the renderer through `BaseSchema`'s + * `[key: string]: any` on the TS side and its `.passthrough()` on the mirror: + * admitted, never examined by either published face. A misspelling therefore + * left the calendar silently colourless while every published gate passed. + * + * ## Why BOTH keys, and the measurement that decided the second one + * + * The two keys look asymmetric and are not. `colorField` IS a spec key — but + * only inside the NESTED `calendar` block (`CalendarConfigSchema`). At the FLAT + * position, which is the position this interface declares, + * `ComponentPropsMap['object-calendar']` refuses `colorField` and `allDayField` + * IDENTICALLY, with the same `unrecognized_keys` diagnostic — and it refuses + * `titleField`, `startDateField` and `endDateField` the same way, all three of + * which have shipped DECLARED here for releases. + * + * So the "declaring `allDayField` widens past the contract" objection, if it + * held, would condemn three shipped members too. It does not hold, and the + * reason is the direction of travel: the flat face is objectui's own lane, taken + * deliberately (`zod/objectql.zod.ts` keeps `.passthrough()` naming this very key + * — "the renderers grow config knobs ahead of the protocol (calendar's + * `allDayField`, for one), and stripping them here would silently disable a + * shipped capability"). Under an index signature and a `.passthrough()` that + * ALREADY admit any value, a declaration cannot widen anything; it only NARROWS, + * by adding value validation where there was none. Commandment #0.1 bans the + * lenient direction, and this is the strict one. + * + * That whole argument rests on measurements, so this file PINS them — the spec's + * five refusals and its accepting controls — rather than restating them in prose + * that could rot when the spec moves. + * + * ## The boundary this card does NOT cross + * + * Neither key is added to `plugin-calendar`'s registration `inputs`, and that is + * load-bearing, not an oversight: the FORWARD direction of + * `apps/console/src/__tests__/registry-inputs-spec-parity.test.ts` refuses an + * `inputs` entry the spec props schema does not accept, so declaring these there + * would redden the merge queue. The three sibling flat keys are absent from + * `inputs` for exactly the same reason. Pinned below. + * + * ## What declaring buys — and what it does NOT, measured not assumed + * + * objectui#7927 measured the ceiling: `BaseSchema` ends in `[key: string]: any`, + * so no annotation here can catch a MISSPELLED key. `colourField` stays admitted + * on both faces, and the control assertions below PIN that, so nobody reads this + * file as claiming more than it does. What the ceiling does not cap is the VALUE + * dimension, and that is the half this file pins — on the TS face through + * `@ts-expect-error` directives that go UNUSED (TS2578, a hard type-check + * failure) the moment their member is deleted, and on the mirror through + * refusals that land ON the key and reach `safeValidateSchema`, the path the + * CLI's `validate` / `check` take. + * + * ## Instruments, borrowed from `kanban-calendar-filter-sort-8174.test.ts` + * + * Membership is asserted on the mirror's OWN `.shape`, never on parse acceptance + * (under `.passthrough()` acceptance cannot tell "declared" from "admitted + * unexamined"). Type-level pins use invariant equality, so a member that fell + * back to the index signature reads as `any` and therefore as a failure. And + * every claim carries a CONTROL asserted to hold the opposite verdict, so no + * assertion can pass vacuously. + */ +import { describe, it, expect } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, join } from 'node:path'; + +import { CalendarConfigSchema, ComponentPropsMap } from '@objectstack/spec/ui'; + +import { ObjectCalendarSchema, safeValidateSchema } from '../zod/index.zod'; +import type { ObjectCalendarSchema as TsObjectCalendarSchema } from '../objectql'; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const REPO_ROOT = join(HERE, '..', '..', '..', '..'); + +const CALENDAR_READER = 'packages/plugin-calendar/src/ObjectCalendar.tsx'; +const CALENDAR_REGISTRATION = 'packages/plugin-calendar/src/index.tsx'; +const CALENDAR_README = 'packages/plugin-calendar/README.md'; + +/** The five flat field-name keys `getCalendarConfig` reads and the README teaches. */ +const FLAT_KEYS = ['titleField', 'startDateField', 'endDateField', 'allDayField', 'colorField'] as const; +/** The three that were already declared — the precedent the two new ones join. */ +const ALREADY_DECLARED = ['titleField', 'startDateField', 'endDateField'] as const; +/** The two this card declares. */ +const NEWLY_DECLARED = ['colorField', 'allDayField'] as const; + +/** + * A key the renderer never reads and neither face declares. Non-vacuity control + * for every "declared" assertion: it must stay `any` on the TS face and out of + * the mirror shape, while still being ADMITTED — the objectui#7927 ceiling, + * pinned rather than claimed away. + */ +const CONTROL_KEY = 'swatchField'; +/** A declared-and-read control for the off-disk read census. */ +const READ_CONTROL_KEY = 'objectName'; +/** The misspelling the ceiling still admits. Pinned, not fixed here. */ +const MISSPELLING = 'colourField'; + +const CALENDAR_NODE = { type: 'object-calendar', objectName: 'event' } as const; + +/* ── Type-level pins (invariant equality, house form) ─────────────────────── */ + +type Equal = + (() => T extends A ? 1 : 2) extends (() => T extends B ? 1 : 2) ? true : false; +type Expect = T; +/** The canonical `any` detector: only `any` absorbs `1 &` down to something `0` extends. */ +type IsAny = 0 extends (1 & T) ? true : false; +/** An object with no keys is assignable to `Pick` only when `K` is optional on `T`. */ +type IsOptional = Record extends Pick ? true : false; + +// `colorField` is DERIVED from the spec's `CalendarConfig`, so it resolves to +// that member's type. Delete the member and the indexed access falls back to +// `[key: string]: any`, making `IsAny` true and this `Equal` false. +export type _ColorFieldIsString = Expect>; +export type _ColorFieldIsNotAny = Expect, false>>; +export type _ColorFieldIsOptional = Expect>; +export type _AllDayFieldIsString = Expect>; +export type _AllDayFieldIsNotAny = Expect, false>>; +export type _AllDayFieldIsOptional = Expect>; +// The control key is undeclared, exactly as the two above were before this card. +// Same instrument, opposite verdict — which is what makes the six lines above +// readings rather than a type that says `true` for everything. +export type _ControlKeyFallsThrough = Expect>; +// objectui#7927's ceiling, pinned rather than claimed away. +export type _MisspellingStillAdmitted = Expect>; + +// The TS face ACCEPTS the documented shape… +const calendarLiteral: TsObjectCalendarSchema = { + ...CALENDAR_NODE, + colorField: 'status_colour', + allDayField: 'is_all_day', +}; + +// …and REFUSES wrong-typed values the index signature used to admit. Each +// directive goes unused — TS2578, a hard failure — if its member is deleted. +// @ts-expect-error — `colorField` names a FIELD, so it is a string, not the colour itself +const calendarBadColorField: TsObjectCalendarSchema = { ...CALENDAR_NODE, colorField: 0xff0000 }; +// @ts-expect-error — `allDayField` names a FIELD; a boolean is the VALUE, the confusion this declaration catches +const calendarBadAllDayField: TsObjectCalendarSchema = { ...CALENDAR_NODE, allDayField: true }; + +/* ── Off-disk derivations ─────────────────────────────────────────────────── */ + +function readRepo(rel: string): string { + return readFileSync(join(REPO_ROOT, rel), 'utf8'); +} + +/** Every `schema.KEY` read in a renderer, off disk. */ +function rendererReads(rel: string): Set { + return new Set([...readRepo(rel).matchAll(/\bschema\.([A-Za-z_$][\w$]*)/g)].map((m) => m[1])); +} + +function shapeKeys(schema: unknown): string[] { + return Object.keys((schema as { shape: Record }).shape); +} + +function specShapeKeys(type: string): string[] { + const entry = (ComponentPropsMap as unknown as Record)[type]; + const def = entry._def; + const shape = typeof def.shape === 'function' ? def.shape() : def.shape; + return Object.keys(shape); +} + +/* ── The reads: the fact the declarations record ──────────────────────────── */ + +describe('objectui#8466 — the renderer reads these keys, which is what the declarations record', () => { + it('`getCalendarConfig` reads all five flat keys off the node', () => { + const src = readRepo(CALENDAR_READER); + for (const key of FLAT_KEYS) { + expect(src, `${CALENDAR_READER} no longer reads the flat ${key}`).toContain(`(schema as any).${key}`); + } + }); + + it('…and `allDayField` is LOAD-BEARING, not merely resolved (objectui#8026)', () => { + // The premise that removed triage's "declaring an inert key would be worse" + // objection. If the renderer ever stops honouring the key, this reddens + // BEFORE anyone trusts the declaration to mean something. + const src = readRepo(CALENDAR_READER); + expect(src).toContain('allDayField'); + // It is in the config memo's dependency list, which is what makes an + // authored change reach the screen. + expect(src).toMatch(/useMemo\(\(\) => getCalendarConfig\(schema\), \[[\s\S]*?allDayField[\s\S]*?\]\)/); + }); + + it('the reads census returns a firing control, so the verdicts above are readings', () => { + const reads = rendererReads(CALENDAR_READER); + expect(reads.has(READ_CONTROL_KEY)).toBe(true); + expect(reads.has(CONTROL_KEY)).toBe(false); + }); + + it('the README still teaches all five in one sentence, which is what makes them authorable', () => { + // The card's second half: the published prose. If this sentence is ever + // rewritten, the declaration set it justifies has to be revisited. + const readme = readRepo(CALENDAR_README); + for (const key of FLAT_KEYS) expect(readme).toContain(`\`${key}\``); + expect(readme).toContain('at your own\nfields when they differ.'); + }); +}); + +/* ── The spec face: the measurement that decided `allDayField` ────────────── */ + +describe('objectui#8466 — the spec refuses ALL FIVE flat keys, which is why declaring widens nothing', () => { + it('`ComponentPropsMap["object-calendar"]` declares none of the five at top level', () => { + const declared = specShapeKeys('object-calendar'); + for (const key of FLAT_KEYS) { + expect(declared, `spec now declares the flat ${key}; revisit this card's reasoning`).not.toContain(key); + } + // Non-vacuity: the same extraction returns the keys the spec certainly does + // declare at this position, so the five absences are readings. + expect(declared).toContain(READ_CONTROL_KEY); + expect(declared).toContain('calendar'); + expect(declared).toContain('defaultView'); + }); + + it('…and refuses each of the five IDENTICALLY, with `unrecognized_keys`', () => { + // This is the measurement that resolves the `colorField` / `allDayField` + // asymmetry: at the FLAT position there is none. The three already-shipped + // members are refused by exactly the same diagnostic as the two new ones. + const oc = (ComponentPropsMap as unknown as Record)['object-calendar']; + for (const key of FLAT_KEYS) { + const r = oc.safeParse({ [key]: 'x' }); + expect(r.success, `spec now accepts the flat ${key}`).toBe(false); + expect(r.error.issues.map((i: { code: string }) => i.code)).toContain('unrecognized_keys'); + } + // Both controls fire: keys the spec DOES declare parse green here. + expect(oc.safeParse({ [READ_CONTROL_KEY]: 'event' }).success).toBe(true); + expect(oc.safeParse({ locale: 'en-GB' }).success).toBe(true); + }); + + it('`colorField` IS a spec key — but only in the NESTED block, which is a different position', () => { + // The distinction the whole decision turns on. `CalendarConfigSchema` is a + // strictObject of four keys: it HAS `colorField` and refuses `allDayField` + // by name. That asymmetry is real nested, and absent flat. + expect(Object.keys(CalendarConfigSchema.shape)).toEqual([ + 'startDateField', + 'endDateField', + 'titleField', + 'colorField', + ]); + const nested = CalendarConfigSchema.safeParse({ startDateField: 's', allDayField: 'x' }); + expect(nested.success).toBe(false); + if (!nested.success) { + expect(nested.error.issues.map((i) => i.code)).toContain('unrecognized_keys'); + } + // Firing control: the same parse with a declared member is green. + expect(CalendarConfigSchema.safeParse({ startDateField: 's', colorField: 'c' }).success).toBe(true); + }); +}); + +/* ── The boundary: `inputs` stays clear of the flat face ──────────────────── */ + +describe('objectui#8466 — the registration `inputs` deliberately declares NO flat key', () => { + it('none of the five is an `inputs` entry, so the parity gate stays green', () => { + // `apps/console/src/__tests__/registry-inputs-spec-parity.test.ts` FORWARD + // direction: a block may not declare a top-level input the spec refuses. + // All five are refused, so all five must stay out of `inputs`. + const src = readRepo(CALENDAR_REGISTRATION); + for (const key of FLAT_KEYS) { + expect(src, `${key} became an inputs entry; the parity gate's forward direction will refuse it`) + .not.toMatch(new RegExp(`name:\\s*'${key}'`)); + } + // Firing control: keys that ARE inputs entries are found by the same regex. + expect(src).toMatch(/name:\s*'objectName'/); + expect(src).toMatch(/name:\s*'defaultView'/); + }); +}); + +/* ── The zod mirror ───────────────────────────────────────────────────────── */ + +describe('objectui#8466 — the mirror declares what the interface declares', () => { + it('membership, read off the mirror shape (acceptance cannot tell declared from admitted)', () => { + const keys = shapeKeys(ObjectCalendarSchema); + for (const key of [...ALREADY_DECLARED, ...NEWLY_DECLARED]) expect(keys).toContain(key); + // The control stays out, which keeps the assertions above from being + // satisfied by a shape that simply contains everything. + expect(keys).not.toContain(CONTROL_KEY); + expect(keys).not.toContain(MISSPELLING); + }); + + it('accepts the documented shape, and the values SURVIVE the parse', () => { + const node = { ...CALENDAR_NODE, colorField: 'status_colour', allDayField: 'is_all_day' }; + const r = ObjectCalendarSchema.safeParse(node); + expect(r.success, JSON.stringify(r.error?.issues)).toBe(true); + if (r.success) { + expect((r.data as Record).colorField).toBe('status_colour'); + expect((r.data as Record).allDayField).toBe('is_all_day'); + } + // …and through the published union entry point, so the right arm is reached. + expect(safeValidateSchema(node).success).toBe(true); + }); + + it('both members stay OPTIONAL: the node without them parses green — this adds no requiredness', () => { + expect(ObjectCalendarSchema.safeParse(CALENDAR_NODE).success).toBe(true); + expect(safeValidateSchema(CALENDAR_NODE).success).toBe(true); + }); + + it.each([ + ['colorField', 0xff0000], + ['colorField', { hex: '#ff0000' }], + // The confusion the declaration catches: the FLAG rather than the FIELD NAME. + ['allDayField', true], + ['allDayField', ['is_all_day']], + ] as const)('refuses a wrong-typed `%s` (%j) AT the key — the verdict declaring MOVES', (key, value) => { + // Before this card every one of these rode `.passthrough()` unexamined. + const r = ObjectCalendarSchema.safeParse({ ...CALENDAR_NODE, [key]: value }); + expect(r.success).toBe(false); + if (!r.success) { + // The refusal must land ON the key, not merely somewhere in the node — + // which is what distinguishes value validation from an unrelated refusal. + const paths = r.error.issues.map((i) => (i.path ?? []).join('.')); + expect(paths, JSON.stringify(paths)).toContain(key); + } + // …and the same refusal through the path the CLI's `validate` / `check` reach. + expect(safeValidateSchema({ ...CALENDAR_NODE, [key]: value }).success).toBe(false); + }); + + it('the objectui#7927 ceiling is UNCHANGED: a misspelled key is still admitted', () => { + // This card buys value validation, not misspelling detection. Pinning the + // ceiling is what stops the change being read as more than it is — and + // turns red if `.passthrough()` is ever tightened, which would be #7927's + // job and would need this file revisited. + expect(ObjectCalendarSchema.safeParse({ ...CALENDAR_NODE, [MISSPELLING]: 'x' }).success).toBe(true); + expect(ObjectCalendarSchema.safeParse({ ...CALENDAR_NODE, [CONTROL_KEY]: 'x' }).success).toBe(true); + // Even a wrong-TYPED misspelling rides through, which is the sharp edge: + // the value validation above reaches only the spelling that is declared. + expect(ObjectCalendarSchema.safeParse({ ...CALENDAR_NODE, [MISSPELLING]: true }).success).toBe(true); + }); +}); + +/* ── Keep the type-level consts referenced (they are the pins) ─────────────── */ + +describe('objectui#8466 — the TS face accepts the documented node', () => { + it('the accepted literal carries the values it was authored with', () => { + expect(calendarLiteral.colorField).toBe('status_colour'); + expect(calendarLiteral.allDayField).toBe('is_all_day'); + // The refused literals exist only so their `@ts-expect-error` directives do; + // referencing them keeps `noUnusedLocals` off this file's back. + expect([calendarBadColorField, calendarBadAllDayField]).toHaveLength(2); + }); +}); diff --git a/packages/types/src/objectql.ts b/packages/types/src/objectql.ts index d260d8ad5e..9ce82712bc 100644 --- a/packages/types/src/objectql.ts +++ b/packages/types/src/objectql.ts @@ -98,6 +98,7 @@ import type { TimelineConfig, NavigationConfig, GanttConfig as SpecGanttConfig, + CalendarConfig as SpecCalendarConfig, } from '@objectstack/spec/ui'; /** @@ -2765,6 +2766,52 @@ export interface ObjectCalendarSchema extends BaseSchema { endDateField?: string; /** Field for event title */ titleField?: string; + /** + * Record field carrying the event's colour — any CSS colour or a semantic + * palette name, typically a server-computed status colour. Resolved PER + * RECORD by `plugin-calendar/src/ObjectCalendar.tsx`, which falls back to the + * record's own `color` value and then to the platform default, so an authored + * value that never arrives is invisible rather than loud. + * + * DERIVED from {@link CalendarConfig}, the same type the `calendar` block + * carries, so the flat spelling cannot drift from the block spelling. That is + * verbatim the pattern objectui#6051 established for + * {@link ObjectGanttSchema.colorField} — the same key name, the same + * mechanism, on this same file — and the reason this member is an indexed + * access rather than a fresh `string`. + * + * Undeclared here until objectui#8466, so an authored value reached the + * renderer only through {@link BaseSchema}'s `[key: string]: any` — admitted, + * never examined — while `plugin-calendar/README.md` taught it as authorable. + */ + colorField?: SpecCalendarConfig['colorField']; + /** + * Record field carrying the all-day flag. LOAD-BEARING since objectui#8026: + * the events pass in `plugin-calendar/src/ObjectCalendar.tsx` reads it and a + * change to the authored key genuinely changes what is drawn. It is also in + * that component's `getCalendarConfig` memo dependency list, which is what + * makes the change reach the screen. + * + * objectui-LOCAL, and the one member here with no {@link CalendarConfig} twin + * to derive from: `@objectstack/spec`'s `CalendarConfigSchema` is a + * `strictObject` of four keys and refuses this one BY NAME with an + * `unrecognized_keys` diagnostic. That is the class this package's mirror + * already names out loud, where `.passthrough()` is kept explicitly for this + * key — "the renderers grow config knobs ahead of the protocol (calendar's + * `allDayField`, for one), and stripping them here would silently disable a + * shipped capability" (`zod/objectql.zod.ts`). + * + * ⛔ Declaring it widens NO accept set, which is why Commandment #0.1 is not + * engaged. Measured on spec 17.3.0: `ComponentPropsMap['object-calendar']` + * refuses ALL FIVE flat field-name keys with `unrecognized_keys` — including + * {@link ObjectCalendarSchema.titleField}, + * {@link ObjectCalendarSchema.startDateField} and + * {@link ObjectCalendarSchema.endDateField} above, which have shipped + * DECLARED for releases. The flat face is objectui's own lane, taken whole; + * this key is its fifth member, not a new dialect. And under `BaseSchema`'s + * index signature the value was already `any`, so declaring only NARROWS. + */ + allDayField?: string; /** * Default view mode — the renderer's rendered set. `'agenda'` was retired * (objectui#5784, following #5740): `CalendarView` renders no agenda view, diff --git a/packages/types/src/zod/objectql.zod.ts b/packages/types/src/zod/objectql.zod.ts index 4db28ef62a..e4a7f8f901 100644 --- a/packages/types/src/zod/objectql.zod.ts +++ b/packages/types/src/zod/objectql.zod.ts @@ -1078,6 +1078,27 @@ export const ObjectCalendarSchema = BaseSchema.extend({ startDateField: z.string().optional().describe('Start date field'), endDateField: z.string().optional().describe('End date field'), titleField: z.string().optional().describe('Title field'), + // objectui#8466 — the last two members of the FLAT field-name face, which + // `ObjectCalendar.tsx`'s `getCalendarConfig` reads bare off the node and + // which `plugin-calendar/README.md` teaches as authorable. Neither published + // face of this package named them: they rode `BaseSchema`'s `[key: string]: + // any` on the TS side and its `.passthrough()` here — admitted, never + // examined, so a misspelling left the calendar silently colourless while + // every published gate passed. + // + // Mirrored at the SAME requiredness as `../objectql.ts` (both optional) so + // the zod-mirror-parity ratchet stays at zero drift for this pair, exactly as + // the `filter`/`sort` pair above. + // + // ⛔ Neither key is added to `plugin-calendar`'s registration `inputs`, and + // that asymmetry is deliberate: `ComponentPropsMap['object-calendar']` + // refuses all five flat keys with `unrecognized_keys`, so declaring them + // THERE would redden the FORWARD direction of + // `apps/console/src/__tests__/registry-inputs-spec-parity.test.ts`. The flat + // face is objectui's own lane — `titleField`/`startDateField`/`endDateField` + // have shipped declared here, and absent from `inputs`, for releases. + colorField: z.string().optional().describe('Field carrying the per-record event colour — a CSS colour or a semantic palette name'), + allDayField: z.string().optional().describe("Field carrying the all-day flag — objectui-local (the spec's CalendarConfigSchema refuses it by name); LOAD-BEARING since objectui#8026"), defaultView: z.enum(['month', 'week', 'day']).optional().describe("Default view — 'month' | 'week' | 'day', the renderer's rendered set ('agenda' was retired: objectui#5784)"), // objectui#8174 — the two query keys `ObjectCalendar.tsx` lowers onto its own // `dataSource.find` (`$filter: schema.filter`, From 697292ebf52b87e8038d70bc5129ae740a6a8ffb Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 9 Sep 2026 10:24:19 +0000 Subject: [PATCH 2/4] test(types): pin the sibling calendar-view precedent for the flat key set (#8466) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `CalendarViewSchema` — the `calendar-view` element drawn by the SAME renderer, since `plugin-calendar` registers `ObjectCalendarRenderer` under both type names — has shipped all five flat field-name keys declared, `allDayField` included. That makes `ObjectCalendarSchema` the odd one out rather than the pioneer, and is the tightest available answer to "does declaring `allDayField` widen past the contract". Pinned so the two interfaces cannot fork again, with a control key that returns the opposite verdict through the same instrument. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Jmxdo7bmeqCQHLSfmLVX9w --- .../8466-calendar-color-allday-fields.md | 7 +++ .../calendar-flat-color-allday-8466.test.ts | 45 +++++++++++++++++++ packages/types/src/objectql.ts | 6 +++ 3 files changed, 58 insertions(+) diff --git a/.changeset/8466-calendar-color-allday-fields.md b/.changeset/8466-calendar-color-allday-fields.md index c950c9d77d..7a247181bb 100644 --- a/.changeset/8466-calendar-color-allday-fields.md +++ b/.changeset/8466-calendar-color-allday-fields.md @@ -32,6 +32,13 @@ objectui's own lane, kept deliberately: the mirror's `.passthrough()` names this very key as its reason. Under an index signature and a `.passthrough()` that already admit any value, a declaration cannot widen anything — it only narrows. +Nor is `allDayField` a new precedent here: `CalendarViewSchema` — the sibling +`calendar-view` element, drawn by the SAME renderer, since `plugin-calendar` +registers `ObjectCalendarRenderer` under both type names — has shipped all five +of these keys declared, `allDayField` included. `ObjectCalendarSchema` was the +odd one out, and the accompanying pin keeps the two interfaces from forking +again. + Both members are optional on both faces, so no node that omits them changes verdict, and nothing that validated before is refused now. What does change verdict is a WRONG-TYPED value at a correctly spelled key, which the index diff --git a/packages/types/src/__tests__/calendar-flat-color-allday-8466.test.ts b/packages/types/src/__tests__/calendar-flat-color-allday-8466.test.ts index 48cd368c3d..6736c54318 100644 --- a/packages/types/src/__tests__/calendar-flat-color-allday-8466.test.ts +++ b/packages/types/src/__tests__/calendar-flat-color-allday-8466.test.ts @@ -83,6 +83,7 @@ import { CalendarConfigSchema, ComponentPropsMap } from '@objectstack/spec/ui'; import { ObjectCalendarSchema, safeValidateSchema } from '../zod/index.zod'; import type { ObjectCalendarSchema as TsObjectCalendarSchema } from '../objectql'; +import type { CalendarViewSchema as TsCalendarViewSchema } from '../complex'; const HERE = dirname(fileURLToPath(import.meta.url)); const REPO_ROOT = join(HERE, '..', '..', '..', '..'); @@ -138,6 +139,21 @@ export type _ControlKeyFallsThrough = Expect>; +// The SIBLING element, drawn by the same renderer, already declares all five. +// A member that fell back to the index signature reads as `any` and fails these, +// so these five lines are what would catch the two interfaces forking again. +type Declared = Equal, false>; +const siblingPins: [ + Expect>, + Expect>, + Expect>, + Expect>, + Expect>, +] = [true, true, true, true, true]; +// Control: the same instrument returns the OPPOSITE verdict for a key the +// sibling does not declare either, so the five above are readings. +export type _SiblingControlFallsThrough = Expect>; + // The TS face ACCEPTS the documented shape… const calendarLiteral: TsObjectCalendarSchema = { ...CALENDAR_NODE, @@ -260,6 +276,35 @@ describe('objectui#8466 — the spec refuses ALL FIVE flat keys, which is why de }); }); +/* ── The sibling element: one renderer, two interfaces, one flat vocabulary ── */ + +describe('objectui#8466 — `calendar-view` already declared all five, and the two must not fork', () => { + it('ONE renderer serves both `object-calendar` and `calendar`', () => { + // `ObjectCalendarRenderer` is registered twice. That is what makes a fork + // between the two interfaces a real defect rather than a tidiness point: + // the same `getCalendarConfig` reads the same five keys off both. + const src = readRepo(CALENDAR_REGISTRATION); + expect(src).toContain("ComponentRegistry.register('object-calendar', ObjectCalendarRenderer"); + expect(src).toContain("ComponentRegistry.register('calendar', ObjectCalendarRenderer"); + }); + + it('the sibling `CalendarViewSchema` declares all five — including the two this card adds', () => { + // Measured, and the reason declaring `allDayField` is not a new precedent: + // this package has ALREADY shipped it declared on a published interface, on + // the element the same renderer draws. `ObjectCalendarSchema` was the odd + // one out, not the pioneer. + const declaredOnSibling: Record<(typeof FLAT_KEYS)[number], true> = { + titleField: true, + startDateField: true, + endDateField: true, + allDayField: true, + colorField: true, + }; + expect(Object.keys(declaredOnSibling).sort()).toEqual([...FLAT_KEYS].sort()); + expect(siblingPins).toHaveLength(5); + }); +}); + /* ── The boundary: `inputs` stays clear of the flat face ──────────────────── */ describe('objectui#8466 — the registration `inputs` deliberately declares NO flat key', () => { diff --git a/packages/types/src/objectql.ts b/packages/types/src/objectql.ts index 9ce82712bc..5a3a11c0cc 100644 --- a/packages/types/src/objectql.ts +++ b/packages/types/src/objectql.ts @@ -2810,6 +2810,12 @@ export interface ObjectCalendarSchema extends BaseSchema { * DECLARED for releases. The flat face is objectui's own lane, taken whole; * this key is its fifth member, not a new dialect. And under `BaseSchema`'s * index signature the value was already `any`, so declaring only NARROWS. + * + * ⭐ Nor is it a new precedent: {@link CalendarViewSchema} — the sibling + * `calendar-view` element, drawn by the SAME renderer (`plugin-calendar` + * registers `ObjectCalendarRenderer` under both type names) — has shipped all + * five of these keys declared, `allDayField` included. This interface was the + * odd one out, not the pioneer, and the pin keeps the two from forking. */ allDayField?: string; /** From 2e5359b72c233de15b7ec3bb152919b5c1235f95 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 9 Sep 2026 11:05:11 +0000 Subject: [PATCH 3/4] docs(types): the sibling calendar interface has its OWN renderer (objectui#8466) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wording only — no assertion, declaration, mirror member, pin or changeset grade moves. Three prose sites claimed `calendar-view` was "drawn by the SAME renderer" as `object-calendar`. Measured, that is false: `plugin-calendar` registers `ObjectCalendarRenderer` under `object-calendar` (index.tsx:409) and `calendar` (:416) only, while `calendar-view` has its own inline renderer (calendar-view-renderer.tsx:224) that imports neither `ObjectCalendarRenderer` nor `getCalendarConfig` and reads `schema.titleField` / `.startDateField` / `.endDateField` / `.colorField` / `.allDayField` itself (:277-285). What survives is the precedent itself, now stated accurately: a sibling calendar interface in the same plugin, whose renderer reads the same five flat keys, has shipped all five declared on both faces. Two interfaces, two renderers, ONE flat vocabulary. The pin's own assertion — "ONE renderer serves both `object-calendar` and `calendar`" — is literally true and is untouched; only the comment around it overreached. Also records why the two `Record` casts in the pin file stay: `_def` is a zod internal the spec publishes no type for, so a hand-written shape would be an unverified assertion about a third-party runtime, and `eslint.config.js` sets `reportUnusedDisableDirectives: 'error'`, which makes a left-behind disable directive a hard error rather than a silencer. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Jmxdo7bmeqCQHLSfmLVX9w --- .../8466-calendar-color-allday-fields.md | 12 ++--- .../calendar-flat-color-allday-8466.test.ts | 44 ++++++++++++++----- packages/types/src/objectql.ts | 12 ++--- 3 files changed, 47 insertions(+), 21 deletions(-) diff --git a/.changeset/8466-calendar-color-allday-fields.md b/.changeset/8466-calendar-color-allday-fields.md index 7a247181bb..dda117b34f 100644 --- a/.changeset/8466-calendar-color-allday-fields.md +++ b/.changeset/8466-calendar-color-allday-fields.md @@ -32,12 +32,12 @@ objectui's own lane, kept deliberately: the mirror's `.passthrough()` names this very key as its reason. Under an index signature and a `.passthrough()` that already admit any value, a declaration cannot widen anything — it only narrows. -Nor is `allDayField` a new precedent here: `CalendarViewSchema` — the sibling -`calendar-view` element, drawn by the SAME renderer, since `plugin-calendar` -registers `ObjectCalendarRenderer` under both type names — has shipped all five -of these keys declared, `allDayField` included. `ObjectCalendarSchema` was the -odd one out, and the accompanying pin keeps the two interfaces from forking -again. +Nor is `allDayField` a new precedent here: `CalendarViewSchema` — a sibling +calendar interface in the same plugin, whose renderer reads the same five flat +keys — has shipped all five of these keys declared, `allDayField` included, on +both faces. Two interfaces, two renderers, ONE flat vocabulary: +`ObjectCalendarSchema` was the odd one out, and the accompanying pin keeps that +shared vocabulary from drifting apart. Both members are optional on both faces, so no node that omits them changes verdict, and nothing that validated before is refused now. What does change diff --git a/packages/types/src/__tests__/calendar-flat-color-allday-8466.test.ts b/packages/types/src/__tests__/calendar-flat-color-allday-8466.test.ts index 6736c54318..2dce231dac 100644 --- a/packages/types/src/__tests__/calendar-flat-color-allday-8466.test.ts +++ b/packages/types/src/__tests__/calendar-flat-color-allday-8466.test.ts @@ -139,9 +139,10 @@ export type _ControlKeyFallsThrough = Expect>; -// The SIBLING element, drawn by the same renderer, already declares all five. -// A member that fell back to the index signature reads as `any` and fails these, -// so these five lines are what would catch the two interfaces forking again. +// The SIBLING interface in the same plugin — served by its OWN renderer, which +// reads the same five flat keys — already declares all five. A member that fell +// back to the index signature reads as `any` and fails these, so these five +// lines are what would catch the shared flat vocabulary drifting apart. type Declared = Equal, false>; const siblingPins: [ Expect>, @@ -183,6 +184,24 @@ function shapeKeys(schema: unknown): string[] { return Object.keys((schema as { shape: Record }).shape); } +/** + * ⚠️ `ComponentPropsMap` entries are read through `Record` on + * purpose — here, and again at the `unrecognized_keys` census below. Both raise + * an `@typescript-eslint/no-explicit-any` WARNING, never an error (eslint over + * this file: exit 0, 0 errors, exactly these 2 warnings), and both are KEPT. + * `_def` is a zod INTERNAL for which the spec publishes no type, so a + * hand-written shape for it would be a local ASSERTION about a third-party + * runtime that nothing re-checks — it would go stale in silence, which is the + * exact failure mode this file exists to catch. The sibling instrument file + * these were borrowed from (`kanban-calendar-filter-sort-8174.test.ts`) carries + * three of the same warnings for the same reason. + * + * ⛔ An `eslint-disable` is not the remedy — THIS COMMENT is, because it records + * WHY. A directive only silences, and `eslint.config.js` sets + * `reportUnusedDisableDirectives: 'error'` on every linted path, so a directive + * left behind after the cast is ever retyped stops being a silencer and becomes + * a hard error. + */ function specShapeKeys(type: string): string[] { const entry = (ComponentPropsMap as unknown as Record)[type]; const def = entry._def; @@ -276,13 +295,18 @@ describe('objectui#8466 — the spec refuses ALL FIVE flat keys, which is why de }); }); -/* ── The sibling element: one renderer, two interfaces, one flat vocabulary ── */ +/* ── The sibling interface: two renderers, one flat vocabulary ────────────── */ describe('objectui#8466 — `calendar-view` already declared all five, and the two must not fork', () => { it('ONE renderer serves both `object-calendar` and `calendar`', () => { - // `ObjectCalendarRenderer` is registered twice. That is what makes a fork - // between the two interfaces a real defect rather than a tidiness point: - // the same `getCalendarConfig` reads the same five keys off both. + // `ObjectCalendarRenderer` is registered twice — under `object-calendar` + // and under `calendar`, and the same `getCalendarConfig` reads the same five + // keys off both. ⛔ NOT under `calendar-view`: that element has its OWN + // renderer (`calendar-view-renderer.tsx`, which imports neither + // `ObjectCalendarRenderer` nor `getCalendarConfig`) and reads the five flat + // keys off `schema` itself. So the flat vocabulary spans THREE registered + // type names across TWO renderers, which is what makes a drift between the + // two interfaces a real defect rather than a tidiness point. const src = readRepo(CALENDAR_REGISTRATION); expect(src).toContain("ComponentRegistry.register('object-calendar', ObjectCalendarRenderer"); expect(src).toContain("ComponentRegistry.register('calendar', ObjectCalendarRenderer"); @@ -290,9 +314,9 @@ describe('objectui#8466 — `calendar-view` already declared all five, and the t it('the sibling `CalendarViewSchema` declares all five — including the two this card adds', () => { // Measured, and the reason declaring `allDayField` is not a new precedent: - // this package has ALREADY shipped it declared on a published interface, on - // the element the same renderer draws. `ObjectCalendarSchema` was the odd - // one out, not the pioneer. + // this package has ALREADY shipped it declared on a published interface, for + // a sibling renderer that reads exactly the same five flat keys. + // `ObjectCalendarSchema` was the odd one out, not the pioneer. const declaredOnSibling: Record<(typeof FLAT_KEYS)[number], true> = { titleField: true, startDateField: true, diff --git a/packages/types/src/objectql.ts b/packages/types/src/objectql.ts index 5a3a11c0cc..525afff22d 100644 --- a/packages/types/src/objectql.ts +++ b/packages/types/src/objectql.ts @@ -2811,11 +2811,13 @@ export interface ObjectCalendarSchema extends BaseSchema { * this key is its fifth member, not a new dialect. And under `BaseSchema`'s * index signature the value was already `any`, so declaring only NARROWS. * - * ⭐ Nor is it a new precedent: {@link CalendarViewSchema} — the sibling - * `calendar-view` element, drawn by the SAME renderer (`plugin-calendar` - * registers `ObjectCalendarRenderer` under both type names) — has shipped all - * five of these keys declared, `allDayField` included. This interface was the - * odd one out, not the pioneer, and the pin keeps the two from forking. + * ⭐ Nor is it a new precedent: {@link CalendarViewSchema} — a sibling + * calendar interface in the same plugin, whose OWN renderer + * (`plugin-calendar/src/calendar-view-renderer.tsx`) reads the same five flat + * keys — has shipped all five declared, `allDayField` included, on both + * faces. Two interfaces, two renderers, one flat vocabulary: this interface + * was the odd one out, not the pioneer, and the pin keeps that shared + * vocabulary from drifting apart. */ allDayField?: string; /** From 4626bca518aeb0a8ee2a8bd62279301446a67989 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 9 Sep 2026 11:45:58 +0000 Subject: [PATCH 4/4] fix(scripts): move the ObjectFormSchema ledger row to the block's real line (objectui#8466) `UNGATED_EXAMPLES` in `scripts/check-doc-example-types.mjs` is keyed by `path:line symbol`. This branch adds one import line near the top of `packages/types/src/objectql.ts`, so the `@example` block the ledger names shifted down by one and the row stopped naming a real block. Re-derived on this head with the gate's own instrument (`exampleCensus` + `ledgerKey`), which reports the block at line 1608 and reports that row as the only ledger key with no matching block. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Jmxdo7bmeqCQHLSfmLVX9w --- scripts/check-doc-example-types.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/check-doc-example-types.mjs b/scripts/check-doc-example-types.mjs index de0cb2891d..4ffec286e9 100644 --- a/scripts/check-doc-example-types.mjs +++ b/scripts/check-doc-example-types.mjs @@ -1013,7 +1013,7 @@ export const UNGATED_EXAMPLES = { reason: 'usage fragment: references `save`, `storedPage`, which the example never declares', }, - 'packages/types/src/objectql.ts:1607 ObjectFormSchema': { + 'packages/types/src/objectql.ts:1608 ObjectFormSchema': { card: null, codes: [1005, 1109], reason: