Skip to content
This repository was archived by the owner on Oct 26, 2026. It is now read-only.

Commit 7343376

Browse files
fix(types,plugin-designer)!: the app wizard saves a document the spec accepts — a separator without label, branding kept on edit, no Layout control (objectui#10867) (#10889)
Fixes #10867 Clause-②: yes (narrowing). Published `@object-ui/types` members narrow: the separator arm of `NavigationItem` loses `label`, and `AppWizardDraft` loses `layout`. That makes this `minor` plus a BREAKING (authoring) banner, and never `major`. Dispatched by the `domain:ui` seat 2 PM (claim comment 5861866471, triage 5861615446), session `https://claude.ai/code/session_014mXUNuFomfj24w7s1pZzhN` (`mode:subagent`, so that id is also this run's). Branch `claude/issue-10867-app-wizard-saves-spec-app`, cut from `origin/main` `b93e245f9` (BASE); head `2c19f3047` after fix round 1 (round-0 head `e2493c796`). ## What changed **Member 1 — a separator carries only `type`, `id` and `order` (the p1).** - `@object-ui/types`: `NavigationItem` is now a union of two arms, discriminated by `type`. `NavigationEntryItem` is the former interface with `type` narrowed to every spec nav type except `'separator'`, and it keeps its required `label`. `NavigationSeparatorItem` admits `type`, `id` and `order`, the spec separator's keys, and every other entry key is `?: never` on it. Those `never` keys are derived from `NavigationEntryItem`, so a key added there is refused on the separator without an edit. Both arms are exported from the package root. `menuItemToNavigationItem` maps a legacy separator to `{ id, type: 'separator' }` and drops its label. - `spec-derived-unions.test.ts`: the blocker-3 pin (`_specSeparatorStillHasNoLabel`) is lifted. It is replaced by three agreement pins. `_separatorAdmitsTheSpecKeys` and `_separatorAdmitsTheSpecInputKeys` say the separator arm admits exactly the spec separator's keys at the output and input tiers. `_localSeparatorIsSpecValid` says the value types of the keys the arm admits are ones the spec's separator accepts at both tiers. `extends` ignores extra keys, so the key agreement is carried by the first two: the third alone passes a labelled arm, and it is vacuous on BASE. "Admits" means a key whose type is not `never`. The two remaining blockers (`visible: boolean`, and `pinned` / `defaultOpen`) are unchanged, and so is the umbrella verdict. - `@object-ui/types` zod mirror (fix round 1): `NavigationItemSchema`'s refinement has an explicit separator branch, and it used to return early, so `objectui validate` passed a separator `label` that the save door refuses with 422. That branch now refuses every key the spec's separator does not declare, each at its own path, with a message that names the key and the fix ("a separator carries only `type`, `id` and `order`; drop `label`"). The allowed set is read off the spec, not restated: the spec does not export `SeparatorNavItemSchema`, so it is taken from the separator arm of the spec `AppSchema`'s own `navigation` union. That schema already crosses the mirror's import boundary, so the objectui#8317 population is unchanged. The read throws if the arm is ever missing, instead of silently allowing everything. - `@object-ui/plugin-designer`: `AppCreationWizard`'s "Add separator" and `NavigationDesigner`'s two add paths (now one `newNavItem` helper) write `{ id, type }`. `NavigationDesigner` no longer writes a `label` onto a new separator, and its label, icon and visibility patchers skip one. - The union makes code that writes an entry-only key onto an unnarrowed item fail to compile, and each such site now narrows first. None of these edits changes what renders for valid data. - `@object-ui/layout`: `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 typed as entries. `collectPinnedItems` tests the type before `pinned`. - `@object-ui/app-shell`: `useNavPins` registers a favorite only for an entry and leaves a separator as it is. The Studio sidebar's navigation walk passes a separator through unchanged. - Test fixtures that spread an item and add `visible` or `requiredPermissions` are typed as `NavigationEntryItem`. These are in layout's `AppSchemaRenderer` and `resolveHref` tests and app-shell's `UnifiedSidebar.derivedAreaVisibility` test. **Member 2 — an edit keeps the stored branding.** `EditAppPage` now merges `branding` one level down, by the objectui#10842 rule. It keeps every stored key the spec's `AppBrandingSchema` declares, read from `AppBrandingSchema.shape`, and the wizard's values win for the keys it maintains. A stored `accentColor` survives the save. A stored key the spec does not declare is still left out. **Member 3 — the Layout control is gone.** `AppWizardDraft.layout` is removed. The Basic Info step's Layout fieldset is removed, with its three icons and the draft default. `EditAppPage` no longer pre-fills `layout`. The step description now reads "Name, title, and icon". The four `appDesigner` layout keys are removed from `DESIGNER_DEFAULT_TRANSLATIONS` and from all ten locale packs, and `appDesigner.stepBasicDesc` is retranslated in all ten. **Text that the change made false:** - the `app.ts` module header and the `NavigationArea` docblock; - the `wizardDraftToAppSchema` docblock; - the `@object-ui/types:NavigationItem` and `@object-ui/types:NavigationItemSchema` ledger reasons in `scripts/check-spec-symbol-derivation.mjs` (the second now counts four divergences, not five); - the mirror's own `NavigationAreaSchema` docblock, which listed a separator `label` among the keys only the spec refuses; - the "separator label" row in `navigation-spec-parity.test.ts`: the faces now agree, so it left the divergence list and is asserted as an agreement (both refuse it); - the ROADMAP line for the wizard's Basic Info step. **Changesets.** - New `.changeset/10867-app-wizard-saves-spec-app.md`: `@object-ui/types` and `@object-ui/i18n` minor (removing published pack keys is a narrowing), and `@object-ui/plugin-designer`, `@object-ui/layout` and `@object-ui/app-shell` patch, with the `Clause-②: yes` line. The BREAKING (authoring) banner names every consequence, and each was checked against the code at TypeScript 6.0.3 by a throwaway probe: - a read of `label` into a `string` slot fails (TS2322); - a spread of an entry-only key fails (TS2322); - `interface … extends NavigationItem` fails (TS2312); - an augmentation of `NavigationItem` fails (TS2300); - `objectui validate` refuses a separator `label`; - `t()` on a removed key renders the raw key unless the call passes a `defaultValue`. The provider-less fallback is the defaults table, then `defaultValue`, then the key, and i18next is configured with `returnNull: false` and no missing-key parser. - Dated notes in the objectui#10533 form on two pending entries. - `10842-wizard-app-declared-keys.md`, titled "the wizard has no layout choice": its "the wizard's layout choice is not saved" has no choice left. Frontmatter md5 `5b8e68791c8bc51ef72179570fd15a1c` before and after. - `3162-types-ledger-batch8-verdicts.md`: its "three semantic blockers" count. Frontmatter md5 `2ee28f1e1b449ccc2dd97fdcc6c2bc1f` before and after. **Fixture triage:** - The `layout` key was dropped from the `AppWizardDraft` fixtures in `app-creation-types`, `app-declared-keys-10842` and `app-logo-one-spelling-10827`. These are spelling fixes: the key was there only because the type required it. - The vacuous `'layout' in schema` assertion in `app-creation-types` "should handle draft with empty navigation" was removed. - `navigation-model`'s "should convert a separator" pinned the removed `label: ''` branch, so it was replaced by an exact-shape assertion. - The `label: ''` was dropped from separator fixtures in `navigation-model`, layout's `AppSchemaRenderer.test` and app-shell's `phase1-page-blocks.render.test`. ## H1 to H3 (measured on BASE `b93e245f9`, installed `@objectstack/spec` 17.4.0) **H1 — reproduced.** A probe run through `wizardDraftToAppSchema`, then the spec's `AppSchema.safeParse`: - a draft with one object entry plus the separator the wizard wrote, `{ id, type: 'separator', label: '' }`, gave `[{"code":"unrecognized_keys","path":"navigation.1","keys":["label"]}]`; - control, the same draft without the separator: parses green; - control, the same separator without `label`: parses green; - `menuItemToNavigationItem({ type: 'separator', label: 'Section' })` gave `{"id":"migrated_0","type":"separator","label":"Section"}`. The new DOM pin gives the same reading through the real wizard and `CreateAppPage` on the base source: "the saved document parses green through the spec `AppSchema`" fails with `unrecognized_keys` `["label"]` at `navigation.1`. **H2 — reproduced.** The new pin "a stored `accentColor` survives the edit round trip" drives the real wizard through `EditAppPage`. On the base source it fails: the saved `branding` is `{ favicon, logo, primaryColor }`, and `"accentColor": "#f59e0b"` is missing. **H3 — census** (`git grep` over packages, apps, docs, examples, skills and content). Each hit goes or is corrected: - **`AppWizardDraft.layout` and the draft default:** - the member in `app.ts` — removed; - `DEFAULT_DRAFT.layout` in `AppCreationWizard` — removed; - `EditAppPage`'s `layout: appToEdit.layout || 'sidebar'` pre-fill — removed; - four draft fixtures — the key dropped (see the triage above). - **The step's UI:** the Layout fieldset (`app-layout-*` test ids, `draft.layout` reads and writes) and its three icon imports — removed. - **The step's strings:** - `appDesigner.layout`, `layoutSidebar`, `layoutHeader` and `layoutEmpty` in `DESIGNER_DEFAULT_TRANSLATIONS` and in ten locale packs — removed; - `appDesigner.stepBasicDesc` ("Name, title, and layout") in eleven places — corrected. - **Docs:** the ROADMAP Basic Info line — corrected. `content/docs/guide/console.md` and `console-architecture.md` describe the wizard's four steps and never the Layout control, so they are unchanged. - **Not this member:** the `layout: 'sidebar'` hits in `content/docs/core/app-schema.mdx`, `content/docs/guide/schema-overview.md`, the runner tests and the `AppComponentSchema` fixtures are `AppComponentSchema.layout`, the renderer node's own layout strategy. They are untouched, and the changeset says so. ## Pins and ablation New pins: - `packages/types/src/__tests__/app-wizard-separator-layout-10867.test.ts`: vitest halves, and `@ts-expect-error` / `satisfies` halves run under `tsc -p tsconfig.test.json`. - `packages/plugin-designer/src/__tests__/AppWizard.specDocument-10867.test.tsx`: drives the real `AppCreationWizard` through `CreateAppPage` and `EditAppPage` and reads what reaches `saveItem`. - The `spec-derived-unions.test.ts` agreement pins. - Fix round 1: `packages/types/src/__tests__/nav-separator-mirror-refusal-10867.test.ts`. `safeValidateSchema`, the strict authoring face (`StrictAnyComponentSchema`) and the mirror itself each refuse a separator `label`, each at its own path; the mirror also refuses `icon` and `pinned` on a separator. Controls: a bare `{ id, type: 'separator' }` (and one with `order`) is accepted by both faces and the spec, the spec refuses the same `label`, and an entry keeps its `label`. - Fix round 1: a new `EditAppPage` row, "a logo the author clears saves as cleared, not as the stored value". It is a guard, not a red-on-BASE pin: green on BASE and on HEAD, it fails if a later tidy-up drops empty strings from the merge. **Red on BASE.** Run from a committed tree: the source files were checked out at BASE, the new pins kept, and everything was restored from `HEAD` under a trap. The restore was proven by blob hash equal to HEAD and an empty `git diff HEAD`. - vitest `Tests 8 failed | 6 passed (14)`. The 8 failures are every non-control row: - `menuItemToNavigationItem`; - the two create-with-separator rows; - the three branding rows; - the two Layout rows. - The 6 passes are the controls and compile-only rows. - `tsc -p tsconfig.test.json` in `packages/types` gives the new pins': - TS2741 ×2 (a bare separator is missing `label`); - TS2578 ×2 (unused `@ts-expect-error` for a labelled separator and for a draft with `layout`); - TS1360 ×3 (the no-`layout` `satisfies` and the two separator-agreement pins). **Red before the mirror fix (round 1).** From the committed round-1 head, only `app.zod.ts` was checked out at the round-0 head `e2493c796`, under a trap; the restore was proven blob == HEAD and `git diff HEAD` empty. The new mirror pins plus `navigation-spec-parity` gave vitest `Tests 4 failed | 17 passed (21)`. The 4 failures are the refusal rows and the new agreement row; the controls passed. **Green on HEAD.** Round 0, at `e2493c796`: `Test Files 284 passed (284)`, `Tests 5676 passed (5676)`. Round 1, at `2c19f3047`: see Gates. The `packages/types` `tsc -p tsconfig.test.json` has 0 errors at both heads. **Ablation: the separator branch is reverted, and only the separator rows redden.** Three anchored mutations went through `ablation-replace.mjs`, each proven landed and restored by blob hash: - the `NavigationItem` alias gives its separator arm a required `label` again; - `menuItemToNavigationItem` writes `label` again; - the wizard's add-separator writes `label: ''` again. Result: - vitest `Tests 3 failed | 11 passed (14)`. The failures are `menuItemToNavigationItem`, "the saved document parses green" and "the saved separator carries only `type` and `id`". The branding and Layout rows stay green. - `tsc -p tsconfig.test.json`: 5 errors, all on separator lines. TS2322 ×2 (bare separators), TS2578 ×1 (labelled separator) and TS1360 ×2 (the two agreement pins). The Layout type pin stays green. - Restore: `blob == HEAD` and `git diff HEAD` empty for both files. **Cross-package reverse check.** The downstream type-checks resolve `@object-ui/types` through its rebuilt `dist`. Their first runs went red only on sites the new union refuses: layout 3, plugin-designer 4, app-shell 3 in source plus 4 in tests. They were green after narrowing. That red could only come from the rebuilt declaration. ## Gates Fix round 1, at `2c19f3047`, all under the verify lock: - vitest `packages/types/`, `packages/plugin-designer/` and `packages/i18n/`: `Test Files 356 passed (356)`, `Tests 6861 passed (6861)`. - vitest over the 18 app-shell navigation, pins and sidebar files, `examples/schema-catalog/test/`, the two cli `validate` suites and the three spec-symbol script suites: `Test Files 57 passed (57)`, `Tests 2487 passed (2487)`. - **Arm-list consumer sweep** for the mirror change. Every test that imports `NavigationItemSchema` or the app zod mirror, or runs `safeValidateSchema` / `StrictAnyComponentSchema` over an app or navigation document, was found by `git grep` and run with per-file verdicts: 34 files, 1251 tests, 0 failed. They include `zod-mirror-parity` 37/37, `navigation-spec-parity` 15/15, `imported-defaults-8317` 124/124, `nav-target-exclusivity-8563` 15/15, `navigation-model` 28/28, `zod-lazy-getter-identity-7918` 31/31, `page-nav-misc-spec-parity` 44/44, `handler-keys-json-refusal-6124` 336/336, the two app-shell studio-design nav suites 22/22 and 3/3, cli `validate-root-path-line` 6/6 and the catalog `tree-view-nodes-mirror-6939` 22/22. - `tsc -p packages/types/tsconfig.test.json`: 0 errors. `@object-ui/types` `type-check`: 0 `error TS`. `@object-ui/plugin-designer` `type-check`, after rebuilding its closure: clean. - Changeset gates: - `check-changeset-presence` ✅; - `changeset:check` ✅ (no `major`); - `check:changeset-claims` (report-only) lists 15 pending entries naming a touched file; the six new ones (4895, 6335, 7113, 7735, 7918 and 8871) were read, and none claims anything a separator refusal falsifies; - `check-changeset-overwrite` (report-only): the two dated-note edits; - `check:pending-changeset-literals` ✅. - Other gates: `check:control-bytes` OK, `check:new-line-citations` 0 new, `check:spec-symbols` ✅, the three i18n gates ✅, `type-check:coverage` ✅. - Round 1 changed no exported type and no layout, app-shell or console source, so the round-0 downstream type-checks below still describe this head. Round 0, at `e2493c796` unless noted: - `pnpm exec vitest run packages/types/ packages/plugin-designer/`: `Test Files 284 passed (284)`, `Tests 5676 passed (5676)`. - `pnpm --filter` `@object-ui/types`, `@object-ui/layout`, `@object-ui/plugin-designer` and `@object-ui/app-shell` `run type-check` (each `tsc --noEmit && tsc -p tsconfig.test.json`; types also runs `tsconfig.examples.json`): verify-lock `VERDICT command-exit 0`. - `@object-ui/console` `type-check` after rebuilding its dependency closure: exit 0, 0 `error TS`. - vitest `packages/layout/` and `packages/plugin-designer/` (after the narrowing): `Tests 436 passed (436)`. - vitest `packages/i18n/`, app-shell `defaults-maps-mirror-en-pack`, `phase1-page-blocks.render` and layout `AppSchemaRenderer`, plus the `check-spec-symbol-derivation`, `spec-symbol-ledger-anchor-liveness-9537` and `spec-symbol-ledger-types-7265` script suites: `Tests 1354 passed (1354)`. - 17 app-shell files covering pins, favorites, the sidebar and nav fixtures: `Tests 160 passed (160)`. - Changeset gates: - `check-changeset-presence` ✅ (5 released packages, 1 changeset); - `changeset:check` ✅ (no `major`); - `check:changeset-claims` (report-only): 9 pending entries name a touched file, and each was read — none is falsified; - `check-changeset-overwrite` (report-only): lists the two dated-note edits; - `check:pending-changeset-literals` ✅. - i18n gates: `check:i18n-keys` ✅, `check:i18n-drift` ✅ ("Every changed en value was followed by all nine translation packs"), `check:i18n-designer-parity` ✅. - Other gates: - `check:spec-symbols` ✅; - `check:control-bytes` OK; - `check:new-line-citations`: `VERDICT new-cross-file-line-citations: 0 new citation(s)`; - `type-check:coverage` ✅ (43/43); - `check:test-path-roots` OK; - `check:vi-mock-specifiers`, `check:vi-mock-inherit` and `check:vi-mock-override-shape` OK; - `check:metadata-write-doors` OK; - `check:unreferenced-sources` OK; - `check:handler-key-reads` OK. - ESLint on the 34 changed `.ts` / `.tsx` / `.mjs` files (repo config, inline config honoured): 0 errors and 47 warnings. Each warned file carries the same warning count at BASE, measured by linting the BASE blob through `--stdin`, so no warning is new. The repo-wide `pnpm lint` is CI's. - NOT MEASURED: - the full app-shell suite, because it runs past the foreground cap; declared to CI; - `check:readme-exports` for the unbuilt `@object-ui/cli` and `@object-ui/plugin-ai` READMEs, which are untouched here. Its judged population read 548 self-imports and 0 fabricated. - Scheduling: two light runs (the red-on-BASE pair) went outside the verify lock. The lock was held for more than 20 minutes by a sibling's full app-shell suite, and this is declared as a narrowing. Every build, suite and type-check above ran under the lock. ## Serial - Open PRs read at 2026-09-28T03:3xZ: #10888, #10886, #10884, #10852, #10780, #10777, #10714 and #10278, plus the release PR. None of their file lists intersects this diff. - `origin/main` has moved to `b2683a2c0` since BASE. `git merge-tree --write-tree` against it is clean. Of the files main moved, only `en.ts` overlaps, on other keys. No file main moved mentions `NavigationItem`, `AppWizardDraft` or the removed keys. ## Acceptance notes - **The zod twin, corrected in fix round 1.** Round 0 read the claim's "if the mirror carries the separator branch" as not met, because the mirror is one flat object. The contract review measured that the refinement does have an explicit separator branch, so the condition held, and the mirror now refuses what the TypeScript face refuses (see What changed). - **Boundary note, not taken: pre-17 tombstones.** Dropping the spec's tombstone keys from `EditAppPage`'s kept set would make the save succeed silently and lose the author's value. The spec's current loud refusal carries a prescription, so this is left as it is. - **Boundary note, not taken: `BrandingConfig.fontFamily`.** `BrandingEditor` reads and writes it, and removing it is not a cheap change inside these files. - **`AppComponentSchema.layout` is untouched.** It is a different member, the renderer node's layout strategy. - **An edit through the wizard drops stored navigation** (found while pinning member 2; not fixed here; the seat is filing it as its own card, and the review confirmed this PR does not make it worse). Leaving the Objects step regenerates `navigation` from the selected objects unconditionally. In edit mode that replaces the stored tree, so separators, groups, URL entries and dashboards are lost on every save. Measured with a throwaway probe through `EditAppPage` and the real wizard: a stored `[object, separator, group with a dashboard child, url]` saved as `[object]` only. --- _Generated by [Claude Code](https://claude.ai/code/session_014mXUNuFomfj24w7s1pZzhN)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent c30c8dd commit 7343376

40 files changed

Lines changed: 718 additions & 220 deletions

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,3 +33,7 @@ has no app layout, so the wizard's layout choice is not saved.
3333
`logo`, `favicon` or `title`; it reads `branding.logo`, `branding.favicon` and `label`.
3434

3535
Migration: move a top-level `favicon` URL to `branding: { favicon: '…' }`.
36+
37+
⚠️ **Dated note, 2026-09-28 — the wizard has no layout choice — objectui#10867.**
38+
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. The rest of this entry still holds.
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
---
2+
'@object-ui/types': minor
3+
'@object-ui/plugin-designer': patch
4+
'@object-ui/i18n': minor
5+
'@object-ui/layout': patch
6+
'@object-ui/app-shell': patch
7+
---
8+
9+
fix(types,plugin-designer)!: the Studio app wizard saves a document the platform accepts, and an edit keeps the stored `accentColor` (objectui#10867)
10+
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`).
12+
13+
**Clause-②: yes (narrowing)** — the separator arm of `NavigationItem` loses `label`, and `AppWizardDraft` loses `layout`.
14+
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.
19+
- **`@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.
20+
- **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".
21+
- **`@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.
22+
23+
**Migration:** write a separator as `{ id, type: 'separator' }`, with an optional `order`. Remove `layout` from any `AppWizardDraft` you build. Code that spreads an entry-only key (`label`, `visible`, `requiredPermissions` and the rest) onto a value typed `NavigationItem` narrows it first (`item.type !== 'separator'`) or types it `NavigationEntryItem`: the separator arm refuses those keys. `AppComponentSchema.layout`, the renderer node's own layout strategy, is a different member and is unchanged.
24+
25+
Pinned in `packages/types/src/__tests__/app-wizard-separator-layout-10867.test.ts` and `packages/plugin-designer/src/__tests__/AppWizard.specDocument-10867.test.tsx`.

‎.changeset/3162-types-ledger-batch8-verdicts.md‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,3 +31,9 @@ Two stale justifications were corrected in passing, both the same defect class t
3131
exists to catch — a comment asserting an upstream type "erases to `any`" when it no longer
3232
does. Left alone, the next triage checks the claim, finds it false, and lands the regression.
3333
No published behaviour changes; no runtime code was touched.
34+
35+
⚠️ **Dated note, 2026-09-28 — one of the three `NavigationItem` blockers has since lifted — objectui#10867.**
36+
Later in this same release the separator `label` stopped being a blocker: `NavigationItem` became a union whose
37+
separator arm admits exactly the spec separator's keys, and `spec-derived-unions.test.ts` pins that agreement where
38+
the blocker stood. Two semantic blockers remain (`visible: boolean`, and `pinned` / `defaultOpen`), so the symbol
39+
is still not bindable. The rest of this entry still holds.

‎ROADMAP.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -673,7 +673,7 @@ ObjectUI is a universal Server-Driven UI (SDUI) engine built on React + Tailwind
673673
- [x] `wizardDraftToAppSchema()` draft-to-schema conversion function
674674

675675
**App Creation Wizard (4-step):**
676-
- [x] Step 1: Basic Info — name (snake_case validated), title, description, icon, template, layout selector
676+
- [x] Step 1: Basic Info — name (snake_case validated), title, description, icon, template (the layout selector it also carried was removed by objectui#10867: `@objectstack/spec` declares no app layout, so the choice was never saved)
677677
- [x] Step 2: Object Selection — card grid with search, select all/none, toggle selection
678678
- [x] Step 3: Navigation Builder — auto-generates NavigationItem[] from selected objects, add group/URL/separator, reorder up/down, remove
679679
- [x] Step 4: Branding — logo URL, primary color, favicon, live preview card

‎packages/app-shell/src/hooks/useNavPins.ts‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,8 @@ export function useNavPins() {
6161
// If a NavigationItem is provided, register/refresh the favorite with
6262
// proper label/href so backend sync carries portable data. Otherwise
6363
// just flip the flag — the existing favorite (if any) keeps its data.
64-
if (item) {
64+
// A separator is never pinnable and carries no `label` (objectui#10867).
65+
if (item && item.type !== 'separator') {
6566
addFavorite({
6667
id: favId,
6768
label: item.label,
@@ -108,6 +109,8 @@ export function useNavPins() {
108109
let pinCount = 0;
109110
const walk = (list: NavigationItem[]): NavigationItem[] =>
110111
list.map(item => {
112+
// A separator carries no `pinned` and no children (objectui#10867).
113+
if (item.type === 'separator') return item;
111114
const shouldPin = pinnedNavIds.has(item.id) && pinCount < MAX_PINS;
112115
if (shouldPin) pinCount++;
113116
const children = item.children?.length ? walk(item.children) : item.children;

‎packages/app-shell/src/layout/UnifiedSidebar.tsx‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -410,8 +410,11 @@ export function UnifiedSidebar({ activeAppName }: UnifiedSidebarProps) {
410410
defaultValue: 'Package management',
411411
});
412412
const walk = (items: NavigationItem[]): NavigationItem[] =>
413-
items.flatMap((item) => {
413+
items.flatMap((item): NavigationItem[] => {
414414
if (isMetadataDirectoryItem(item)) return [];
415+
// A separator has no children and is never the packages entry, and
416+
// carries none of the keys rewritten below (objectui#10867).
417+
if (item.type === 'separator') return [item];
415418
const children = item.children?.length ? walk(item.children) : item.children;
416419
if (item.type === 'group' && children?.length === 0) return [];
417420
if (isPackagesItem(item)) {

‎packages/app-shell/src/layout/__tests__/UnifiedSidebar.derivedAreaVisibility.test.tsx‎

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest';
2121
import React from 'react';
2222
import { render, screen, fireEvent } from '@testing-library/react';
2323
import { MemoryRouter } from 'react-router-dom';
24-
import type { NavigationArea } from '@object-ui/types';
24+
import type { NavigationArea, NavigationEntryItem } from '@object-ui/types';
2525

2626
// ---------------------------------------------------------------------------
2727
// Mocks — providers and console-only chrome. @object-ui/components and
@@ -117,16 +117,21 @@ import { UnifiedSidebar } from '../UnifiedSidebar';
117117
// Fixtures — labels are pairwise distinct so a hit is unambiguous.
118118
// ---------------------------------------------------------------------------
119119

120+
// Typed as ENTRIES, so a case can spread one and add an entry-only key
121+
// (`visible`, `requiredPermissions`); a separator carries neither (objectui#10867).
122+
const salesEntry: NavigationEntryItem = { id: 'a1', type: 'object', label: 'Opportunities', objectName: 'opportunity' };
123+
const serviceEntry: NavigationEntryItem = { id: 'a2', type: 'object', label: 'Cases', objectName: 'case' };
124+
120125
const salesArea: NavigationArea = {
121126
id: 'area-sales',
122127
label: 'Sales',
123-
navigation: [{ id: 'a1', type: 'object', label: 'Opportunities', objectName: 'opportunity' }],
128+
navigation: [salesEntry],
124129
};
125130

126131
const serviceArea: NavigationArea = {
127132
id: 'area-service',
128133
label: 'Service',
129-
navigation: [{ id: 'a2', type: 'object', label: 'Cases', objectName: 'case' }],
134+
navigation: [serviceEntry],
130135
};
131136

132137
const marketingArea: NavigationArea = {
@@ -137,7 +142,7 @@ const marketingArea: NavigationArea = {
137142

138143
const gatedSales: NavigationArea = {
139144
...salesArea,
140-
navigation: [{ ...salesArea.navigation[0], visible: false }],
145+
navigation: [{ ...salesEntry, visible: false }],
141146
};
142147

143148
function sidebarUi(areas: NavigationArea[]) {
@@ -183,7 +188,7 @@ describe('UnifiedSidebar derived area visibility (#3319)', () => {
183188
const partialSales: NavigationArea = {
184189
...salesArea,
185190
navigation: [
186-
{ ...salesArea.navigation[0], visible: false },
191+
{ ...salesEntry, visible: false },
187192
{ id: 'a1b', type: 'object', label: 'Quotes', objectName: 'quote' },
188193
],
189194
};
@@ -217,7 +222,7 @@ describe('UnifiedSidebar derived area visibility (#3319)', () => {
217222
render(
218223
sidebarUi([
219224
gatedSales,
220-
{ ...serviceArea, navigation: [{ ...serviceArea.navigation[0], visible: false }] },
225+
{ ...serviceArea, navigation: [{ ...serviceEntry, visible: false }] },
221226
]),
222227
);
223228
expect(screen.queryByText('Sales')).not.toBeInTheDocument();
@@ -245,7 +250,7 @@ describe('UnifiedSidebar derived area visibility (#3319)', () => {
245250
it('re-elects when the ACTIVE area is gated away, and a mere reveal does not steal the selection', () => {
246251
const adminSales: NavigationArea = {
247252
...salesArea,
248-
navigation: [{ ...salesArea.navigation[0], requiredPermissions: ['sales:admin'] }],
253+
navigation: [{ ...salesEntry, requiredPermissions: ['sales:admin'] }],
249254
};
250255
const areas = [adminSales, serviceArea, marketingArea];
251256

‎packages/app-shell/src/views/__tests__/phase1-page-blocks.render.test.tsx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -107,7 +107,7 @@ const APPS = [
107107
objectName: 'sys_invoice',
108108
requiresObject: 'sys_invoice',
109109
},
110-
{ id: 'divider_1', type: 'separator', label: '' },
110+
{ id: 'divider_1', type: 'separator' },
111111
],
112112
},
113113
{ name: 'ops', label: 'Operations', icon: 'Wrench', navigation: [] },

‎packages/i18n/src/locales/ar.ts‎

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1327,10 +1327,6 @@ const ar = {
13271327
appDescription: "الوصف",
13281328
appIcon: "الأيقونة",
13291329
template: "القالب",
1330-
layout: "التخطيط",
1331-
layoutSidebar: "الشريط الجانبي",
1332-
layoutHeader: "الرأس",
1333-
layoutEmpty: "فارغ",
13341330
selectObjects: "تحديد الكائنات",
13351331
searchObjects: "البحث في الكائنات…",
13361332
selectAll: "تحديد الكل",
@@ -1365,7 +1361,7 @@ const ar = {
13651361
appearance: "المظهر",
13661362
rowHeight: "ارتفاع الصف",
13671363
livePreview: "معاينة مباشرة",
1368-
stepBasicDesc: "الاسم والعنوان والتخطيط",
1364+
stepBasicDesc: "الاسم والعنوان والأيقونة",
13691365
stepObjectsDesc: "اختيار كائنات الأعمال",
13701366
stepNavigationDesc: "بناء شجرة التنقل",
13711367
stepBrandingDesc: "الشعار والألوان والأيقونة",

‎packages/i18n/src/locales/de.ts‎

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1307,10 +1307,6 @@ const de = {
13071307
appDescription: "Beschreibung",
13081308
appIcon: "Symbol",
13091309
template: "Vorlage",
1310-
layout: "Layout",
1311-
layoutSidebar: "Seitenleiste",
1312-
layoutHeader: "Kopfzeile",
1313-
layoutEmpty: "Leer",
13141310
selectObjects: "Objekte auswählen",
13151311
searchObjects: "Objekte suchen…",
13161312
selectAll: "Alle auswählen",
@@ -1345,7 +1341,7 @@ const de = {
13451341
appearance: "Erscheinungsbild",
13461342
rowHeight: "Zeilenhöhe",
13471343
livePreview: "Echtzeit-Vorschau",
1348-
stepBasicDesc: "Name, Titel und Layout",
1344+
stepBasicDesc: "Name, Titel und Symbol",
13491345
stepObjectsDesc: "Geschäftsobjekte auswählen",
13501346
stepNavigationDesc: "Navigationsbaum erstellen",
13511347
stepBrandingDesc: "Logo, Farben und Favicon",

0 commit comments

Comments
 (0)