Skip to content

Commit 2c19f30

Browse files
committed
fix(types)!: the zod mirror refuses a separator key the spec separator does not declare; changeset corrections (objectui#10867 round 1)
- NavigationItemSchema's separator branch refuses every key outside the spec separator's own set (type, id, order), read off the spec AppSchema's navigation union rather than restated, with a message that names the key to drop. objectui validate no longer passes a separator label the save door refuses. - navigation-spec-parity: the separator-label row leaves the divergence list and becomes an agreement assertion; the mirror doc and the NavigationItemSchema ledger reason count four divergences, not five. - New pins: nav-separator-mirror-refusal-10867 (safeValidateSchema, the strict face and the mirror refuse a separator label; a bare separator is accepted), and an EditAppPage row that a cleared logo saves as ''. - Changeset: @object-ui/i18n is minor; the banner names the union-alias, validate and raw-key consequences; the layout, reading, designer and pins wording is corrected. The 10842 dated note keeps only its layout half (frontmatter byte-identical). - spec-derived-unions: the spec-validity pin's comment says what it can and cannot see. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014mXUNuFomfj24w7s1pZzhN
1 parent e2493c7 commit 2c19f30

8 files changed

Lines changed: 175 additions & 22 deletions

‎.changeset/10842-wizard-app-declared-keys.md‎

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -34,9 +34,6 @@ has no app layout, so the wizard's layout choice is not saved.
3434

3535
Migration: move a top-level `favicon` URL to `branding: { favicon: '…' }`.
3636

37-
⚠️ **Dated note, 2026-09-28 — the wizard has no layout choice, and a separator no longer blocks the save — objectui#10867.**
37+
⚠️ **Dated note, 2026-09-28 — the wizard has no layout choice — objectui#10867.**
3838
Later in this same release the wizard's Layout control was removed, with `AppWizardDraft.layout`, so there is no
39-
layout choice left to not save. "Creating an app is no longer refused" did not yet hold for an app with a
40-
navigation separator: the wizard wrote one with a `label`, which the spec's separator does not declare, and that
41-
save was still refused until objectui#10867 made the separator carry only `type`, `id` and `order`. The rest of
42-
this entry still holds.
39+
layout choice left to not save. The rest of this entry still holds.

‎.changeset/10867-app-wizard-saves-spec-app.md‎

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,21 @@
11
---
22
'@object-ui/types': minor
33
'@object-ui/plugin-designer': patch
4-
'@object-ui/i18n': patch
4+
'@object-ui/i18n': minor
55
'@object-ui/layout': patch
66
'@object-ui/app-shell': patch
77
---
88

99
fix(types,plugin-designer)!: the Studio app wizard saves a document the platform accepts, and an edit keeps the stored `accentColor` (objectui#10867)
1010

11-
⚠️ **BREAKING (authoring)**, marked `minor` under this repository's version-alignment rule (a `major` in the fixed group would move all of it off the `@objectstack` major). Two published `@object-ui/types` members narrow: a navigation separator no longer takes a `label`, and `AppWizardDraft.layout` is removed. A TypeScript literal that writes either no longer compiles.
11+
⚠️ **BREAKING (authoring)**, marked `minor` under this repository's version-alignment rule (a `major` in the fixed group would move all of it off the `@objectstack` major). Two published `@object-ui/types` members narrow: a navigation separator no longer takes a `label`, and `AppWizardDraft.layout` is removed. A TypeScript literal that writes either no longer compiles. `NavigationItem` is now a union type alias, not an interface. So code that reads `label` off an unnarrowed `NavigationItem` into a `string` slot (its type is now `string | undefined`) also stops compiling, as does code that spreads an entry-only key onto one, and an `interface` that `extends NavigationItem` or augments it (extend `NavigationEntryItem` instead). `objectui validate` now refuses a separator `label` too: the zod mirror's `NavigationItemSchema` refuses every key on a separator that the spec's separator does not declare. The four `appDesigner` layout keys also leave the published `@object-ui/i18n` packs and `DESIGNER_DEFAULT_TRANSLATIONS`, so an application that calls `t()` with one of them now renders the raw key (unless the call passes a `defaultValue`).
1212

1313
**Clause-②: yes (narrowing)** — the separator arm of `NavigationItem` loses `label`, and `AppWizardDraft` loses `layout`.
1414

15-
- **A separator carries only `type`, `id` and `order`.** `@objectstack/spec`'s separator branch declares exactly those keys, and its `AppSchema` refuses anything else. `NavigationItem` required a `label` on every item, so the wizard's "Add separator" wrote `{ id, type: 'separator', label: '' }`, and the console's create-app and edit-app saves were refused with `422 INVALID_METADATA` (`unrecognized_keys` `['label']` at `navigation.N`). `NavigationItem` is now a union of two arms, discriminated by `type`. `NavigationEntryItem` holds every other nav type and keeps its required `label`. `NavigationSeparatorItem` admits `type`, `id` and `order`, and every other entry key is `?: never` on it. Both arms are exported. Reading an entry-only key off an unnarrowed item still compiles and answers `undefined` on the separator arm. Narrow on `item.type === 'separator'` before relying on `label`. `menuItemToNavigationItem` maps a legacy separator to `{ id, type: 'separator' }` and drops its label. `spec-derived-unions.test.ts` no longer pins the separator `label` as a blocker. It asserts, at both spec tiers, that the separator arm admits the spec separator's keys and no others.
16-
- **`@object-ui/plugin-designer`: the wizard and `NavigationDesigner` write a separator as `{ id, type }`.** `NavigationDesigner` no longer writes a label, icon or visibility onto a separator.
17-
- **`@object-ui/layout` narrows on the separator arm; nothing it renders changes.** `resolveNavItemLabel` answers `''` for a separator, which is what a separator's `label: ''` resolved to before. The mobile bottom nav's leaf list, which already skipped separators, is now typed as entries.
18-
- **`@object-ui/app-shell` narrows the same way; nothing it renders changes.** `useNavPins` registers a favorite only for an entry and leaves a separator unpinned, and the Studio sidebar's navigation walk passes a separator through unchanged.
15+
- **A separator carries only `type`, `id` and `order`.** `@objectstack/spec`'s separator branch declares exactly those keys, and its `AppSchema` refuses anything else. `NavigationItem` required a `label` on every item, so the wizard's "Add separator" wrote `{ id, type: 'separator', label: '' }`, and the console's create-app and edit-app saves were refused with `422 INVALID_METADATA` (`unrecognized_keys` `['label']` at `navigation.N`). `NavigationItem` is now a union of two arms, discriminated by `type`. `NavigationEntryItem` holds every other nav type and keeps its required `label`. `NavigationSeparatorItem` admits `type`, `id` and `order`, and every other entry key is `?: never` on it. Both arms are exported. Reading an entry-only key off an unnarrowed item still compiles, but its type now includes `undefined` (a `label` is `string | undefined`), so passing it where a `string` is required does not. Narrow on `item.type === 'separator'` before relying on `label`. `menuItemToNavigationItem` maps a legacy separator to `{ id, type: 'separator' }` and drops its label. `spec-derived-unions.test.ts` no longer pins the separator `label` as a blocker. It asserts, at both spec tiers, that the separator arm admits the spec separator's keys and no others.
16+
- **`@object-ui/plugin-designer`: the wizard and `NavigationDesigner` write a separator as `{ id, type }`.** `NavigationDesigner` no longer writes a `label` onto a new separator, and its label, icon and visibility patchers skip one.
17+
- **`@object-ui/layout` narrows on the separator arm; nothing it renders changes.** `resolveNavItemLabel` answers `''` for every separator. A stored separator carrying a non-empty `label`, which `menuItemToNavigationItem` produced before this change, used to resolve to that label. No renderer asks it for a separator's label. The mobile bottom nav's leaf list, which already skipped separators, is now typed as entries.
18+
- **`@object-ui/app-shell` narrows the same way; nothing it renders changes.** `useNavPins` registers a favorite only for an entry and leaves a separator as it is, and the Studio sidebar's navigation walk passes a separator through unchanged.
1919
- **`@object-ui/plugin-designer`: `EditAppPage` keeps the stored branding.** The wizard maintains the logo, primary colour and favicon, and its `branding` replaced the stored block, so a stored `accentColor` was dropped on every edit. The console reads that key. The save now keeps every stored `branding` key the spec's `AppBrandingSchema` declares, read from that schema. The wizard's values win for the keys it maintains. A stored key the spec does not declare is still left out.
2020
- **The wizard's Layout control is removed, with `AppWizardDraft.layout`.** The spec declares no app `layout`, no console surface reads one, and since objectui#10842 the save wrote none. The control persisted nothing. `EditAppPage` no longer reads a stored `layout` into the draft. The Basic Info step's description now reads "Name, title, and icon".
2121
- **`@object-ui/i18n`:** the four `appDesigner` layout keys (`layout`, `layoutSidebar`, `layoutHeader` and `layoutEmpty`) are removed from all ten packs, and `appDesigner.stepBasicDesc` no longer names a layout.

‎packages/plugin-designer/src/__tests__/AppWizard.specDocument-10867.test.tsx‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,16 @@ describe('objectui#10867 — member 2: an edit keeps every stored `branding` key
152152
expect(body.branding).toEqual({ ...STORED.branding, primaryColor: '#dc2626' });
153153
});
154154

155+
it('a logo the author clears saves as cleared, not as the stored value', async () => {
156+
// Guards the merge's order: the wizard's `branding` wins for the keys it
157+
// maintains, empty strings included, so a later "drop empty values" tidy-up
158+
// would bring a removed logo back from storage.
159+
const body = await editThrough(STORED, () =>
160+
fireEvent.change(screen.getByTestId('branding-logo-input'), { target: { value: '' } }),
161+
);
162+
expect(body.branding).toEqual({ ...STORED.branding, logo: '' });
163+
});
164+
155165
it('CONTROL — a stored `branding` key the spec does not declare is not echoed into the save', async () => {
156166
const body = await editThrough({
157167
...STORED,
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
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+
* The zod mirror refuses a separator key the spec's separator does not declare
11+
* (objectui#10867).
12+
*
13+
* `NavigationItemSchema` is one flat object, so it declares `label`, `icon` and
14+
* the rest for every item type, and its refinement's separator branch used to
15+
* return early. `objectui validate` therefore passed `{ type: 'separator',
16+
* label }`, which `@objectstack/spec`'s strict separator branch (`type`, `id`,
17+
* `order`) refuses, so the platform's save door answered 422. The branch now
18+
* refuses every key outside the set it reads off the spec's own separator arm.
19+
*
20+
* Pinned on both published faces: `safeValidateSchema` (the tolerant node face)
21+
* and `StrictAnyComponentSchema` (the strict authoring face), plus the mirror
22+
* itself.
23+
*/
24+
25+
import { describe, it, expect } from 'vitest';
26+
import { NavigationItemSchema as SpecNavigationItemSchema } from '@objectstack/spec/ui';
27+
import { NavigationItemSchema } from '../zod/app.zod';
28+
import { safeValidateSchema, StrictAnyComponentSchema } from '../zod/index.zod';
29+
30+
type Issue = { code: string; path: string; message: string };
31+
const issuesOf = (r: { success: boolean; error?: { issues: Array<{ code: string; path: PropertyKey[]; message: string }> } }): Issue[] | null =>
32+
r.success ? null : r.error!.issues.map((i) => ({ code: i.code, path: i.path.map(String).join('.'), message: i.message }));
33+
34+
const app = (navigation: unknown[]) => ({ type: 'app', name: 'acme_crm', label: 'Acme CRM', navigation });
35+
const OBJECT_ENTRY = { id: 'account', type: 'object', label: 'Accounts', objectName: 'account' };
36+
const LABELLED = { id: 'sep_1', type: 'separator', label: 'Section' };
37+
const BARE = { id: 'sep_1', type: 'separator' };
38+
39+
describe('objectui#10867 — a separator `label` is refused on every published face', () => {
40+
it('`safeValidateSchema` refuses it with one issue at the label, naming the fix', () => {
41+
const issues = issuesOf(safeValidateSchema(app([OBJECT_ENTRY, LABELLED])));
42+
expect(issues).not.toBeNull();
43+
const atLabel = issues!.filter((i) => i.path.endsWith('navigation.1.label'));
44+
expect(atLabel).toHaveLength(1);
45+
expect(atLabel[0].code).toBe('custom');
46+
expect(atLabel[0].message).toContain('drop `label`');
47+
});
48+
49+
it('the strict authoring face refuses it too', () => {
50+
const issues = issuesOf(StrictAnyComponentSchema.safeParse(app([OBJECT_ENTRY, LABELLED])));
51+
expect(issues).not.toBeNull();
52+
expect(issues!.some((i) => i.path.endsWith('navigation.1.label') && i.code === 'custom')).toBe(true);
53+
});
54+
55+
it('the mirror refuses every entry-only key on a separator, each at its own path', () => {
56+
const issues = issuesOf(NavigationItemSchema.safeParse({ ...BARE, label: 'Section', icon: 'Minus', pinned: true }));
57+
expect(issues!.map((i) => [i.code, i.path])).toEqual([
58+
['custom', 'label'],
59+
['custom', 'icon'],
60+
['custom', 'pinned'],
61+
]);
62+
});
63+
64+
it('CONTROL — a bare `{ id, type: separator }` (and one with `order`) is accepted by both faces and the spec', () => {
65+
for (const separator of [BARE, { ...BARE, order: 3 }]) {
66+
expect(issuesOf(safeValidateSchema(app([OBJECT_ENTRY, separator])))).toBeNull();
67+
expect(issuesOf(StrictAnyComponentSchema.safeParse(app([OBJECT_ENTRY, separator])))).toBeNull();
68+
expect(SpecNavigationItemSchema.safeParse(separator).success).toBe(true);
69+
}
70+
});
71+
72+
it('CONTROL — the spec refuses the same `label`, so the faces now agree with it', () => {
73+
expect(SpecNavigationItemSchema.safeParse(LABELLED).success).toBe(false);
74+
});
75+
76+
it('CONTROL — an entry keeps its `label`, so the refusal is scoped to the separator', () => {
77+
expect(issuesOf(safeValidateSchema(app([OBJECT_ENTRY])))).toBeNull();
78+
});
79+
});

‎packages/types/src/__tests__/navigation-spec-parity.test.ts‎

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -162,8 +162,6 @@ describe('referencing the spec NavigationItemSchema would reject metadata object
162162
'the legacy spelling this file keeps accepting for published metadata'],
163163
['visible: boolean', { id: 'ai', type: 'url', label: 'AI', url: '/ai', visible: true },
164164
'menuItemToNavigationItem MANUFACTURES one when it inverts AppMenuItem.hidden'],
165-
['separator label', { type: 'separator', label: 'Section' },
166-
'the flat mirror declares label for every type; the TS face refuses it on a separator since objectui#10867, and the spec separator declares only id/order'],
167165
['single-character id', { id: 'a', type: 'url', label: 'A', url: '/a' },
168166
'objectui requires only a non-empty id; the spec requires two characters'],
169167
])('the spec rejects %s (%s)', (_name, input, _why) => {
@@ -172,4 +170,13 @@ describe('referencing the spec NavigationItemSchema would reject metadata object
172170
// ...and the spec does not, which is the whole reason for the local schema.
173171
expect(SpecNavigationItemSchema.safeParse(input).success).toBe(false);
174172
});
173+
174+
// A separator carrying `label` was a row above until objectui#10867: the flat
175+
// mirror admitted it, the spec refused it. The mirror now refuses it too, so
176+
// it is no longer a divergence and is asserted as an agreement instead.
177+
it('a separator `label` is no longer a divergence: both refuse it (objectui#10867)', () => {
178+
const input = { type: 'separator', label: 'Section' };
179+
expect(NavigationItemSchema.safeParse(input).success).toBe(false);
180+
expect(SpecNavigationItemSchema.safeParse(input).success).toBe(false);
181+
});
175182
});

‎packages/types/src/__tests__/spec-derived-unions.test.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -313,7 +313,7 @@ const _separatorAdmitsTheSpecInputKeys = true satisfies SameKeys<
313313
AdmittedKeys<LocalSeparator>,
314314
keyof SpecSeparatorInput
315315
>;
316-
// ...and every separator this arm admits is one the spec's accepts, at both tiers.
316+
// ...and the value types of the keys this arm admits are ones the spec's separator accepts, at both tiers. `extends` ignores extra keys, so key agreement is the two pins above: this one alone passes a labelled arm, and it is vacuous on BASE.
317317
const _localSeparatorIsSpecValid = true satisfies [LocalSeparator] extends [SpecSeparator & SpecSeparatorInput]
318318
? true
319319
: false;

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

Lines changed: 64 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,47 @@ export const NavigationItemTypeSchema = z.enum([
9696
* `../__tests__/zod-lazy-getter-identity-7918.test.ts` — read that before
9797
* "fixing" any of them to match this one.
9898
*/
99+
/**
100+
* The keys `@objectstack/spec`'s separator branch declares — `type`, `id` and
101+
* `order` on the installed pin — READ OFF the spec rather than restated
102+
* (objectui#10867). The spec does not export its `SeparatorNavItemSchema`, so
103+
* this walks the spec `AppSchema`'s own `navigation` element (optional → array
104+
* → lazy → the nav-item union) and takes the arm whose `type` literal is
105+
* `'separator'`. Reading it through `AppSchema`, which this mirror already
106+
* crosses, keeps the read inside the objectui#8317 import boundary's measured
107+
* population instead of adding a lazy root that population cannot walk.
108+
*
109+
* Computed on first use, because the spec schema is lazy. It throws when no such
110+
* arm exists: a separator check that silently allowed nothing, or everything,
111+
* would read as enforcement, and a thrown error is what the pin file sees.
112+
*/
113+
let specSeparatorKeys: readonly string[] | undefined;
114+
function getSpecSeparatorKeys(): readonly string[] {
115+
if (specSeparatorKeys) return specSeparatorKeys;
116+
type Node = {
117+
unwrap?: () => Node;
118+
element?: Node;
119+
options?: readonly Node[];
120+
shape?: Record<string, Node & { value?: unknown }>;
121+
};
122+
let node = (stripImportedDefaults(SpecAppSchema) as unknown as Node).shape?.navigation as Node | undefined;
123+
for (let hop = 0; node && !node.options && hop < 8; hop++) node = node.element ?? node.unwrap?.();
124+
const arm = node?.options?.find((option) => option.shape?.type?.value === 'separator');
125+
if (!arm?.shape) {
126+
throw new Error(
127+
"objectui#10867: @objectstack/spec's AppSchema.navigation has no `type: 'separator'` arm to read the separator's keys from",
128+
);
129+
}
130+
specSeparatorKeys = Object.freeze(Object.keys(arm.shape));
131+
return specSeparatorKeys;
132+
}
133+
134+
/** `a`, `b` and `c` — the separator refusal's list of what a separator may carry. */
135+
function codeList(keys: readonly string[]): string {
136+
const quoted = keys.map((key) => `\`${key}\``);
137+
return quoted.length > 1 ? `${quoted.slice(0, -1).join(', ')} and ${quoted[quoted.length - 1]}` : quoted.join('');
138+
}
139+
99140
const NavigationItemObject = z.object({
100141
// Declared optional so a bare `{ type: 'separator' }` — which the spec
101142
// accepts, and which carries no identity or text by definition — validates
@@ -159,7 +200,24 @@ const NavigationItemObject = z.object({
159200
// separator — a rule, not an entry — is exempt. Declaring the fields
160201
// optional above is what lets `{ type: 'separator' }` through, so without
161202
// this an id-less `type: 'object'` item would validate too.
162-
if (item.type === 'separator') return;
203+
//
204+
// The separator carries exactly what the spec's separator declares
205+
// (objectui#10867). This shape is flat, so it declares `label`, `icon` and
206+
// the rest for every type; without this branch refusing them, `objectui
207+
// validate` passed a separator `label` the platform's save door refuses with
208+
// `unrecognized_keys`. The allowed set is read off the spec, not restated.
209+
if (item.type === 'separator') {
210+
const allowed = getSpecSeparatorKeys();
211+
for (const [key, value] of Object.entries(item)) {
212+
if (allowed.includes(key) || value === undefined) continue;
213+
ctx.addIssue({
214+
code: 'custom',
215+
path: [key],
216+
message: `a separator carries only ${codeList(allowed)}; drop \`${key}\``,
217+
});
218+
}
219+
return;
220+
}
163221
for (const key of ['id', 'label'] as const) {
164222
if (typeof item[key] !== 'string' || item[key] === '') {
165223
ctx.addIssue({
@@ -215,10 +273,11 @@ export const NavigationItemSchema: z.ZodType<any> = z.lazy(() => NavigationItemO
215273
* navigation as a discriminated union of `.strict()` variants; objectui keeps
216274
* one flat, all-optional object that deliberately accepts more. Measured
217275
* against spec 17.2.0, referencing the spec's schema would make `objectui
218-
* validate` REJECT metadata this renderer accepts today: `pinned`,
219-
* `defaultOpen` and a separator carrying `label` all fail `unrecognized_keys`,
220-
* `visible: boolean` fails `invalid_union`, and a one-character `id` fails
221-
* `too_small`. Pinned by `__tests__/navigation-spec-parity.test.ts`.
276+
* validate` REJECT metadata this renderer accepts today: `pinned` and
277+
* `defaultOpen` fail `unrecognized_keys`, `visible: boolean` fails
278+
* `invalid_union`, and a one-character `id` fails `too_small`. Pinned by
279+
* `__tests__/navigation-spec-parity.test.ts`. (A separator carrying `label`
280+
* was a fifth until objectui#10867, which made the mirror refuse it too.)
222281
*
223282
* Converging on the union is a breaking change for every consumer that reads
224283
* fields off `NavigationItem` without narrowing — tracked separately, and

0 commit comments

Comments
 (0)