Skip to content

Commit 54a7830

Browse files
feat(types): declare node-level responsiveStyles on every public-block arm, by reference to the spec (objectui#10872, batch 8) (#11260)
Part of #10872 Clause-②: yes Batch 8 of objectui#10872, the envelope-key batch. It declares the node-level `responsiveStyles` that `@objectstack/spec`'s `PageComponentSchema` declares on every public-block arm, by reference to the spec. The change is additive, and it is the only envelope key this batch declares. Claim `5917584827`, seat `domain:ui#1`, session `https://claude.ai/code/session_0122Knsowci76D2rBWReCzzZ`. **Clause-② yes (widening).** The strict authoring face (`StrictAnyComponentSchema`) now accepts a node-level `responsiveStyles` on the 30 public-block arms: the 27 in `PublicBlockComponentSchema` and the 3 in `ObjectQLPublicBlockComponentSchema`. Before this, it refused the key by name. The tolerant face (`safeValidateSchema`) already accepted the key, but never judged it. It now judges it the way the spec does. The one narrowing is on the tolerant face: a value the spec refuses (a `md` breakpoint, a number, a non-CSS value) no longer passes. The renderer never applied such a value (probe below). Changeset: `.changeset/10872-envelope-responsive-styles.md`, `'@object-ui/types': minor`. ## What changed - `packages/types/src/zod/public-blocks.zod.ts` adds `PUBLIC_BLOCK_ENVELOPE = { responsiveStyles }`. The member is `stripImportedDefaults(SpecResponsiveStylesSchema).optional()`, the construct `dataSource` uses. It is written once and spread into each of the 27 arms. Like `propsBag`, the fragment is internal: `index.zod.ts` does not re-export it (the rebuilt `dist/zod/index.zod.d.ts` has 0 hits for it; the control `PublicBlockComponentSchema` has 2). - `packages/types/src/zod/objectql.zod.ts`: the three ObjectQL public-block arms (`object-metric`, `object-master-detail-form`, `object-timeline`) spread the same fragment. This is the fold that seat note `5866125199` asked for. - It is not declared on `BaseSchema`, and nothing else widens. The other five envelope keys (`events`, `dataSource`, `aria`, `visibility`, `responsive`) stay undeclared. `dataSource` stays on the four arms that already read it. - Census rows this change forces, and no others: - `imported-defaults-8317.test.ts` gets an `IMPORTED` row for `ResponsiveStylesSchema`. The census `every symbol the mirrors import is covered by the differential above` requires it for every import, even though this schema carries no default. - `zod-mirror-parity.test.ts` gets an `EXCLUSIONS` row for the new `export const` (a shape fragment, not a mirror). - The 30 per-arm `EXCLUSIONS` reasons were left as they are: no test reads them, and the new row's comment names the shared fragment. - New pin `packages/types/src/__tests__/public-block-responsive-styles-10872.test.ts`, 190 tests: - a `page:section` bag node and an `object-metric` bag node with a valid map parse on both faces, and the map comes back unchanged. Control: the spec's `PageComponentSchema` accepts the same node; - every arm accepts a node carrying only `responsiveStyles` on the strict face; - three invalid values are refused on both faces with exactly the issue list (code, path, message) the spec emits for the same node, read at run time; - the member is one shared object across all 30 arms, and its breakpoint schemas are the spec's own objects; - `BaseSchema` does not declare the key, and a `flex` node is still refused by name on the strict face, which is the boundary control; - no arm gained any of the other five keys (`dataSource` only on the four named arms), and each is still refused by name on the strict face (146 cases); - a type-level row: the member's input type equals the spec's `ResponsiveStyles`, or undefined. - `packages/types/src/zod/README.md` gets one line. ## Zone 2 readings (the order's mechanism assumptions) 1. **The pull, re-driven at this tip.** objectui `ae0b9d3905` and objectstack `4d0b9cd542` (a depth-1 clone into the scratchpad; the shared checkout was not fetched or edited). The instrument is `scripts/body-dialect-census.mjs` (`scanNodes`), driven by a scratch script that is not committed. The population is the 30 arm literals, read at run time from the two unions. `responsiveStyles` still has producers: - the objectstack showcase writes it on 17 public-block nodes and 34 nodes of all types (`command-center.page.ts`, `styling-gallery.page.ts`, `test/sdui-styling/pricing-showcase.ts`); - `skills/objectstack-ui/rules/pages.md` teaches it on 2 public-block nodes. Non-test writers of the other five keys on arms that lack them: 0. The only hits are `responsive` in the spec's own CHANGELOG and in its retired-key conversion, and `dataSource` on `element:number` in objectui's guide, where the arm already declares it. Lit controls: the detector fires on the test buckets (`responsiveStyles` 12 in objectui, 14 in objectstack). Population: 644 objectstack nodes of the 30 types, 545 of them carrying `properties`. 2. **The spec's declaration.** The installed `@objectstack/spec` 17.5.0 `dist/ui` exports `ResponsiveStylesSchema` by name, and `PageComponentSchema` declares `responsiveStyles: ResponsiveStylesSchema.optional()`. The schema is a strict `{ large, medium, small, xsmall }` with no default. 3. **Where to declare.** Measured before the edit, on both a `page:section` node and an `object-metric` node: - the strict face refused the key (`unrecognized_keys`); - the tolerant face accepted it unjudged, including `responsiveStyles: 7` and `{ md: ... }`. Each arm is a `BaseSchema.extend`, so one fragment spread into the public-block arms closes it without touching `BaseSchema`. `BaseSchema` is therefore not the only sound place, and this batch does not raise a `needs_decision`. 4. **The renderer honours it.** A scratch probe mounted a `page:section` with `responsiveStyles` through the real `SchemaRenderer` and registry, then was deleted without being committed: - the section carried the scope class `os-s-probe-10872`; - the injected style held the `large` base plus `@media (max-width: 640px)` and `@media (max-width: 479px)` overrides; - the child in `properties.children` still rendered inside the section. Controls: without the key, no scope class and no style tag. A `{ md: ... }` map (which the tolerant face used to pass) produced no style tag at all. 5. **Census.** See the two forced rows above. The `IMPORTED` row is forced by the census unconditionally, not only when the schema carries defaults. ## Verification (every run through `os-verify-lock.sh`, slot `issue-10872`) - **Red first.** The pin was run on the unmodified source (base `ae0b9d3905`): `Tests 40 failed | 150 passed (190)`. The 40 failures were the two declaration rows, the two bag-node rows, 30 strict per-arm rows and 6 invalid-value rows. The 150 controls were green. - **Ablation** at `73d9479eef`, with the implementation committed first, through objectstack's `scripts/ablation-replace.mjs` in WRAP mode, with a trap-armed restore: - the mutation replaced the anchor `export const PUBLIC_BLOCK_ENVELOPE = {` with an empty fragment. The tool reported `ok mutation landed: anchor 1 to 0, blob de912a0 to 5fdcec5bb9ee`; - the pin went to `Tests 40 failed | 150 passed (190)`, exactly the red-first set; - restore: `ok restored: blob == HEAD (de912a0) and git diff HEAD is empty`. The first attempt was a no-op. Its replacement text contained the anchor, so the tool refused (anchor count 1 to 1) and ran nothing. It was re-run with a corrected replacement. The pin imports `../zod/index.zod.js` by relative source path, so there is no dist leg. - **Reverse type check through the rebuilt dist.** A scratch consumer in `packages/components`, resolved to `packages/types/dist/zod/index.zod.d.ts`, got exactly one error, TS2353 (`'md' does not exist in type`), on the invalid line. With that line removed, `tsc` exits 0. The scratch files were deleted. - **Final head `361c5428ff`** (`main` merged in for objectui#8069, which also edits `imported-defaults-8317.test.ts` in a different row; no conflict). One hold, `VERDICT command-exit 0`: - `pnpm install --frozen-lockfile` exit 0; - `pnpm --filter @object-ui/types build` exit 0 (`dist completeness: 1 package(s) complete`); - `pnpm --filter @object-ui/types type-check` exit 0. The new pin is listed by `tsc -p tsconfig.test.json --listFilesOnly`; - `pnpm exec vitest run packages/types/`: `Test Files 294 passed (294)`, `Tests 7332 passed (7332)`; - `pnpm exec vitest run packages/cli/`: `Test Files 23 passed (23)`, `Tests 335 passed (335)`. - **Arm-list consumer sweep** (the ruling's order lesson), 53 files outside `packages/types` and `packages/cli`. They came from `git grep -lw` on `AnyComponentSchema|StrictAnyComponentSchema|safeValidateSchema|ObjectQLPublicBlockComponentSchema|PUBLIC_BLOCKS|propsBag|nodeOracles|KNOWN_SCHEMA_TYPES`, the four named files, and a widening to every `...BlockSchema` arm name, `PublicBlockComponentSchema`, `SchemaNodeSchema`, `StrictSchemaNodeSchema` and `deriveStrictAuthoringSchema`. Result at `361c5428ff`: `Test Files 53 passed (53)`, `Tests 1033 passed (1033)`. - The 24 `scripts/__tests__` files that read `.changeset/` (owed because the diff adds a changeset) gave `Test Files 24 passed (24)` / `Tests 914 passed (914)` at `361c5428ff`, the same counts they gave at `73d9479eef`. - **Gates at `361c5428ff`, every one exit 0:** - `check:handler-key-reads` (`OK 140 arm(s) ... every judged read is a declared member of its arm`); - `check:spec-symbols`; - `check:new-line-citations` (`VERDICT new-cross-file-line-citations: 0 new citation(s)`); - `check-changeset-presence.mjs` (`6 source file(s) of 1 released package(s) changed, and this change declares 1 changeset(s)`); - `changeset:check` (`No changeset declares a major bump`); - `check:control-bytes`; - `check:changeset-claims` (report-only; 33 pending changesets name a touched file, and none claims anything about node-level `responsiveStyles`); - `check:pending-changeset-literals`; - `markdown-test-inputs.mjs --audit`; - governed guard: `NOT GOVERNED`. - **eslint, a declared narrowing:** the five changed TS files, run with `--no-inline-config --format json`, gave 5 files, 0 errors and 3 warnings, all pre-existing `no-explicit-any` on lines this diff does not touch. `--print-config` shows no `parserOptions.project` or `projectService`, so linting is not type-aware and no untouched file's verdict can move. The repo-wide `pnpm lint` run belongs to CI. ## Acceptance notes - **Showcase nodes outside the public-block arms stay refused.** Objectstack's showcase writes `responsiveStyles` on 17 nodes whose types are not public blocks, and its skill on 1 more (by type, the 18 are `flex` 16, `object-chart` 1 and `object-grid` 1). The strict face still refuses them, because `BaseSchema` is not widened; that would be a different accept-set change. This bites only when PR objectui#11069 moves `objectui validate` onto the strict face. That sequencing belongs to its un-park census, and batch 7 already pointed it on objectui#5250. This PR files nothing for it. - The five other envelope keys stay undeclared: they have no producer. `record:line_items` and the flat-props refusal half are not touched. The files of draft PR objectui#11069 (`packages/cli/**` and the ratchet test) are not touched. --- _Generated by [Claude Code](https://claude.ai/code/session_0122Knsowci76D2rBWReCzzZ)_ Co-authored-by: Claude <noreply@anthropic.com>
1 parent 02a2295 commit 54a7830

7 files changed

Lines changed: 377 additions & 3 deletions

File tree

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
---
2+
'@object-ui/types': minor
3+
---
4+
5+
Every ADR-0080 public-block arm accepts the node-level `responsiveStyles` that `@objectstack/spec`'s `PageComponentSchema` declares, and judges it as the spec does (objectui#10872, batch 8).
6+
7+
**Clause-②: yes (widening)** — the strict authoring face (`StrictAnyComponentSchema`) now accepts a node-level `responsiveStyles` on the 30 public-block arms (`PublicBlockComponentSchema` and `ObjectQLPublicBlockComponentSchema`), where it refused the key by name. On the tolerant face (`safeValidateSchema`) the key was already accepted, unjudged; it is now judged, so a value the spec refuses is refused there too.
8+
9+
**What it was.** `responsiveStyles` is the ADR-0065 per-breakpoint style map that `SchemaRenderer` compiles to CSS scoped to the node. The spec declares it on every page component, the objectstack showcase writes it on public-block nodes (`element:text`, `element:button`, `object-metric`, `page:card`) and objectstack's UI skill teaches it. No arm here declared it, so a spec-valid node failed the strict face with `unrecognized_keys`, and the tolerant face kept any value it was given: `responsiveStyles: 7`, or a breakpoint the spec does not have (`md`), parsed clean and styled nothing.
10+
11+
**What changed, in observable terms.**
12+
13+
- Each public-block arm declares `responsiveStyles` as the spec's `ResponsiveStylesSchema`, by reference, so its breakpoints (`large`, `medium`, `small`, `xsmall`), its strictness and its own unknown-breakpoint message come from the spec. All 30 arms share one declaration.
14+
- A node such as `{ "type": "page:section", "responsiveStyles": { "large": { "padding": "var(--space-6)" }, "small": { "padding": "var(--space-4)" } } }` parses on both faces, and the map comes back unchanged.
15+
- A value the spec refuses (a `md` breakpoint, a number instead of a map, a style value that is neither a string nor a number) is refused on both faces at `responsiveStyles`, with the same issue the spec reports. On the tolerant face this is a narrowing: such a value used to pass unjudged, and `SchemaRenderer` would not have applied it.
16+
- Nothing else widens. `BaseSchema` does not declare `responsiveStyles`, so the strict face still refuses it on the other arms. The other node-level keys the spec declares (`events`, `aria`, `visibility`, `responsive`, and `dataSource` outside the arms that already read it) stay undeclared and are still refused by name on the strict face.

‎packages/types/src/__tests__/imported-defaults-8317.test.ts‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,8 @@ import {
113113
// objectui#10872 batch 2 — `element:number`'s row, and the node's `dataSource`.
114114
ElementNumberPropsSchema as SpecElementNumberPropsSchema,
115115
ElementDataSourceSchema as SpecElementDataSourceSchema,
116+
// objectui#10872 batch 8 — the node-level `responsiveStyles` every public-block arm declares.
117+
ResponsiveStylesSchema as SpecResponsiveStylesSchema,
116118
objectNavTargetExclusivity,
117119
checkListViewCalendarVisualization,
118120
checkPageSourceCompleteness,
@@ -297,6 +299,13 @@ const IMPORTED: Array<readonly [string, z.ZodType]> = [
297299
// like every other one.
298300
['ElementNumberPropsSchema', SpecElementNumberPropsSchema],
299301
['ElementDataSourceSchema', SpecElementDataSourceSchema],
302+
// objectui#10872 batch 8: every public-block arm declares the node-level
303+
// `responsiveStyles` as the spec's `ResponsiveStylesSchema` (one shared
304+
// `PUBLIC_BLOCK_ENVELOPE`), crossed through this boundary like every other
305+
// read. It carries no default and reaches no `z.lazy`, so the strip is the
306+
// identity function — the row is here because the census below requires
307+
// every imported symbol to be measured, not because the strip does work.
308+
['ResponsiveStylesSchema', SpecResponsiveStylesSchema],
300309
// objectui#11070: `FormFieldSchema` reads spec `FieldSchema` members by
301310
// reference (the field metadata a hand-authored form writes on the entry
302311
// itself; `FormFieldSchema.shape` lists them), and `multiple` carries the
Lines changed: 257 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,257 @@
1+
/**
2+
* ObjectUI
3+
* Copyright (c) 2024-present ObjectStack Inc.
4+
*
5+
* This source code is licensed under the MIT license found in the
6+
* LICENSE file in the root directory of this source tree.
7+
*/
8+
9+
/**
10+
* Every ADR-0080 public-block arm declares the node-level `responsiveStyles`
11+
* that `@objectstack/spec`'s `PageComponentSchema` declares, by reference
12+
* (objectui#10872, batch 8 — the envelope-key batch).
13+
*
14+
* ## The defect these pin
15+
*
16+
* `PageComponentSchema` declares six node-level keys beside `properties`:
17+
* `events`, `responsiveStyles`, `dataSource`, `aria`, `visibility` and the
18+
* retired `responsive`. `responsiveStyles` is the one with a producer — the
19+
* objectstack showcase writes it on public-block nodes, objectstack's UI skill
20+
* teaches it, and `SchemaRenderer` compiles it to id-scoped CSS on every node
21+
* (ADR-0065). No arm here declared it, so:
22+
*
23+
* - the STRICT authoring face refused a spec-valid node by name
24+
* (`unrecognized_keys`), and
25+
* - the TOLERANT face passed it unjudged — `responsiveStyles: 7`, or a
26+
* breakpoint the spec does not have (`md`), parsed clean and then styled
27+
* nothing.
28+
*
29+
* ## What is and is not widened
30+
*
31+
* The key is declared on the public-block arms only — `PublicBlockComponentSchema`
32+
* and `ObjectQLPublicBlockComponentSchema` — through ONE shared fragment, and
33+
* NOT on `BaseSchema`, which would widen every arm in `AnyComponentSchema`
34+
* (a different accept-set change, not granted by the batch's ruling). The other
35+
* five envelope keys stay undeclared: they have no producer. The pins below
36+
* hold both boundaries.
37+
*
38+
* Every verdict is read against the INSTALLED spec at run time, not against
39+
* transcribed text: the arm's member is compared with the spec's own
40+
* `PageComponentSchema.responsiveStyles`, and a refusal's message is compared
41+
* with the one the spec itself emits for the same node.
42+
*/
43+
44+
import { describe, expect, it } from 'vitest';
45+
import type { z } from 'zod';
46+
import {
47+
PageComponentSchema,
48+
ResponsiveStylesSchema as SpecResponsiveStylesSchema,
49+
type ResponsiveStyles as SpecResponsiveStyles,
50+
} from '@objectstack/spec/ui';
51+
52+
import {
53+
BaseSchema,
54+
ObjectMetricBlockSchema,
55+
ObjectQLPublicBlockComponentSchema,
56+
PageSectionBlockSchema,
57+
PublicBlockComponentSchema,
58+
StrictAnyComponentSchema,
59+
safeValidateSchema,
60+
} from '../zod/index.zod.js';
61+
62+
/* ── Type-level pin (compiled by `tsc -p tsconfig.test.json`) ─────────────── */
63+
64+
type Equal<A, B> =
65+
(<T>() => T extends A ? 1 : 2) extends (<T>() => T extends B ? 1 : 2) ? true : false;
66+
type Expect<T extends true> = T;
67+
type ShapeOf<M> = M extends { shape: infer S } ? S : never;
68+
type InputOf<T> = T extends z.ZodType ? z.input<T> : never;
69+
70+
/** The member accepts exactly the spec's published type, absent allowed — on a bag arm and an ObjectQL arm. */
71+
export type assertionResponsiveStylesIsTheSpecType = [
72+
Expect<Equal<InputOf<ShapeOf<typeof PageSectionBlockSchema>['responsiveStyles']>, SpecResponsiveStyles | undefined>>,
73+
Expect<Equal<InputOf<ShapeOf<typeof ObjectMetricBlockSchema>['responsiveStyles']>, SpecResponsiveStyles | undefined>>,
74+
];
75+
/** The helper can FAIL — a narrower map is not the spec's type. */
76+
export type assertionEqualCanFail = Expect<
77+
Equal<Equal<{ large?: Record<string, string> } | undefined, SpecResponsiveStyles | undefined>, false>
78+
>;
79+
80+
/* ── The population, read off the two unions ─────────────────────────────── */
81+
82+
type Arm = z.ZodObject;
83+
const optionsOf = (union: unknown): Arm[] => (union as { options: Arm[] }).options;
84+
const literalOf = (arm: Arm): string => (arm.shape.type as z.ZodLiteral).value as string;
85+
86+
/** Every public-block arm — the population this batch widens, and no other. */
87+
const ARMS: readonly Arm[] = [
88+
...optionsOf(PublicBlockComponentSchema),
89+
...optionsOf(ObjectQLPublicBlockComponentSchema),
90+
];
91+
const TYPES = ARMS.map(literalOf);
92+
93+
/**
94+
* The arms that already declared `dataSource` before this batch (objectui#10872
95+
* batch 2: `element:number`; objectui#10859 batches 2 and 3: the three ObjectQL
96+
* blocks). NAMED rather than read off the shapes, so an arm that gains
97+
* `dataSource` later reddens the control below instead of joining it silently.
98+
*/
99+
const DECLARES_DATA_SOURCE: ReadonlySet<string> = new Set([
100+
'element:number',
101+
'object-metric',
102+
'object-master-detail-form',
103+
'object-timeline',
104+
]);
105+
106+
/** The five envelope keys the spec declares and this batch leaves undeclared. */
107+
const UNDECLARED_ENVELOPE = ['events', 'dataSource', 'aria', 'visibility', 'responsive'] as const;
108+
109+
/** A spec-valid map: the unconditional base and two `max-width` overrides. */
110+
const VALID_STYLES = {
111+
large: { padding: 'var(--space-6)', gap: '16px' },
112+
small: { padding: 'var(--space-4)' },
113+
xsmall: { display: 'none', order: 2 },
114+
} as const;
115+
116+
/** The two bag nodes the order names: a `page:` container and an ObjectQL block. */
117+
const BAG_NODES = [
118+
{
119+
type: 'page:section',
120+
id: 'kpis',
121+
properties: { children: [{ type: 'element:text', properties: { content: 'Revenue' } }] },
122+
responsiveStyles: VALID_STYLES,
123+
},
124+
{
125+
type: 'object-metric',
126+
id: 'open-deals',
127+
properties: { objectName: 'deal', label: 'Open deals' },
128+
responsiveStyles: { large: { minWidth: '0' }, small: { gridColumn: 'span 2' } },
129+
},
130+
] as const;
131+
132+
/** Values the spec refuses: a breakpoint it does not have, a non-object, a non-CSS value. */
133+
const INVALID_STYLES: ReadonlyArray<readonly [string, unknown]> = [
134+
['a breakpoint the spec does not declare (`md`)', { md: { padding: '8px' } }],
135+
['a number instead of a map', 7],
136+
['a style value that is neither string nor number', { large: { padding: true } }],
137+
];
138+
139+
type Result = { success: boolean; error?: { issues: z.core.$ZodIssue[] } };
140+
141+
/** Every issue as `{ code, path, message }`, the `code` and `path` the stable part. */
142+
const issuesOf = (result: Result) =>
143+
result.success
144+
? []
145+
: result.error!.issues.map((issue) => ({ code: issue.code, path: issue.path.join('.'), message: issue.message }));
146+
147+
/** The keys an `unrecognized_keys` issue names, at any depth of a union. */
148+
function refusedKeys(result: Result): string[] {
149+
if (result.success) return [];
150+
const collect = (issues: readonly z.core.$ZodIssue[]): string[] =>
151+
issues.flatMap((issue) => {
152+
if (issue.code === 'unrecognized_keys') return issue.keys;
153+
if (issue.code === 'invalid_union') return issue.errors.flatMap((arm) => collect(arm));
154+
return [];
155+
});
156+
return collect(result.error!.issues);
157+
}
158+
159+
describe('the population (objectui#10872 batch 8)', () => {
160+
it('reads both public-block unions, and every arm is a distinct block', () => {
161+
// Non-vacuity: the pins below iterate this list.
162+
expect(optionsOf(PublicBlockComponentSchema).length).toBeGreaterThan(20);
163+
expect(optionsOf(ObjectQLPublicBlockComponentSchema).length).toBeGreaterThan(2);
164+
expect(new Set(TYPES).size).toBe(TYPES.length);
165+
// Every named `dataSource` arm is in the population, so the set is not stale.
166+
for (const type of DECLARES_DATA_SOURCE) expect(TYPES).toContain(type);
167+
});
168+
169+
it('the spec still declares `responsiveStyles` on its page component, as `ResponsiveStylesSchema` (lit control)', () => {
170+
const spec = PageComponentSchema.safeParse({ type: 'page:section', responsiveStyles: VALID_STYLES });
171+
expect(spec.success).toBe(true);
172+
expect(SpecResponsiveStylesSchema.safeParse(VALID_STYLES).success).toBe(true);
173+
});
174+
});
175+
176+
describe('`responsiveStyles` is declared on every public-block arm, by reference (objectui#10872 batch 8)', () => {
177+
it('every arm carries the member, and it is ONE shared object — no per-arm copy', () => {
178+
const members = ARMS.map((arm) => arm.shape.responsiveStyles);
179+
expect(members.filter((member) => member === undefined).length, 'an arm without the member').toBe(0);
180+
expect(new Set(members).size).toBe(1);
181+
});
182+
183+
it('the member is the spec\'s own map: the same breakpoints, the same strictness', () => {
184+
const member = (ARMS[0].shape.responsiveStyles as z.ZodOptional).unwrap() as z.ZodObject;
185+
const spec = SpecResponsiveStylesSchema as unknown as z.ZodObject;
186+
expect(Object.keys(member.shape).sort()).toEqual(Object.keys(spec.shape).sort());
187+
// Each breakpoint's value schema is the spec's own object, not a restatement.
188+
for (const key of Object.keys(spec.shape)) expect(member.shape[key]).toBe(spec.shape[key]);
189+
});
190+
191+
it('`BaseSchema` is NOT widened — the declaration is the public blocks\' alone', () => {
192+
expect(Object.keys(BaseSchema.shape)).not.toContain('responsiveStyles');
193+
// Control: a non-public-block node is still refused by name on the strict
194+
// face, so the accept-set change is exactly the arms above. This is the
195+
// boundary of this batch, not a ruling that it must stay.
196+
const flex = { type: 'flex', responsiveStyles: VALID_STYLES };
197+
expect(refusedKeys(StrictAnyComponentSchema.safeParse(flex))).toEqual(['responsiveStyles']);
198+
});
199+
});
200+
201+
describe('a valid `responsiveStyles` is accepted on BOTH faces (objectui#10872 batch 8)', () => {
202+
it.each(BAG_NODES.map((node) => [node.type, node] as const))(
203+
'%s: a bag node with `responsiveStyles` parses on both faces, and the map survives unchanged',
204+
(_type, node) => {
205+
// Lit control: the spec's own page component accepts the same node.
206+
expect(PageComponentSchema.safeParse(node).success).toBe(true);
207+
const tolerant = safeValidateSchema(node);
208+
expect(issuesOf(tolerant)).toEqual([]);
209+
expect((tolerant.data as { responsiveStyles?: unknown }).responsiveStyles).toEqual(node.responsiveStyles);
210+
const strict = StrictAnyComponentSchema.safeParse(node);
211+
expect(issuesOf(strict)).toEqual([]);
212+
expect((strict.data as { responsiveStyles?: unknown }).responsiveStyles).toEqual(node.responsiveStyles);
213+
},
214+
);
215+
216+
it.each(TYPES)('%s: a node carrying only `responsiveStyles` is accepted on the strict face', (type) => {
217+
const node = { type, responsiveStyles: VALID_STYLES };
218+
expect(issuesOf(StrictAnyComponentSchema.safeParse(node))).toEqual([]);
219+
expect(issuesOf(safeValidateSchema(node))).toEqual([]);
220+
});
221+
});
222+
223+
describe('an invalid `responsiveStyles` is refused on BOTH faces, with the spec\'s own issue (objectui#10872 batch 8)', () => {
224+
const cases = BAG_NODES.flatMap((node) =>
225+
INVALID_STYLES.map(([why, value]) => [node.type, why, { ...node, responsiveStyles: value }] as const),
226+
);
227+
228+
it.each(cases)('%s — %s', (_type, _why, node) => {
229+
// The spec's verdict on the same node is the expected one, read at run time.
230+
const spec = issuesOf(PageComponentSchema.safeParse(node));
231+
expect(spec.length, 'lit control: the spec refuses this node').toBeGreaterThan(0);
232+
expect(spec.every((issue) => issue.path.startsWith('responsiveStyles'))).toBe(true);
233+
// Both faces refuse it at the same path, with the same code and message.
234+
expect(issuesOf(safeValidateSchema(node))).toEqual(spec);
235+
expect(issuesOf(StrictAnyComponentSchema.safeParse(node))).toEqual(spec);
236+
});
237+
});
238+
239+
describe('the other five envelope keys stay undeclared (objectui#10872 batch 8)', () => {
240+
it('no arm gained any of them — `dataSource` only where an earlier batch declared it', () => {
241+
const declared = ARMS.flatMap((arm) =>
242+
UNDECLARED_ENVELOPE.filter((key) => key in arm.shape).map((key) => `${literalOf(arm)}#${key}`),
243+
).sort();
244+
expect(declared).toEqual([...DECLARES_DATA_SOURCE].map((type) => `${type}#dataSource`).sort());
245+
});
246+
247+
const cases = TYPES.flatMap((type) =>
248+
UNDECLARED_ENVELOPE.filter((key) => !(key === 'dataSource' && DECLARES_DATA_SOURCE.has(type))).map(
249+
(key) => [type, key] as const,
250+
),
251+
);
252+
253+
it.each(cases)('%s: a node-level `%s` is still refused on the strict face, by name', (type, key) => {
254+
const node = { type, [key]: { x: 1 } };
255+
expect(refusedKeys(StrictAnyComponentSchema.safeParse(node))).toEqual([key]);
256+
});
257+
});

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

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4333,6 +4333,14 @@ const EXCLUSIONS: Readonly<Record<string, string>> = {
43334333
"spec-owned BY REFERENCE — `BaseSchema` + the `action:menu` literal + `properties`, which IS `ComponentPropsMap['action:menu']`, + the objectui#9256 `body` / `children` refusals (objectui#10872 batch 5; its renderer reads neither channel); no TS declaration in this package restates the node",
43344334
'public-blocks.zod.ts#PublicBlockComponentSchema':
43354335
"a union OVER the public-block arms, not an object of its own — its members are accounted for individually above",
4336+
// objectui#10872 batch 8 — the node-level envelope every public-block arm
4337+
// above (and the three ObjectQL public-block arms) spreads beside
4338+
// `properties`: today `responsiveStyles` alone, the spec's
4339+
// `ResponsiveStylesSchema` by reference. The arm reasons above describe the
4340+
// members each arm adds of its own; this fragment is shared by all of them.
4341+
// Pinned in `./public-block-responsive-styles-10872.test.ts`.
4342+
'public-blocks.zod.ts#PUBLIC_BLOCK_ENVELOPE':
4343+
"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",
43364344
// objectui#10919 — the `cloud:plan-status` widget's arm, declared locally from
43374345
// the widget's read points: `@objectstack/spec` has no row for a `cloud:`
43384346
// widget, and the widget's props type lives beside it in `@object-ui/app-shell`,

‎packages/types/src/zod/README.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -240,6 +240,7 @@ The ADR-0080 public blocks `object-metric`, `object-master-detail-form` and `obj
240240

241241
### ADR-0080 Public Blocks (27)
242242
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.
243+
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.
243244
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.
244245
- `PageHeaderBlockSchema`, `PageTabsBlockSchema`, `PageCardBlockSchema`, `PageAccordionBlockSchema`
245246
- `PageSectionBlockSchema`, `PageFooterBlockSchema`, `PageSidebarBlockSchema`

0 commit comments

Comments
 (0)