From 0b52b17965aec0f39ea5032ecc7c58ab6246ce3c Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 30 Sep 2026 20:31:35 +0000 Subject: [PATCH 1/4] feat(types): declare node-level `responsiveStyles` on `flex`, `object-grid` and `object-chart`, by reference to the spec (objectui#10872, batch 9) The three arms outside the public-block set whose nodes a producer writes `responsiveStyles` on spread batch 8's shared fragment, so the member is the same object on all 33 arms. The fragment is renamed from `PUBLIC_BLOCK_ENVELOPE` to `NODE_ENVELOPE`, because it is no longer the public blocks' alone. The published TypeScript twins declare the key as the spec's `ResponsiveStyles`, which the mirror-parity census requires for a mirrored key. `BaseSchema` and every other arm are unchanged. Claude-Session: https://claude.ai/code/session_0122Knsowci76D2rBWReCzzZ Co-authored-by: Claude --- .../10872-flat-arm-responsive-styles.md | 19 ++ .../flat-arm-responsive-styles-10872.test.ts | 301 ++++++++++++++++++ .../__tests__/imported-defaults-8317.test.ts | 2 +- ...blic-block-responsive-styles-10872.test.ts | 15 +- .../src/__tests__/zod-mirror-parity.test.ts | 2 +- packages/types/src/layout.ts | 18 ++ packages/types/src/objectql.ts | 22 ++ packages/types/src/zod/README.md | 3 +- packages/types/src/zod/layout.zod.ts | 11 + packages/types/src/zod/objectql.zod.ts | 22 +- packages/types/src/zod/public-blocks.zod.ts | 79 +++-- 11 files changed, 449 insertions(+), 45 deletions(-) create mode 100644 .changeset/10872-flat-arm-responsive-styles.md create mode 100644 packages/types/src/__tests__/flat-arm-responsive-styles-10872.test.ts diff --git a/.changeset/10872-flat-arm-responsive-styles.md b/.changeset/10872-flat-arm-responsive-styles.md new file mode 100644 index 0000000000..2133b759bd --- /dev/null +++ b/.changeset/10872-flat-arm-responsive-styles.md @@ -0,0 +1,19 @@ +--- +'@object-ui/types': minor +--- + +`flex`, `object-grid` and `object-chart` accept the node-level `responsiveStyles` that `@objectstack/spec`'s `PageComponentSchema` declares on every page component, and judge it as the spec does (objectui#10872, batch 9). + +**Clause-②: yes (widening)**: the strict authoring face (`StrictAnyComponentSchema`) now accepts a node-level `responsiveStyles` on `FlexSchema`, `ObjectGridSchema` and `ObjectChartSchema`, where it refused the key by name. Batch 8 declared the same key on the 30 public-block arms; these are the three arms outside that set whose nodes the objectstack showcase and UI skill write it on. + +**What changed, in observable terms.** + +- The three arms declare `responsiveStyles` from the same shared declaration as the public-block arms: the spec's `ResponsiveStylesSchema`, by reference. Its breakpoints (`large`, `medium`, `small`, `xsmall`), its strictness and its unknown-breakpoint message come from the spec. +- A node such as `{ "type": "flex", "direction": "col", "responsiveStyles": { "large": { "display": "block", "minWidth": "0" }, "small": { "padding": "12px" } } }` parses on both faces, and the map comes back unchanged. The same holds for `object-grid` and `object-chart` nodes. +- The published TypeScript interfaces `FlexSchema`, `ObjectGridSchema` and `ObjectChartSchema` declare `responsiveStyles?: ResponsiveStyles`, the spec's type. Before, the key was typed `any` through `BaseSchema`'s index signature. +- Nothing else widens. `BaseSchema` still does not declare `responsiveStyles`, and neither does any other arm (`stack`, `grid` and `container` included), so the strict face still refuses it there by name. The other node-level keys the spec declares (`events`, `aria`, `visibility`, `responsive`, and `dataSource` on `flex` and `object-chart`) stay undeclared. + +**Narrowing, on already-published arms.** Unlike the public-block arms, these three were published before this change, and two things now refuse what used to pass: + +- The tolerant face (`safeValidateSchema`, the face `objectui validate` runs) used to keep any node-level `responsiveStyles` value on these arms unjudged. It now refuses a value the spec refuses, at `responsiveStyles`, with the spec's own issue: a breakpoint the spec does not have (`md`), a number instead of a map, or a style value that is neither a string nor a number. `SchemaRenderer` applied nothing for a number or for a map with no spec breakpoint. It emitted a non-CSS value inside a valid breakpoint verbatim, where the browser dropped it, and it applied the valid breakpoints of a mixed map. +- In TypeScript, a `responsiveStyles` value that is not the spec's `ResponsiveStyles` no longer type-checks on these three interfaces. diff --git a/packages/types/src/__tests__/flat-arm-responsive-styles-10872.test.ts b/packages/types/src/__tests__/flat-arm-responsive-styles-10872.test.ts new file mode 100644 index 0000000000..cf130c92ab --- /dev/null +++ b/packages/types/src/__tests__/flat-arm-responsive-styles-10872.test.ts @@ -0,0 +1,301 @@ +/** + * 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. + */ + +/** + * `flex`, `object-grid` and `object-chart` declare the node-level + * `responsiveStyles` that `@objectstack/spec`'s `PageComponentSchema` declares + * on every page component, by reference, from the same fragment as the + * public-block arms (objectui#10872, batch 9). + * + * ## The defect these pin + * + * Batch 8 declared the key on the 30 public-block arms. Objectstack's showcase + * and its UI skill also write it on nodes of three arms outside that set: + * `flex`, `object-grid` and `object-chart`. That reading is recorded on + * objectui#10872, and nothing here re-derives it. `SchemaRenderer` compiles + * the key to id-scoped CSS on every node (ADR-0065), but these three arms did + * not declare it, so: + * + * - the STRICT authoring face refused a spec-valid node by name + * (`unrecognized_keys`), and + * - the TOLERANT face passed it unjudged: `responsiveStyles: 7`, or a + * breakpoint the spec does not have (`md`), parsed clean and styled + * nothing. + * + * ## What is and is not widened + * + * The three arms spread the ONE shared fragment the public-block arms spread, + * `PAGE_COMPONENT_ENVELOPE`, so the member is the same object on all 33 arms. + * The key is NOT declared on `BaseSchema` and NOT on any arm without a measured + * producer: `grid`, `stack` and `container` below are the control, and the + * whole arm list is read at run time so an arm that gains the member silently + * turns this file red. The published TypeScript twins declare it too, as the + * spec's `ResponsiveStyles` type by reference, so the mirror-parity census + * measures no mirrored-but-undeclared key on these pairs. + * + * Every verdict is read against the INSTALLED spec at run time: the member is + * compared with the spec's own `ResponsiveStylesSchema`, and a refusal is + * compared with the issue the spec itself emits for the same value. + */ + +import { describe, expect, it } from 'vitest'; +import type { z } from 'zod'; +import { + PageComponentSchema, + ResponsiveStylesSchema as SpecResponsiveStylesSchema, + type ResponsiveStyles as SpecResponsiveStyles, +} from '@objectstack/spec/ui'; + +import { + AnyComponentSchema, + BaseSchema, + FlexSchema, + ObjectChartSchema, + ObjectGridSchema, + ObjectQLPublicBlockComponentSchema, + PageSectionBlockSchema, + PublicBlockComponentSchema, + StrictAnyComponentSchema, + safeValidateSchema, +} from '../zod/index.zod.js'; +import type { FlexSchema as TsFlexSchema } from '../layout'; +import type { ObjectChartSchema as TsObjectChartSchema, ObjectGridSchema as TsObjectGridSchema } from '../objectql'; + +/* ── Type-level pins (compiled by `tsc -p tsconfig.test.json`) ─────────────── */ + +type Equal = + (() => T extends A ? 1 : 2) extends (() => T extends B ? 1 : 2) ? true : false; +type Expect = T; +type ShapeOf = M extends { shape: infer S } ? S : never; +type InputOf = T extends z.ZodType ? z.input : never; + +/** Each zod arm's member accepts exactly the spec's published type, absent allowed. */ +export type assertionZodMemberIsTheSpecType = [ + Expect['responsiveStyles']>, SpecResponsiveStyles | undefined>>, + Expect['responsiveStyles']>, SpecResponsiveStyles | undefined>>, + Expect['responsiveStyles']>, SpecResponsiveStyles | undefined>>, +]; +/** + * Each published TypeScript twin declares the member as the spec's type. Without + * the declaration the member resolves through `BaseSchema`'s index signature to + * `any`, which `Equal` refuses. + */ +export type assertionTsTwinIsTheSpecType = [ + Expect>, + Expect>, + Expect>, +]; +/** The helper can FAIL: `any` is not the spec's type. */ +export type assertionEqualRefusesAny = Expect, false>>; + +/* ── The population ───────────────────────────────────────────────────────── */ + +type Arm = z.ZodObject; +const optionsOf = (union: unknown): Arm[] => (union as { options: Arm[] }).options; +const literalOf = (arm: Arm): string => (arm.shape.type as z.ZodLiteral).value as string; + +/** Every arm of the node union with its `type` literals, read off the live union. */ +function armsByLiteral(): Map { + const out = new Map(); + const walk = (schema: unknown): void => { + const options = (schema as { options?: unknown[] }).options; + if (options) { + for (const option of options) walk(option); + return; + } + const type = (schema as Arm).shape?.type as { _zod?: { values?: Set } } | undefined; + for (const value of type?._zod?.values ?? []) out.set(String(value), schema as Arm); + }; + walk(AnyComponentSchema); + return out; +} + +/** + * The three arms this batch widens: the arms outside the public-block set that + * a producer writes `responsiveStyles` on (objectui#10872's batch-9 pull). NAMED, + * not derived, because the population is a measurement of producers. + */ +const FLAT_ARMS = [ + ['flex', FlexSchema], + ['object-grid', ObjectGridSchema], + ['object-chart', ObjectChartSchema], +] as const; + +/** Arms with no producer: the strict face still refuses the key on them. */ +const CONTROL_TYPES = ['grid', 'stack', 'container'] as const; + +/** The arm that already declared `dataSource` before this batch (objectui#11070). */ +const DECLARES_DATA_SOURCE: ReadonlySet = new Set(['object-grid']); + +/** The five envelope keys the spec declares and this batch leaves undeclared. */ +const UNDECLARED_ENVELOPE = ['events', 'dataSource', 'aria', 'visibility', 'responsive'] as const; + +/** A valid flat node per arm, shaped like the producer's nodes, `responsiveStyles` included. */ +const VALID_NODES = { + flex: { + type: 'flex', + id: 'cc_panel', + direction: 'col', + responsiveStyles: { + large: { display: 'block', minWidth: '0', padding: '15px 17px 17px', borderRadius: '16px' }, + small: { padding: '12px', minHeight: '200px' }, + }, + // A nested node carrying the key: judged at depth, through the node recursion. + children: [{ type: 'flex', id: 'cc_spacer', responsiveStyles: { large: { flex: '1 1 auto' } } }], + }, + 'object-grid': { + type: 'object-grid', + id: 'cc_queue_g', + objectName: 'showcase_task', + columns: ['title', 'status'], + responsiveStyles: { large: { minWidth: '0', display: 'block' } }, + }, + 'object-chart': { + type: 'object-chart', + id: 'cc_status_c', + dataset: 'showcase_task_metrics', + dimensions: ['status'], + values: ['task_count'], + chartType: 'bar', + responsiveStyles: { large: { width: '100%', minWidth: '0' }, xsmall: { display: 'none' } }, + }, +} as const; + +/** Values the spec refuses: a breakpoint it does not have, a non-object, a non-CSS value. */ +const INVALID_STYLES: ReadonlyArray = [ + ['a breakpoint the spec does not declare (`md`)', { md: { padding: '8px' } }], + ['a number instead of a map', 7], + ['a style value that is neither string nor number', { large: { padding: true } }], +]; + +type Result = { success: boolean; error?: { issues: z.core.$ZodIssue[] } }; + +/** Every issue as `{ code, path, message }`. */ +const issuesOf = (result: Result) => + result.success + ? [] + : result.error!.issues.map((issue) => ({ code: issue.code, path: issue.path.join('.'), message: issue.message })); + +/** The keys an `unrecognized_keys` issue names, at any depth of a union. */ +function refusedKeys(result: Result): string[] { + if (result.success) return []; + const collect = (issues: readonly z.core.$ZodIssue[]): string[] => + issues.flatMap((issue) => { + if (issue.code === 'unrecognized_keys') return issue.keys; + if (issue.code === 'invalid_union') return issue.errors.flatMap((arm) => collect(arm)); + return []; + }); + return collect(result.error!.issues); +} + +describe('the population (objectui#10872 batch 9)', () => { + it('each named arm is the arm the node union selects for its literal', () => { + const arms = armsByLiteral(); + // Non-vacuity: the union is read, and it is the whole node vocabulary. + expect(arms.size).toBeGreaterThan(100); + for (const [type, arm] of FLAT_ARMS) { + expect(literalOf(arm)).toBe(type); + expect(arms.get(type)).toBe(arm); + } + for (const type of CONTROL_TYPES) expect(arms.has(type)).toBe(true); + }); + + it('the spec declares `responsiveStyles` on a page component of each type (lit control)', () => { + for (const [type] of FLAT_ARMS) { + const node = { type, responsiveStyles: VALID_NODES[type].responsiveStyles }; + expect(PageComponentSchema.safeParse(node).success).toBe(true); + expect(SpecResponsiveStylesSchema.safeParse(node.responsiveStyles).success).toBe(true); + } + }); +}); + +describe('`responsiveStyles` is declared on the three arms, from the public blocks\' fragment (objectui#10872 batch 9)', () => { + it.each(FLAT_ARMS)('%s carries the member, and it is the SAME object the public-block arms carry', (_type, arm) => { + const shared = PageSectionBlockSchema.shape.responsiveStyles; + expect(shared).toBeDefined(); + expect(arm.shape.responsiveStyles).toBe(shared); + }); + + it('the member is the spec\'s own map: the same breakpoints, each the spec\'s own value schema', () => { + const member = (FlexSchema.shape.responsiveStyles as z.ZodOptional).unwrap() as z.ZodObject; + const spec = SpecResponsiveStylesSchema as unknown as z.ZodObject; + expect(Object.keys(member.shape).sort()).toEqual(Object.keys(spec.shape).sort()); + for (const key of Object.keys(spec.shape)) expect(member.shape[key]).toBe(spec.shape[key]); + }); + + it('exactly the public-block arms and these three declare it: no other arm, and not `BaseSchema`', () => { + expect(Object.keys(BaseSchema.shape)).not.toContain('responsiveStyles'); + const publicBlocks = [ + ...optionsOf(PublicBlockComponentSchema), + ...optionsOf(ObjectQLPublicBlockComponentSchema), + ].map(literalOf); + const expected = [...publicBlocks, ...FLAT_ARMS.map(([type]) => type)].sort(); + const declaring = [...armsByLiteral()] + .filter(([, arm]) => 'responsiveStyles' in arm.shape) + .map(([type]) => type) + .sort(); + expect(declaring).toEqual(expected); + }); + + it.each(CONTROL_TYPES)('%s (no producer) still refuses it on the strict face, by name', (type) => { + const node = { type, responsiveStyles: VALID_NODES.flex.responsiveStyles }; + expect(refusedKeys(StrictAnyComponentSchema.safeParse(node))).toEqual(['responsiveStyles']); + }); +}); + +describe('a valid `responsiveStyles` is accepted on BOTH faces (objectui#10872 batch 9)', () => { + it.each(FLAT_ARMS.map(([type]) => type))('%s: the node parses on both faces, and the map survives unchanged', (type) => { + const node = VALID_NODES[type]; + const tolerant = safeValidateSchema(node); + expect(issuesOf(tolerant)).toEqual([]); + expect((tolerant.data as { responsiveStyles?: unknown }).responsiveStyles).toEqual(node.responsiveStyles); + const strict = StrictAnyComponentSchema.safeParse(node); + expect(issuesOf(strict)).toEqual([]); + expect((strict.data as { responsiveStyles?: unknown }).responsiveStyles).toEqual(node.responsiveStyles); + }); +}); + +describe('an invalid `responsiveStyles` is refused on BOTH faces, with the spec\'s own issue (objectui#10872 batch 9)', () => { + const cases = FLAT_ARMS.flatMap(([type]) => INVALID_STYLES.map(([why, value]) => [type, why, value] as const)); + + it.each(cases)('%s — %s', (type, _why, value) => { + // The spec's verdict on the same value at a page component of this type, + // read at run time. The spec node carries only `type` and the key, so every + // issue it reports is about the key. + const spec = issuesOf(PageComponentSchema.safeParse({ type, responsiveStyles: value })); + expect(spec.length, 'lit control: the spec refuses this value').toBeGreaterThan(0); + expect(spec.every((issue) => issue.path.startsWith('responsiveStyles'))).toBe(true); + const node = { ...VALID_NODES[type], responsiveStyles: value }; + expect(issuesOf(safeValidateSchema(node))).toEqual(spec); + expect(issuesOf(StrictAnyComponentSchema.safeParse(node))).toEqual(spec); + }); +}); + +describe('the other five envelope keys stay undeclared on the three arms (objectui#10872 batch 9)', () => { + it('no arm gained any of them: `dataSource` only where objectui#11070 declared it', () => { + const declared = FLAT_ARMS.flatMap(([type, arm]) => + UNDECLARED_ENVELOPE.filter((key) => key in arm.shape).map((key) => `${type}#${key}`), + ).sort(); + expect(declared).toEqual([...DECLARES_DATA_SOURCE].map((type) => `${type}#dataSource`)); + }); + + const cases = FLAT_ARMS.flatMap(([type]) => + UNDECLARED_ENVELOPE.filter((key) => !(key === 'dataSource' && DECLARES_DATA_SOURCE.has(type))).map( + (key) => [type, key] as const, + ), + ); + + it.each(cases)('%s: a node-level `%s` is still refused on the strict face, by name', (type, key) => { + // The valid node without the key under test and without a nested child, so + // the only refusal left is the one this row asks about. + const { responsiveStyles: _styles, ...rest } = VALID_NODES[type] as Record; + const { children: _children, ...bare } = rest; + const node = { ...bare, [key]: { x: 1 } }; + expect(refusedKeys(StrictAnyComponentSchema.safeParse(node))).toEqual([key]); + }); +}); diff --git a/packages/types/src/__tests__/imported-defaults-8317.test.ts b/packages/types/src/__tests__/imported-defaults-8317.test.ts index 4febb117b2..72354db8f7 100644 --- a/packages/types/src/__tests__/imported-defaults-8317.test.ts +++ b/packages/types/src/__tests__/imported-defaults-8317.test.ts @@ -301,7 +301,7 @@ const IMPORTED: Array = [ ['ElementDataSourceSchema', SpecElementDataSourceSchema], // objectui#10872 batch 8: every public-block arm declares the node-level // `responsiveStyles` as the spec's `ResponsiveStylesSchema` (one shared - // `PUBLIC_BLOCK_ENVELOPE`), crossed through this boundary like every other + // `NODE_ENVELOPE`), crossed through this boundary like every other // read. It carries no default and reaches no `z.lazy`, so the strip is the // identity function — the row is here because the census below requires // every imported symbol to be measured, not because the strip does work. diff --git a/packages/types/src/__tests__/public-block-responsive-styles-10872.test.ts b/packages/types/src/__tests__/public-block-responsive-styles-10872.test.ts index 7c5e6d0a0e..f942e540de 100644 --- a/packages/types/src/__tests__/public-block-responsive-styles-10872.test.ts +++ b/packages/types/src/__tests__/public-block-responsive-styles-10872.test.ts @@ -28,8 +28,10 @@ * * ## What is and is not widened * - * The key is declared on the public-block arms only — `PublicBlockComponentSchema` - * and `ObjectQLPublicBlockComponentSchema` — through ONE shared fragment, and + * The key is declared on the public-block arms — `PublicBlockComponentSchema` + * and `ObjectQLPublicBlockComponentSchema` — through ONE shared fragment + * (batch 9 spread the same fragment into `flex`, `object-grid` and + * `object-chart`, pinned in `./flat-arm-responsive-styles-10872.test.ts`), and * NOT on `BaseSchema`, which would widen every arm in `AnyComponentSchema` * (a different accept-set change, not granted by the batch's ruling). The other * five envelope keys stay undeclared: they have no producer. The pins below @@ -192,9 +194,12 @@ describe('`responsiveStyles` is declared on every public-block arm, by reference expect(Object.keys(BaseSchema.shape)).not.toContain('responsiveStyles'); // Control: a non-public-block node is still refused by name on the strict // face, so the accept-set change is exactly the arms above. This is the - // boundary of this batch, not a ruling that it must stay. - const flex = { type: 'flex', responsiveStyles: VALID_STYLES }; - expect(refusedKeys(StrictAnyComponentSchema.safeParse(flex))).toEqual(['responsiveStyles']); + // boundary of this batch, not a ruling that it must stay: batch 9 moved it + // for `flex`, `object-grid` and `object-chart`, whose nodes a producer + // writes the key on (`./flat-arm-responsive-styles-10872.test.ts`). The + // control is now `grid`, an arm with no producer. + const grid = { type: 'grid', responsiveStyles: VALID_STYLES }; + expect(refusedKeys(StrictAnyComponentSchema.safeParse(grid))).toEqual(['responsiveStyles']); }); }); diff --git a/packages/types/src/__tests__/zod-mirror-parity.test.ts b/packages/types/src/__tests__/zod-mirror-parity.test.ts index 4b7a9a5540..c7c5b044d2 100644 --- a/packages/types/src/__tests__/zod-mirror-parity.test.ts +++ b/packages/types/src/__tests__/zod-mirror-parity.test.ts @@ -4339,7 +4339,7 @@ const EXCLUSIONS: Readonly> = { // `ResponsiveStylesSchema` by reference. The arm reasons above describe the // members each arm adds of its own; this fragment is shared by all of them. // Pinned in `./public-block-responsive-styles-10872.test.ts`. - 'public-blocks.zod.ts#PUBLIC_BLOCK_ENVELOPE': + 'public-blocks.zod.ts#NODE_ENVELOPE': "a shape FRAGMENT, not a schema and not a mirror — `{ responsiveStyles }`, the spec's `ResponsiveStylesSchema` by reference (the schema `PageComponentSchema.responsiveStyles` declares), spread into every public-block arm so the one declaration is shared; no TS declaration in this package restates it", // objectui#10919 — the `cloud:plan-status` widget's arm, declared locally from // the widget's read points: `@objectstack/spec` has no row for a `cloud:` diff --git a/packages/types/src/layout.ts b/packages/types/src/layout.ts index a90b246c7a..1647b8253a 100644 --- a/packages/types/src/layout.ts +++ b/packages/types/src/layout.ts @@ -20,6 +20,9 @@ import type { I18nLabel, Page as SpecPage, PageType as SpecPageType } from '@obj import type { BaseSchema, SchemaNode } from './base.js'; import type { PAGE_SPEC_EXCLUDED } from './zod/layout.zod.js'; import type { BreakpointName } from './mobile.js'; +// objectui#10872 batch 9 — the per-breakpoint style maps `FlexSchema.responsiveStyles` +// declares, by reference. Type-only. +import type { ResponsiveStyles as SpecResponsiveStyles } from '@objectstack/spec/ui'; /** * Basic HTML div container @@ -700,6 +703,21 @@ export interface FlexLayoutProps { */ export interface FlexSchema extends BaseSchema, FlexLayoutProps { type: 'flex'; + /** + * Per-breakpoint scoped style maps (ADR-0065): `@objectstack/spec`'s + * `ResponsiveStyles`, the type `PageComponentSchema.responsiveStyles` + * declares, by reference (objectui#10872 batch 9). `SchemaRenderer` compiles + * it to CSS scoped to this node: `large` is the unconditional base, and + * `medium` / `small` / `xsmall` are max-width overrides. + * + * Declared here, and not on {@link FlexLayoutProps}, because `flex` is the + * layout arm a producer writes the key on (the objectstack showcase; the + * reading is recorded on objectui#10872 and nothing here re-derives it). + * `stack` shares those props and has no measured producer, so it does not + * declare the key. The zod mirror spreads the same member from the + * public blocks' fragment (`NODE_ENVELOPE`). + */ + responsiveStyles?: SpecResponsiveStyles; /** * REFUSED BY NAME (objectui#8284, ADR-0049) — `flex` reads `children`, and no * renderer read consumes `body`. diff --git a/packages/types/src/objectql.ts b/packages/types/src/objectql.ts index 63d9ef39e9..c8855c23c0 100644 --- a/packages/types/src/objectql.ts +++ b/packages/types/src/objectql.ts @@ -33,6 +33,9 @@ import type { QueryParams } from './data.js'; import type { BulkActionOperation } from '@objectstack/spec/ui'; // objectui#11070 — the per-element binding `dataSource` declares, by reference. import type { ElementDataSource } from '@objectstack/spec/ui'; +// objectui#10872 batch 9 — the per-breakpoint style maps `responsiveStyles` declares +// on `ObjectGridSchema` and `ObjectChartSchema`, by reference. +import type { ResponsiveStyles as SpecResponsiveStyles } from '@objectstack/spec/ui'; // objectui#7928 — `ObjectViewSchema.listViews` is the protocol's record of this // schema, BY REFERENCE. The spec publishes a TS type for `ListView` and none for // the object-scoped `ObjectListView`, so the member reads it as `z.input` (the @@ -774,6 +777,18 @@ export interface ListViewExportOptions { export interface ObjectGridSchema extends BaseSchema { type: 'object-grid'; + /** + * Per-breakpoint scoped style maps (ADR-0065): `@objectstack/spec`'s + * `ResponsiveStyles`, the type `PageComponentSchema.responsiveStyles` + * declares, by reference (objectui#10872 batch 9). `SchemaRenderer` compiles + * it to CSS scoped to this node and adds the scope class to the node's + * `className`, which `ObjectGrid` forwards. A producer writes it on + * `object-grid` nodes (the objectstack showcase; the reading is recorded on + * objectui#10872). Same declaration on {@link ObjectChartSchema}; the zod + * mirror spreads the public blocks' fragment (`NODE_ENVELOPE`). + */ + responsiveStyles?: SpecResponsiveStyles; + /** * Per-element data binding — `@objectstack/spec`'s `ElementDataSource` * (`PageComponentSchema.dataSource`), by reference: `{ object, view?, @@ -4933,6 +4948,13 @@ export type KanbanConditionalFormattingRule = */ export interface ObjectChartSchema extends BaseSchema { type: 'object-chart'; + /** + * Per-breakpoint scoped style maps (ADR-0065): `@objectstack/spec`'s + * `ResponsiveStyles`, by reference (objectui#10872 batch 9). The same + * declaration as {@link ObjectGridSchema.responsiveStyles}; a producer writes + * it on `object-chart` nodes (the objectstack showcase). + */ + responsiveStyles?: SpecResponsiveStyles; /** ObjectQL object name (legacy inline path; optional under ADR-0021 dataset binding) */ objectName?: string; /** Chart type. Includes donut / horizontal-bar / column — all rendered by diff --git a/packages/types/src/zod/README.md b/packages/types/src/zod/README.md index 9d0f026942..fd4b374fde 100644 --- a/packages/types/src/zod/README.md +++ b/packages/types/src/zod/README.md @@ -240,7 +240,8 @@ The ADR-0080 public blocks `object-metric`, `object-master-detail-form` and `obj ### ADR-0080 Public Blocks (27) Each arm's `properties` is the block's `@objectstack/spec` `ComponentPropsMap` row, by reference. `ElementNumberBlockSchema` also declares the spec's `dataSource` binding, and mirrors the spec gate's one waiver: its bag may omit `object` when `dataSource.object` names the object. -Every arm here, and each of the three ObjectQL public blocks above, declares the node-level `responsiveStyles` (ADR-0065 per-breakpoint style maps, which `SchemaRenderer` compiles to scoped CSS) as the spec's `ResponsiveStylesSchema`, by reference, from one shared fragment, `PUBLIC_BLOCK_ENVELOPE` (objectui#10872 batch 8). The other node-level keys the spec's `PageComponentSchema` declares (`events`, `aria`, `visibility`, `responsive`, and `dataSource` outside the arms that read it) stay undeclared, so the strict face refuses them by name. +Every arm here, and each of the three ObjectQL public blocks above, declares the node-level `responsiveStyles` (ADR-0065 per-breakpoint style maps, which `SchemaRenderer` compiles to scoped CSS) as the spec's `ResponsiveStylesSchema`, by reference, from one shared fragment, `NODE_ENVELOPE` (objectui#10872 batch 8; named `PUBLIC_BLOCK_ENVELOPE` until batch 9). The other node-level keys the spec's `PageComponentSchema` declares (`events`, `aria`, `visibility`, `responsive`, and `dataSource` outside the arms that read it) stay undeclared, so the strict face refuses them by name. +Outside this set, `FlexSchema`, `ObjectGridSchema` and `ObjectChartSchema` spread the same fragment (objectui#10872 batch 9), and their TypeScript twins declare the key as the spec's `ResponsiveStyles`: they are the arms outside this set that objectui#10872's producer reading found `responsiveStyles` written on (a dated reading, not re-derived here). `BaseSchema` and every other arm leave it undeclared, so the strict face refuses it there by name. Every arm except the four `page:` containers (`page:card`, `page:section`, `page:footer`, `page:sidebar`) refuses `children` and `body` by name, because its renderer reads neither content channel (objectui#9256; the last six below since objectui#10872 batch 5). The four containers do render a child list, and its home is `properties.children`, the member their spec row declares; they refuse the node-level `children` and `body` with a message naming it, as `@objectstack/spec`'s `PageComponentSchema` refuses a node-level `children` (objectui#10872 batch 6). Two carve-outs: `record:alert` refuses `children` with its own message, while its `body` is the message text and belongs in `properties`, so a flat `body` is refused with a pointer to `properties.body` rather than to `children` (objectui#10872); and `page:tabs` / `page:accordion` refuse the node's own `children` while each item's `children` in `items` stays live. - `PageHeaderBlockSchema`, `PageTabsBlockSchema`, `PageCardBlockSchema`, `PageAccordionBlockSchema` - `PageSectionBlockSchema`, `PageFooterBlockSchema`, `PageSidebarBlockSchema` diff --git a/packages/types/src/zod/layout.zod.ts b/packages/types/src/zod/layout.zod.ts index 384879530d..797a9c0b4e 100644 --- a/packages/types/src/zod/layout.zod.ts +++ b/packages/types/src/zod/layout.zod.ts @@ -26,6 +26,10 @@ import { } from '@objectstack/spec/ui'; import { BaseSchema, SchemaNodeSchema, specFieldsExcept } from './base.zod.js'; import { stripImportedDefaults } from './imported-defaults.js'; +// objectui#10872 batch 9 — the node-level `responsiveStyles` fragment the public +// blocks declare, spread into `FlexSchema` below. `./public-blocks.zod.ts` +// imports nothing from this module, so this adds no cycle. +import { NODE_ENVELOPE } from './public-blocks.zod.js'; /** * ⭐ THE IMPORT BOUNDARY (objectui#8317, decision batch #90, 2026-09-08). @@ -325,6 +329,13 @@ export const ContainerSchema = BaseSchema.extend({ */ export const FlexSchema = BaseSchema.extend({ type: z.literal('flex'), + // objectui#10872 batch 9 — the node-level `responsiveStyles` the spec's + // `PageComponentSchema` declares, from the ONE fragment the public blocks + // spread (`NODE_ENVELOPE`). The objectstack showcase writes it on `flex` + // nodes, and `SchemaRenderer` compiles it on every node. ⛔ Not on `stack`, + // `grid` or `container`: no producer was measured writing it there. The TS + // twin declares it too. + ...NODE_ENVELOPE, direction: z.enum(['row', 'col', 'row-reverse', 'col-reverse']) .optional() .describe('Flex direction'), diff --git a/packages/types/src/zod/objectql.zod.ts b/packages/types/src/zod/objectql.zod.ts index eedbd392b0..2aa44c2822 100644 --- a/packages/types/src/zod/objectql.zod.ts +++ b/packages/types/src/zod/objectql.zod.ts @@ -64,7 +64,7 @@ import { DrillDownConfigSchema } from './data-display.zod.js'; import { KanbanCardSchema } from './complex.zod.js'; import { ViewSwitcherSchema } from './views.zod.js'; import { stripImportedDefaults } from './imported-defaults.js'; -import { PUBLIC_BLOCK_ENVELOPE, propsBag } from './public-blocks.zod.js'; +import { NODE_ENVELOPE, propsBag } from './public-blocks.zod.js'; /** * ⭐ THE IMPORT BOUNDARY (objectui#8317, decision batch #90, 2026-09-08). @@ -291,6 +291,11 @@ const ELEMENT_DATA_SOURCE_BINDING_DESCRIPTION = */ export const ObjectGridSchema = BaseSchema.extend({ type: z.literal('object-grid'), + // objectui#10872 batch 9 — the node-level `responsiveStyles` the spec's + // `PageComponentSchema` declares, from the ONE fragment the public blocks + // spread (`NODE_ENVELOPE`). A producer writes it on `object-grid` nodes, and + // `SchemaRenderer` compiles it on every node. The TS twin declares it too. + ...NODE_ENVELOPE, objectName: z.string().describe('ObjectQL object name'), // objectui#11070 — the spec's per-element binding, by reference, as // `public-blocks.zod.ts`'s `element:number` arm already declares it. The @@ -2917,6 +2922,10 @@ const OBJECT_CHART_NEITHER_CHANNEL = neitherContentChannelGuidance( */ export const ObjectChartSchema = BaseSchema.extend({ type: z.literal('object-chart'), + // objectui#10872 batch 9 — the node-level `responsiveStyles`, from the same + // `NODE_ENVELOPE` fragment as `ObjectGridSchema` above. A producer writes + // it on `object-chart` nodes. The TS twin declares it too. + ...NODE_ENVELOPE, // Legacy inline path (objectName + aggregate). Optional now that a chart may // instead bind to a semantic-layer dataset (ADR-0021, objectstack-ai/objectstack#1890). objectName: z.string().optional().describe('ObjectQL object name (legacy inline path)'), @@ -3411,7 +3420,7 @@ const OBJECT_METRIC_NEITHER_CHANNEL = neitherContentChannelGuidance( */ export const ObjectMetricBlockSchema = BaseSchema.extend({ type: z.literal('object-metric'), - ...PUBLIC_BLOCK_ENVELOPE, + ...NODE_ENVELOPE, properties: propsBag('object-metric', stripImportedDefaults(SpecObjectMetricPropsSchema)), dataSource: stripImportedDefaults(SpecElementDataSourceSchema) .optional() @@ -3467,7 +3476,7 @@ const OBJECT_MASTER_DETAIL_FORM_NEITHER_CHANNEL = neitherContentChannelGuidance( */ export const ObjectMasterDetailFormBlockSchema = BaseSchema.extend({ type: z.literal('object-master-detail-form'), - ...PUBLIC_BLOCK_ENVELOPE, + ...NODE_ENVELOPE, properties: propsBag( 'object-master-detail-form', stripImportedDefaults(SpecObjectMasterDetailFormPropsSchema), @@ -3549,7 +3558,7 @@ const OBJECT_TIMELINE_NEITHER_CHANNEL = neitherContentChannelGuidance( */ export const ObjectTimelineBlockSchema = BaseSchema.extend({ type: z.literal('object-timeline'), - ...PUBLIC_BLOCK_ENVELOPE, + ...NODE_ENVELOPE, properties: propsBag('object-timeline', stripImportedDefaults(SpecObjectTimelinePropsSchema)), dataSource: stripImportedDefaults(SpecElementDataSourceSchema) .optional() @@ -3569,10 +3578,11 @@ export const ObjectTimelineBlockSchema = BaseSchema.extend({ * member for member, and none of these blocks has a declaration there — their * declaration is the spec row each `properties` member reads. * - * Each arm also spreads `PUBLIC_BLOCK_ENVELOPE` from `./public-blocks.zod.ts`, + * Each arm also spreads `NODE_ENVELOPE` from `./public-blocks.zod.ts`, * the node-level `responsiveStyles` every public block declares by reference to * the spec's `PageComponentSchema` (objectui#10872 batch 8) — the same one - * declaration, not a copy of it. + * declaration, not a copy of it. `ObjectGridSchema` and `ObjectChartSchema` + * above spread it too (objectui#10872 batch 9). */ export const ObjectQLPublicBlockComponentSchema = z.discriminatedUnion('type', [ ObjectMetricBlockSchema, diff --git a/packages/types/src/zod/public-blocks.zod.ts b/packages/types/src/zod/public-blocks.zod.ts index de912a06e8..f7cd5d572b 100644 --- a/packages/types/src/zod/public-blocks.zod.ts +++ b/packages/types/src/zod/public-blocks.zod.ts @@ -43,7 +43,8 @@ * (`element:divider`, below). Every arm also declares the node-level * envelope the spec's `PageComponentSchema` declares beside `properties` and a * producer writes — today `responsiveStyles` alone, spread from ONE fragment, - * `PUBLIC_BLOCK_ENVELOPE` (objectui#10872 batch 8). The node-level members an + * `NODE_ENVELOPE` (objectui#10872 batch 8; shared with three arms outside + * this module since batch 9). The node-level members an * arm adds of its own are three kinds: an `on*` key a renderer reads off the * node, declared as * `check:handler-key-reads` requires of every such read — refused by name with @@ -213,7 +214,8 @@ import { ActionMenuPropsSchema as SpecActionMenuPropsSchema, // objectui#10872 batch 8 — the node-level per-breakpoint style maps // (`PageComponentSchema.responsiveStyles`), declared once below - // (`PUBLIC_BLOCK_ENVELOPE`) and spread into every public-block arm. + // (`NODE_ENVELOPE`) and spread into every public-block arm (and, since + // batch 9, into `flex`, `object-grid` and `object-chart`). ResponsiveStylesSchema as SpecResponsiveStylesSchema, } from '@objectstack/spec/ui'; import { BaseSchema } from './base.zod.js'; @@ -249,6 +251,20 @@ export function propsBag(type: string, row: T) { * `./objectql.zod.ts`'s public-block arms, so the one declaration is shared and * no arm restates it. * + * objectui#10872 batch 9 spread the same fragment into three arms OUTSIDE the + * public-block set: `flex` (`./layout.zod.ts`), `object-grid` and + * `object-chart` (`./objectql.zod.ts`). Those are the arms whose nodes the + * objectstack showcase and UI skill write with `responsiveStyles`; that reading + * is recorded on objectui#10872 too, and nothing here re-derives it. So the + * fragment is no longer the public blocks' alone, and batch 9 renamed it from + * `PUBLIC_BLOCK_ENVELOPE`: the old name would have told a reader of + * `FlexSchema` that `flex` is a public block. It stays in this module because + * nothing here imports `./layout.zod.ts` or `./objectql.zod.ts`, so neither + * import makes a cycle. Every other arm, and `BaseSchema`, leaves the key + * undeclared, because that reading found no producer on them. + * `../__tests__/flat-arm-responsive-styles-10872.test.ts` pins which arms + * carry the fragment, read off the live union. + * * `@objectstack/spec`'s `PageComponentSchema` declares six node-level keys * beside `properties`: `events`, `responsiveStyles`, `dataSource`, `aria`, * `visibility` and the retired `responsive`. One has a measured producer, and @@ -268,12 +284,13 @@ export function propsBag(type: string, row: T) { * when a producer needs them", objectui#10872). `dataSource` is declared per * arm where a renderer reads the binding (`element:number`, and the ObjectQL * blocks), not here. ⛔ Not on `BaseSchema`: that widens every arm of - * `AnyComponentSchema`, a different accept-set change from the public blocks'. + * `AnyComponentSchema`, a different accept-set change from the arms above, + * each of which has a measured producer. * * Internal to this package's zod modules, like `propsBag` — deliberately NOT * re-exported from `index.zod.ts`. */ -export const PUBLIC_BLOCK_ENVELOPE = { +export const NODE_ENVELOPE = { responsiveStyles: stripImportedDefaults(SpecResponsiveStylesSchema) .optional() .describe( @@ -324,7 +341,7 @@ const PAGE_HEADER_NEITHER_CHANNEL = neitherContentChannelGuidance( /** `page:header` — `ComponentPropsMap['page:header']`. */ export const PageHeaderBlockSchema = BaseSchema.extend({ type: z.literal('page:header'), - ...PUBLIC_BLOCK_ENVELOPE, + ...NODE_ENVELOPE, properties: propsBag('page:header', stripImportedDefaults(SpecPageHeaderProps)), // objectui#9256: the renderer reads NEITHER content channel, so both are refused by name, each // kept a MEMBER (see "The content channels" above). @@ -359,7 +376,7 @@ const PAGE_TABS_NEITHER_CHANNEL = neitherContentChannelGuidance( */ export const PageTabsBlockSchema = BaseSchema.extend({ type: z.literal('page:tabs'), - ...PUBLIC_BLOCK_ENVELOPE, + ...NODE_ENVELOPE, properties: propsBag('page:tabs', stripImportedDefaults(SpecPageTabsProps)), onTabChange: handlerKeyRefusal('onTabChange', 'runtime-slot', 'Tab switch callback'), // objectui#9256: the NODE's content channels only — each item's `children` stays live. @@ -377,7 +394,7 @@ const PAGE_CARD_CHILD_LIST = pageContainerChildListGuidance('page:card', 'PageCa */ export const PageCardBlockSchema = BaseSchema.extend({ type: z.literal('page:card'), - ...PUBLIC_BLOCK_ENVELOPE, + ...NODE_ENVELOPE, properties: propsBag('page:card', stripImportedDefaults(SpecPageCardProps)), // objectui#10872 batch 6: the child list is the row's `children` member, in the bag. body: retirementTombstone(PAGE_CARD_CHILD_LIST), @@ -400,7 +417,7 @@ const PAGE_ACCORDION_NEITHER_CHANNEL = neitherContentChannelGuidance( */ export const PageAccordionBlockSchema = BaseSchema.extend({ type: z.literal('page:accordion'), - ...PUBLIC_BLOCK_ENVELOPE, + ...NODE_ENVELOPE, properties: propsBag('page:accordion', stripImportedDefaults(SpecPageAccordionProps)), // objectui#9256: the NODE's content channels only — each item's `children` stays live. body: retirementTombstone(PAGE_ACCORDION_NEITHER_CHANNEL), @@ -419,7 +436,7 @@ const PAGE_SECTION_CHILD_LIST = pageContainerChildListGuidance('page:section', ' */ export const PageSectionBlockSchema = BaseSchema.extend({ type: z.literal('page:section'), - ...PUBLIC_BLOCK_ENVELOPE, + ...NODE_ENVELOPE, properties: propsBag('page:section', stripImportedDefaults(SpecPageContainerProps)), // objectui#10872 batch 6: the child list is the row's `children` member, in the bag. body: retirementTombstone(PAGE_SECTION_CHILD_LIST), @@ -432,7 +449,7 @@ const PAGE_FOOTER_CHILD_LIST = pageContainerChildListGuidance('page:footer', 'Pa /** `page:footer` — `ComponentPropsMap['page:footer']` (`PageContainerProps`). */ export const PageFooterBlockSchema = BaseSchema.extend({ type: z.literal('page:footer'), - ...PUBLIC_BLOCK_ENVELOPE, + ...NODE_ENVELOPE, properties: propsBag('page:footer', stripImportedDefaults(SpecPageContainerProps)), // objectui#10872 batch 6: the child list is the row's `children` member, in the bag. body: retirementTombstone(PAGE_FOOTER_CHILD_LIST), @@ -445,7 +462,7 @@ const PAGE_SIDEBAR_CHILD_LIST = pageContainerChildListGuidance('page:sidebar', ' /** `page:sidebar` — `ComponentPropsMap['page:sidebar']` (`PageContainerProps`). */ export const PageSidebarBlockSchema = BaseSchema.extend({ type: z.literal('page:sidebar'), - ...PUBLIC_BLOCK_ENVELOPE, + ...NODE_ENVELOPE, properties: propsBag('page:sidebar', stripImportedDefaults(SpecPageContainerProps)), // objectui#10872 batch 6: the child list is the row's `children` member, in the bag. body: retirementTombstone(PAGE_SIDEBAR_CHILD_LIST), @@ -465,7 +482,7 @@ const RECORD_DETAILS_NEITHER_CHANNEL = neitherContentChannelGuidance( /** `record:details` — `ComponentPropsMap['record:details']`. */ export const RecordDetailsBlockSchema = BaseSchema.extend({ type: z.literal('record:details'), - ...PUBLIC_BLOCK_ENVELOPE, + ...NODE_ENVELOPE, properties: propsBag('record:details', stripImportedDefaults(SpecRecordDetailsProps)), // objectui#9256: the renderer reads NEITHER content channel, so both are refused by name, each // kept a MEMBER (see "The content channels" above). @@ -484,7 +501,7 @@ const RECORD_HIGHLIGHTS_NEITHER_CHANNEL = neitherContentChannelGuidance( /** `record:highlights` — `ComponentPropsMap['record:highlights']`. */ export const RecordHighlightsBlockSchema = BaseSchema.extend({ type: z.literal('record:highlights'), - ...PUBLIC_BLOCK_ENVELOPE, + ...NODE_ENVELOPE, properties: propsBag('record:highlights', stripImportedDefaults(SpecRecordHighlightsProps)), // objectui#9256: the renderer reads NEITHER content channel, so both are refused by name, each // kept a MEMBER (see "The content channels" above). @@ -504,7 +521,7 @@ const RECORD_RELATED_LIST_NEITHER_CHANNEL = neitherContentChannelGuidance( /** `record:related_list` — `ComponentPropsMap['record:related_list']`. */ export const RecordRelatedListBlockSchema = BaseSchema.extend({ type: z.literal('record:related_list'), - ...PUBLIC_BLOCK_ENVELOPE, + ...NODE_ENVELOPE, properties: propsBag('record:related_list', stripImportedDefaults(SpecRecordRelatedListProps)), // objectui#9256: the renderer reads NEITHER content channel, so both are refused by name, each // kept a MEMBER (see "The content channels" above). @@ -523,7 +540,7 @@ const RECORD_PATH_NEITHER_CHANNEL = neitherContentChannelGuidance( /** `record:path` — `ComponentPropsMap['record:path']`. */ export const RecordPathBlockSchema = BaseSchema.extend({ type: z.literal('record:path'), - ...PUBLIC_BLOCK_ENVELOPE, + ...NODE_ENVELOPE, properties: propsBag('record:path', stripImportedDefaults(SpecRecordPathProps)), // objectui#9256: the renderer reads NEITHER content channel, so both are refused by name, each // kept a MEMBER (see "The content channels" above). @@ -543,7 +560,7 @@ const RECORD_ACTIVITY_NEITHER_CHANNEL = neitherContentChannelGuidance( /** `record:activity` — `ComponentPropsMap['record:activity']`. */ export const RecordActivityBlockSchema = BaseSchema.extend({ type: z.literal('record:activity'), - ...PUBLIC_BLOCK_ENVELOPE, + ...NODE_ENVELOPE, properties: propsBag('record:activity', stripImportedDefaults(SpecRecordActivityProps)), // objectui#9256: the renderer reads NEITHER content channel, so both are refused by name, each // kept a MEMBER (see "The content channels" above). @@ -567,7 +584,7 @@ const RECORD_DISCUSSION_NEITHER_CHANNEL = neitherContentChannelGuidance( */ export const RecordDiscussionBlockSchema = BaseSchema.extend({ type: z.literal('record:discussion'), - ...PUBLIC_BLOCK_ENVELOPE, + ...NODE_ENVELOPE, properties: propsBag('record:discussion', stripImportedDefaults(SpecRecordChatterProps)), // objectui#9256: the renderer reads NEITHER content channel, so both are refused by name, each // kept a MEMBER (see "The content channels" above). @@ -586,7 +603,7 @@ const RECORD_HISTORY_NEITHER_CHANNEL = neitherContentChannelGuidance( /** `record:history` — `ComponentPropsMap['record:history']`. */ export const RecordHistoryBlockSchema = BaseSchema.extend({ type: z.literal('record:history'), - ...PUBLIC_BLOCK_ENVELOPE, + ...NODE_ENVELOPE, properties: propsBag('record:history', stripImportedDefaults(SpecRecordHistoryProps)), // objectui#9256: the renderer reads NEITHER content channel, so both are refused by name, each // kept a MEMBER (see "The content channels" above). @@ -606,7 +623,7 @@ const RECORD_QUICK_ACTIONS_NEITHER_CHANNEL = neitherContentChannelGuidance( /** `record:quick_actions` — `ComponentPropsMap['record:quick_actions']`. */ export const RecordQuickActionsBlockSchema = BaseSchema.extend({ type: z.literal('record:quick_actions'), - ...PUBLIC_BLOCK_ENVELOPE, + ...NODE_ENVELOPE, properties: propsBag('record:quick_actions', stripImportedDefaults(SpecRecordQuickActionsProps)), // objectui#9256: the renderer reads NEITHER content channel, so both are refused by name, each // kept a MEMBER (see "The content channels" above). @@ -625,7 +642,7 @@ const RECORD_REFERENCE_RAIL_NEITHER_CHANNEL = neitherContentChannelGuidance( /** `record:reference_rail` — `ComponentPropsMap['record:reference_rail']`. */ export const RecordReferenceRailBlockSchema = BaseSchema.extend({ type: z.literal('record:reference_rail'), - ...PUBLIC_BLOCK_ENVELOPE, + ...NODE_ENVELOPE, properties: propsBag('record:reference_rail', stripImportedDefaults(SpecRecordReferenceRailProps)), // objectui#9256: the renderer reads NEITHER content channel, so both are refused by name, each // kept a MEMBER (see "The content channels" above). @@ -676,7 +693,7 @@ const RECORD_ALERT_NO_CHILD_LIST = */ export const RecordAlertBlockSchema = BaseSchema.extend({ type: z.literal('record:alert'), - ...PUBLIC_BLOCK_ENVELOPE, + ...NODE_ENVELOPE, properties: propsBag('record:alert', stripImportedDefaults(SpecRecordAlertProps)), // objectui#10872: the flat spelling of the row's `body`, refused by name with the key that holds the text. body: aliasKeyRefusal( @@ -706,7 +723,7 @@ const ELEMENT_TEXT_NEITHER_CHANNEL = neitherContentChannelGuidance( /** `element:text` — `ComponentPropsMap['element:text']`. */ export const ElementTextBlockSchema = BaseSchema.extend({ type: z.literal('element:text'), - ...PUBLIC_BLOCK_ENVELOPE, + ...NODE_ENVELOPE, properties: propsBag('element:text', stripImportedDefaults(SpecElementTextPropsSchema)), // objectui#9256: the renderer reads NEITHER content channel, so both are refused by name, each // kept a MEMBER (see "The content channels" above). @@ -801,7 +818,7 @@ const ELEMENT_NUMBER_NEITHER_CHANNEL = neitherContentChannelGuidance( */ export const ElementNumberBlockSchema = BaseSchema.extend({ type: z.literal('element:number'), - ...PUBLIC_BLOCK_ENVELOPE, + ...NODE_ENVELOPE, properties: ElementNumberPropsBag .optional() .describe( @@ -832,7 +849,7 @@ const ELEMENT_BUTTON_NEITHER_CHANNEL = neitherContentChannelGuidance( /** `element:button` — `ComponentPropsMap['element:button']`. */ export const ElementButtonBlockSchema = BaseSchema.extend({ type: z.literal('element:button'), - ...PUBLIC_BLOCK_ENVELOPE, + ...NODE_ENVELOPE, properties: propsBag('element:button', stripImportedDefaults(SpecElementButtonPropsSchema)), // objectui#9256: the renderer reads NEITHER content channel, so both are refused by name, each // kept a MEMBER (see "The content channels" above). @@ -862,7 +879,7 @@ const ELEMENT_DIVIDER_NEITHER_CHANNEL = neitherContentChannelGuidance( */ export const ElementDividerBlockSchema = BaseSchema.extend({ type: z.literal('element:divider'), - ...PUBLIC_BLOCK_ENVELOPE, + ...NODE_ENVELOPE, properties: z .strictObject({}) .optional() @@ -902,7 +919,7 @@ const ELEMENT_DEFINITION_LIST_NEITHER_CHANNEL = neitherContentChannelGuidance( */ export const ElementDefinitionListBlockSchema = BaseSchema.extend({ type: z.literal('element:definition-list'), - ...PUBLIC_BLOCK_ENVELOPE, + ...NODE_ENVELOPE, properties: propsBag('element:definition-list', stripImportedDefaults(SpecElementDefinitionListPropsSchema)), // objectui#10872 batch 5: the renderer reads NEITHER content channel, so both are refused by name, // each kept a MEMBER (see "The content channels" above). @@ -938,7 +955,7 @@ const ELEMENT_REPEATER_NEITHER_CHANNEL = neitherContentChannelGuidance( */ export const ElementRepeaterBlockSchema = BaseSchema.extend({ type: z.literal('element:repeater'), - ...PUBLIC_BLOCK_ENVELOPE, + ...NODE_ENVELOPE, properties: propsBag('element:repeater', stripImportedDefaults(SpecElementRepeaterPropsSchema)), // objectui#10872 batch 5: the renderer reads NEITHER content channel, so both are refused by name, // each kept a MEMBER (see "The content channels" above). @@ -1006,7 +1023,7 @@ const ACTION_BUTTON_NEITHER_CHANNEL = neitherContentChannelGuidance( */ export const ActionButtonBlockSchema = BaseSchema.extend({ type: z.literal('action:button'), - ...PUBLIC_BLOCK_ENVELOPE, + ...NODE_ENVELOPE, properties: propsBag('action:button', stripImportedDefaults(SpecActionButtonPropsSchema)), onClick: handlerKeyRefusal('onClick', 'runtime-slot', 'Click handler'), onSuccess: aliasKeyRefusal('onSuccess', 'properties.onSuccess', 'this `action:button` node', ACTION_BUTTON_FLAT_ON_SUCCESS), @@ -1036,7 +1053,7 @@ const ACTION_ICON_NEITHER_CHANNEL = neitherContentChannelGuidance( */ export const ActionIconBlockSchema = BaseSchema.extend({ type: z.literal('action:icon'), - ...PUBLIC_BLOCK_ENVELOPE, + ...NODE_ENVELOPE, properties: propsBag('action:icon', stripImportedDefaults(SpecActionIconPropsSchema)), onClick: handlerKeyRefusal('onClick', 'runtime-slot', 'Click handler'), onSuccess: aliasKeyRefusal('onSuccess', 'properties.onSuccess', 'this `action:icon` node', ACTION_ICON_FLAT_ON_SUCCESS), @@ -1067,7 +1084,7 @@ const ACTION_GROUP_NEITHER_CHANNEL = neitherContentChannelGuidance( */ export const ActionGroupBlockSchema = BaseSchema.extend({ type: z.literal('action:group'), - ...PUBLIC_BLOCK_ENVELOPE, + ...NODE_ENVELOPE, properties: propsBag('action:group', stripImportedDefaults(SpecActionGroupPropsSchema)), // objectui#10872 batch 5: the renderer reads NEITHER content channel, so both are refused by name, // each kept a MEMBER (see "The content channels" above). @@ -1095,7 +1112,7 @@ const ACTION_MENU_NEITHER_CHANNEL = neitherContentChannelGuidance( */ export const ActionMenuBlockSchema = BaseSchema.extend({ type: z.literal('action:menu'), - ...PUBLIC_BLOCK_ENVELOPE, + ...NODE_ENVELOPE, properties: propsBag('action:menu', stripImportedDefaults(SpecActionMenuPropsSchema)), // objectui#10872 batch 5: the renderer reads NEITHER content channel, so both are refused by name, // each kept a MEMBER (see "The content channels" above). From c4edd1d1066d50d88083c940864298f1fd57b49c Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 30 Sep 2026 20:34:04 +0000 Subject: [PATCH 2/4] test(types): name batch 9's arms in the `NODE_ENVELOPE` census reason and the imported-defaults note (objectui#10872) Text only. The census reads the row's presence, not its words. Claude-Session: https://claude.ai/code/session_0122Knsowci76D2rBWReCzzZ Co-authored-by: Claude --- .../types/src/__tests__/imported-defaults-8317.test.ts | 3 ++- packages/types/src/__tests__/zod-mirror-parity.test.ts | 9 +++++++-- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/packages/types/src/__tests__/imported-defaults-8317.test.ts b/packages/types/src/__tests__/imported-defaults-8317.test.ts index 72354db8f7..9ff2e07cff 100644 --- a/packages/types/src/__tests__/imported-defaults-8317.test.ts +++ b/packages/types/src/__tests__/imported-defaults-8317.test.ts @@ -301,7 +301,8 @@ const IMPORTED: Array = [ ['ElementDataSourceSchema', SpecElementDataSourceSchema], // objectui#10872 batch 8: every public-block arm declares the node-level // `responsiveStyles` as the spec's `ResponsiveStylesSchema` (one shared - // `NODE_ENVELOPE`), crossed through this boundary like every other + // `NODE_ENVELOPE`, which batch 9 also spread into `flex`, `object-grid` and + // `object-chart`), crossed through this boundary like every other // read. It carries no default and reaches no `z.lazy`, so the strip is the // identity function — the row is here because the census below requires // every imported symbol to be measured, not because the strip does work. diff --git a/packages/types/src/__tests__/zod-mirror-parity.test.ts b/packages/types/src/__tests__/zod-mirror-parity.test.ts index c7c5b044d2..689f19e6c3 100644 --- a/packages/types/src/__tests__/zod-mirror-parity.test.ts +++ b/packages/types/src/__tests__/zod-mirror-parity.test.ts @@ -4338,9 +4338,14 @@ const EXCLUSIONS: Readonly> = { // `properties`: today `responsiveStyles` alone, the spec's // `ResponsiveStylesSchema` by reference. The arm reasons above describe the // members each arm adds of its own; this fragment is shared by all of them. - // Pinned in `./public-block-responsive-styles-10872.test.ts`. + // Pinned in `./public-block-responsive-styles-10872.test.ts`. Batch 9 spread + // it into three registered pairs as well (`layout.zod.ts#FlexSchema`, + // `objectql.zod.ts#ObjectGridSchema`, `objectql.zod.ts#ObjectChartSchema`), + // whose TS twins declare the member as the spec's `ResponsiveStyles`, and + // renamed it from `PUBLIC_BLOCK_ENVELOPE`. Pinned in + // `./flat-arm-responsive-styles-10872.test.ts`. 'public-blocks.zod.ts#NODE_ENVELOPE': - "a shape FRAGMENT, not a schema and not a mirror — `{ responsiveStyles }`, the spec's `ResponsiveStylesSchema` by reference (the schema `PageComponentSchema.responsiveStyles` declares), spread into every public-block arm so the one declaration is shared; no TS declaration in this package restates it", + "a shape FRAGMENT, not a schema and not a mirror — `{ responsiveStyles }`, the spec's `ResponsiveStylesSchema` by reference (the schema `PageComponentSchema.responsiveStyles` declares), spread into every public-block arm and into the `flex`, `object-grid` and `object-chart` mirrors so the one declaration is shared; no TS declaration restates the fragment itself, and each of those three pairs' TS twins declares the member as the spec's `ResponsiveStyles` type", // objectui#10919 — the `cloud:plan-status` widget's arm, declared locally from // the widget's read points: `@objectstack/spec` has no row for a `cloud:` // widget, and the widget's props type lives beside it in `@object-ui/app-shell`, From 2568876116043da90cd82fc9f7ea6b035310cdfd Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 30 Sep 2026 20:48:03 +0000 Subject: [PATCH 3/4] fix(types): keep `object-view`'s `table` slot from gaining `responsiveStyles` (objectui#10872, batch 9) The slot takes `ObjectGridSchema`'s members, so declaring the key on the grid put it in the slot too, where nothing compiles it: the view draws its grid as a component. The slot now refuses `table.responsiveStyles` by name on both faces, as it refuses the other node-level keys (objectui#10976), with a reason of its own. The two member pins that read the TypeScript twins name the one new member: the grid's count, and the one member `flex` declares and `stack` does not. Claude-Session: https://claude.ai/code/session_0122Knsowci76D2rBWReCzzZ Co-authored-by: Claude --- .changeset/10872-flat-arm-responsive-styles.md | 1 + .../__tests__/object-view-slot-key-lists.test.ts | 16 +++++++++++----- .../stack-schema-emitted-members.test.ts | 12 ++++++++++-- packages/types/src/layout.ts | 3 ++- packages/types/src/zod/objectql.zod.ts | 11 +++++++++++ 5 files changed, 35 insertions(+), 8 deletions(-) diff --git a/.changeset/10872-flat-arm-responsive-styles.md b/.changeset/10872-flat-arm-responsive-styles.md index 2133b759bd..f0460e5f7a 100644 --- a/.changeset/10872-flat-arm-responsive-styles.md +++ b/.changeset/10872-flat-arm-responsive-styles.md @@ -17,3 +17,4 @@ - The tolerant face (`safeValidateSchema`, the face `objectui validate` runs) used to keep any node-level `responsiveStyles` value on these arms unjudged. It now refuses a value the spec refuses, at `responsiveStyles`, with the spec's own issue: a breakpoint the spec does not have (`md`), a number instead of a map, or a style value that is neither a string nor a number. `SchemaRenderer` applied nothing for a number or for a map with no spec breakpoint. It emitted a non-CSS value inside a valid breakpoint verbatim, where the browser dropped it, and it applied the valid breakpoints of a mixed map. - In TypeScript, a `responsiveStyles` value that is not the spec's `ResponsiveStyles` no longer type-checks on these three interfaces. +- An `object-view`'s `table` slot, which takes `ObjectGridSchema`'s members, does not gain the key. `ObjectView` draws its grid as a component, not as a schema node, so nothing compiles a `responsiveStyles` map written in `table`. The slot therefore refuses `table.responsiveStyles` by name, with that reason, on both faces, as it refuses the other node-level keys (objectui#10976). The tolerant face used to keep it unjudged, and the TypeScript slot never declared it. diff --git a/packages/types/src/__tests__/object-view-slot-key-lists.test.ts b/packages/types/src/__tests__/object-view-slot-key-lists.test.ts index 3382dfa816..ce1768f9e1 100644 --- a/packages/types/src/__tests__/object-view-slot-key-lists.test.ts +++ b/packages/types/src/__tests__/object-view-slot-key-lists.test.ts @@ -33,6 +33,9 @@ * objectui#11070 then declared `dataSource` (the spec's per-element binding) * on both source schemas and the guard named it again: the `form` slot carries * it, and the `table` slot withholds it as a record source the view owns. + * objectui#10872 batch 9 declared `responsiveStyles` on `ObjectGridSchema`, and + * the `table` slot withholds it as a node-level key. Section 1 pins the counts + * as they stand; the "63 and 69" above are the reading of their day. * * Nothing errored — the index signature answers every key as `any` — so the * symptoms were in the tools that READ the declaration: `table: { colunms: 3 }` @@ -139,10 +142,12 @@ const TABLE_WITHHELD_BY_REASON = { /** The view owns it: its own record source, its own row click, its grid's identity. */ viewOwned: ['bind', 'data', 'dataSource', 'id', 'navigation', 'onNavigate', 'staticData'], /** - * A node-level `BaseSchema` key. `ObjectView` draws its grid as a component, - * not as a schema node, so no renderer applies one of these to it. + * A node-level key: a `BaseSchema` key, or `responsiveStyles`, which + * `ObjectGridSchema` declares since objectui#10872 batch 9. `ObjectView` draws + * its grid as a component, not as a schema node, so no renderer applies one + * of these to it. */ - nodeLevel: ['ariaLabel', 'disabled', 'disabledOn', 'hidden', 'hiddenOn', 'style', 'testId', 'visible', 'visibleOn', 'visibleWhen'], + nodeLevel: ['ariaLabel', 'disabled', 'disabledOn', 'hidden', 'hiddenOn', 'responsiveStyles', 'style', 'testId', 'visible', 'visibleOn', 'visibleWhen'], /** The legacy alias of a relayed key: `bulkActions`, `resizable`. */ alias: ['batchActions', 'resizableColumns'], } as const; @@ -255,9 +260,10 @@ function slotType(slot: 'table' | 'form'): ts.Type { * inherited: bump them deliberately when a member is genuinely added. */ describe('the source schemas still declare their full member sets', () => { - it('ObjectGridSchema declares 63 members and carries the #5155 index signature', () => { + // 64 since objectui#10872 batch 9 declared `responsiveStyles` (withheld from the slot, above). + it('ObjectGridSchema declares 64 members and carries the #5155 index signature', () => { const grid = exportedType('ObjectGridSchema'); - expect(memberNames(grid)).toHaveLength(63); + expect(memberNames(grid)).toHaveLength(64); expect(memberNames(grid)).toEqual(expect.arrayContaining(['columns', 'pageSize', 'rowActions'])); // When this flips to `false`, objectui#5155 has removed the root index // signature and the `Pick` lists this file pins become removable. diff --git a/packages/types/src/__tests__/stack-schema-emitted-members.test.ts b/packages/types/src/__tests__/stack-schema-emitted-members.test.ts index ceaef3e240..8a7a623356 100644 --- a/packages/types/src/__tests__/stack-schema-emitted-members.test.ts +++ b/packages/types/src/__tests__/stack-schema-emitted-members.test.ts @@ -188,9 +188,17 @@ describe('the emitted declaration is readable (guards every assertion below)', ( /* ── 2. The measurement ──────────────────────────────────────────────────── */ describe('StackSchema ships the members it declares (objectui#6151)', () => { - it('declares EXACTLY what FlexSchema declares', () => { + it('declares EXACTLY what FlexSchema declares, less the one member only `flex` has a producer for', () => { // Before the fix: StackSchema declared ['type'] against FlexSchema's 25. - expect(declaredMembers('StackSchema')).toEqual(declaredMembers('FlexSchema')); + // objectui#10872 batch 9 declared the node-level `responsiveStyles` on + // `FlexSchema` alone (a producer writes it on `flex` nodes, none on `stack`), + // so it is the one member the two do not share. It is named, so a second + // difference still turns this red. + const FLEX_ONLY = ['responsiveStyles']; + expect(declaredMembers('FlexSchema')).toEqual(expect.arrayContaining(FLEX_ONLY)); + expect(declaredMembers('StackSchema')).toEqual( + declaredMembers('FlexSchema').filter((member) => !FLEX_ONLY.includes(member)), + ); }); it.each(['gap', 'children', 'align', 'justify', 'direction', 'wrap', 'className'])( diff --git a/packages/types/src/layout.ts b/packages/types/src/layout.ts index 1647b8253a..d1eba1b5bc 100644 --- a/packages/types/src/layout.ts +++ b/packages/types/src/layout.ts @@ -750,7 +750,8 @@ export interface FlexSchema extends BaseSchema, FlexLayoutProps { * * Declares the same members as {@link FlexSchema} — see {@link FlexLayoutProps} * for why they are shared through a third interface rather than derived with an - * `Omit` (objectui#6151). + * `Omit` (objectui#6151) — except `responsiveStyles`, which `FlexSchema` + * declares on its own (objectui#10872 batch 9). */ export interface StackSchema extends BaseSchema, FlexLayoutProps { type: 'stack'; diff --git a/packages/types/src/zod/objectql.zod.ts b/packages/types/src/zod/objectql.zod.ts index 2aa44c2822..96fffd3a85 100644 --- a/packages/types/src/zod/objectql.zod.ts +++ b/packages/types/src/zod/objectql.zod.ts @@ -759,6 +759,17 @@ const OBJECT_VIEW_TABLE_WITHHELD = { onNavigate: tableKeyRefusal('onNavigate', TABLE_KEY_ROW_CLICK), placeholder: tableKeyRefusal('placeholder', TABLE_KEY_UNREAD), resizableColumns: tableKeyRefusal('resizableColumns', 'it is the legacy alias of `resizable`. Write `resizable`.'), + // objectui#10872 batch 9 declared the node-level `responsiveStyles` on + // `ObjectGridSchema`; the view draws its grid as a component, so nothing + // compiles it inside `table`. Without this row the slot would have gained it. + // Its own reason, not `TABLE_KEY_NODE_LEVEL`: that one sends the key to the + // object-view node, whose arm does not declare `responsiveStyles`, so the + // strict face would refuse it there too. + responsiveStyles: tableKeyRefusal( + 'responsiveStyles', + 'the view draws its grid as a component, not as a schema node, so nothing compiles a `responsiveStyles` map ' + + 'written here. Delete it.', + ), rowSpecActions: tableKeyRefusal('rowSpecActions', `${TABLE_KEY_UNREAD} Write \`rowActions\`.`), showFilters: tableKeyRefusal('showFilters', TABLE_KEY_UNREAD), staticData: tableKeyRefusal('staticData', TABLE_KEY_RECORD_SOURCE), From c31dff6c1f16a78d9256b21167364da2432cc345 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 30 Sep 2026 21:01:52 +0000 Subject: [PATCH 4/4] test(types): make the batch-9 pin's `Equal` control an undeclared twin, not a bare `any` (objectui#10872) `stack` declares no `responsiveStyles`, so its member resolves through the index signature: the exact shape the three twins had before this batch. No explicit `any` is left in the file. Claude-Session: https://claude.ai/code/session_0122Knsowci76D2rBWReCzzZ Co-authored-by: Claude --- .../flat-arm-responsive-styles-10872.test.ts | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/packages/types/src/__tests__/flat-arm-responsive-styles-10872.test.ts b/packages/types/src/__tests__/flat-arm-responsive-styles-10872.test.ts index cf130c92ab..613dc4e5d7 100644 --- a/packages/types/src/__tests__/flat-arm-responsive-styles-10872.test.ts +++ b/packages/types/src/__tests__/flat-arm-responsive-styles-10872.test.ts @@ -63,7 +63,7 @@ import { StrictAnyComponentSchema, safeValidateSchema, } from '../zod/index.zod.js'; -import type { FlexSchema as TsFlexSchema } from '../layout'; +import type { FlexSchema as TsFlexSchema, StackSchema as TsStackSchema } from '../layout'; import type { ObjectChartSchema as TsObjectChartSchema, ObjectGridSchema as TsObjectGridSchema } from '../objectql'; /* ── Type-level pins (compiled by `tsc -p tsconfig.test.json`) ─────────────── */ @@ -90,8 +90,14 @@ export type assertionTsTwinIsTheSpecType = [ Expect>, Expect>, ]; -/** The helper can FAIL: `any` is not the spec's type. */ -export type assertionEqualRefusesAny = Expect, false>>; +/** + * The helper can FAIL, on the exact shape a missing declaration takes: `stack` + * declares no `responsiveStyles`, so the member resolves through the index + * signature, and `Equal` refuses it. + */ +export type assertionUndeclaredTwinIsRefused = Expect< + Equal, false> +>; /* ── The population ───────────────────────────────────────────────────────── */