Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions .changeset/10872-flat-arm-responsive-styles.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
---
'@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.
- 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.
307 changes: 307 additions & 0 deletions packages/types/src/__tests__/flat-arm-responsive-styles-10872.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,307 @@
/**
* 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, 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`) ─────────────── */

type Equal<A, B> =
(<T>() => T extends A ? 1 : 2) extends (<T>() => T extends B ? 1 : 2) ? true : false;
type Expect<T extends true> = T;
type ShapeOf<M> = M extends { shape: infer S } ? S : never;
type InputOf<T> = T extends z.ZodType ? z.input<T> : never;

/** Each zod arm's member accepts exactly the spec's published type, absent allowed. */
export type assertionZodMemberIsTheSpecType = [
Expect<Equal<InputOf<ShapeOf<typeof FlexSchema>['responsiveStyles']>, SpecResponsiveStyles | undefined>>,
Expect<Equal<InputOf<ShapeOf<typeof ObjectGridSchema>['responsiveStyles']>, SpecResponsiveStyles | undefined>>,
Expect<Equal<InputOf<ShapeOf<typeof ObjectChartSchema>['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<Equal<TsFlexSchema['responsiveStyles'], SpecResponsiveStyles | undefined>>,
Expect<Equal<TsObjectGridSchema['responsiveStyles'], SpecResponsiveStyles | undefined>>,
Expect<Equal<TsObjectChartSchema['responsiveStyles'], SpecResponsiveStyles | undefined>>,
];
/**
* 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<Equal<TsStackSchema['responsiveStyles'], SpecResponsiveStyles | undefined>, 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<string, Arm> {
const out = new Map<string, Arm>();
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<unknown> } } | 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<string> = 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<readonly [string, unknown]> = [
['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<string, unknown>;
const { children: _children, ...bare } = rest;
const node = { ...bare, [key]: { x: 1 } };
expect(refusedKeys(StrictAnyComponentSchema.safeParse(node))).toEqual([key]);
});
});
3 changes: 2 additions & 1 deletion packages/types/src/__tests__/imported-defaults-8317.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -301,7 +301,8 @@ const IMPORTED: Array<readonly [string, z.ZodType]> = [
['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`, 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.
Expand Down
Loading
Loading