Skip to content
Merged
4 changes: 4 additions & 0 deletions .changeset/10842-wizard-app-declared-keys.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,3 +33,7 @@ has no app layout, so the wizard's layout choice is not saved.
`logo`, `favicon` or `title`; it reads `branding.logo`, `branding.favicon` and `label`.

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

⚠️ **Dated note, 2026-09-28 — the wizard has no layout choice — objectui#10867.**
Later in this same release the wizard's Layout control was removed, with `AppWizardDraft.layout`, so there is no
layout choice left to not save. The rest of this entry still holds.
25 changes: 25 additions & 0 deletions .changeset/10867-app-wizard-saves-spec-app.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
---
'@object-ui/types': minor
'@object-ui/plugin-designer': patch
'@object-ui/i18n': minor
'@object-ui/layout': patch
'@object-ui/app-shell': patch
---

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

⚠️ **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`).

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

- **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.
- **`@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.
- **`@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.
- **`@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.
- **`@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.
- **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".
- **`@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.

**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.

Pinned in `packages/types/src/__tests__/app-wizard-separator-layout-10867.test.ts` and `packages/plugin-designer/src/__tests__/AppWizard.specDocument-10867.test.tsx`.
6 changes: 6 additions & 0 deletions .changeset/3162-types-ledger-batch8-verdicts.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,3 +31,9 @@ Two stale justifications were corrected in passing, both the same defect class t
exists to catch — a comment asserting an upstream type "erases to `any`" when it no longer
does. Left alone, the next triage checks the claim, finds it false, and lands the regression.
No published behaviour changes; no runtime code was touched.

⚠️ **Dated note, 2026-09-28 — one of the three `NavigationItem` blockers has since lifted — objectui#10867.**
Later in this same release the separator `label` stopped being a blocker: `NavigationItem` became a union whose
separator arm admits exactly the spec separator's keys, and `spec-derived-unions.test.ts` pins that agreement where
the blocker stood. Two semantic blockers remain (`visible: boolean`, and `pinned` / `defaultOpen`), so the symbol
is still not bindable. The rest of this entry still holds.
2 changes: 1 addition & 1 deletion ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -673,7 +673,7 @@ ObjectUI is a universal Server-Driven UI (SDUI) engine built on React + Tailwind
- [x] `wizardDraftToAppSchema()` draft-to-schema conversion function

**App Creation Wizard (4-step):**
- [x] Step 1: Basic Info — name (snake_case validated), title, description, icon, template, layout selector
- [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)
- [x] Step 2: Object Selection — card grid with search, select all/none, toggle selection
- [x] Step 3: Navigation Builder — auto-generates NavigationItem[] from selected objects, add group/URL/separator, reorder up/down, remove
- [x] Step 4: Branding — logo URL, primary color, favicon, live preview card
Expand Down
5 changes: 4 additions & 1 deletion packages/app-shell/src/hooks/useNavPins.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,8 @@ export function useNavPins() {
// If a NavigationItem is provided, register/refresh the favorite with
// proper label/href so backend sync carries portable data. Otherwise
// just flip the flag — the existing favorite (if any) keeps its data.
if (item) {
// A separator is never pinnable and carries no `label` (objectui#10867).
if (item && item.type !== 'separator') {
addFavorite({
id: favId,
label: item.label,
Expand Down Expand Up @@ -108,6 +109,8 @@ export function useNavPins() {
let pinCount = 0;
const walk = (list: NavigationItem[]): NavigationItem[] =>
list.map(item => {
// A separator carries no `pinned` and no children (objectui#10867).
if (item.type === 'separator') return item;
const shouldPin = pinnedNavIds.has(item.id) && pinCount < MAX_PINS;
if (shouldPin) pinCount++;
const children = item.children?.length ? walk(item.children) : item.children;
Expand Down
5 changes: 4 additions & 1 deletion packages/app-shell/src/layout/UnifiedSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -410,8 +410,11 @@ export function UnifiedSidebar({ activeAppName }: UnifiedSidebarProps) {
defaultValue: 'Package management',
});
const walk = (items: NavigationItem[]): NavigationItem[] =>
items.flatMap((item) => {
items.flatMap((item): NavigationItem[] => {
if (isMetadataDirectoryItem(item)) return [];
// A separator has no children and is never the packages entry, and
// carries none of the keys rewritten below (objectui#10867).
if (item.type === 'separator') return [item];
const children = item.children?.length ? walk(item.children) : item.children;
if (item.type === 'group' && children?.length === 0) return [];
if (isPackagesItem(item)) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest';
import React from 'react';
import { render, screen, fireEvent } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import type { NavigationArea } from '@object-ui/types';
import type { NavigationArea, NavigationEntryItem } from '@object-ui/types';

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

// Typed as ENTRIES, so a case can spread one and add an entry-only key
// (`visible`, `requiredPermissions`); a separator carries neither (objectui#10867).
const salesEntry: NavigationEntryItem = { id: 'a1', type: 'object', label: 'Opportunities', objectName: 'opportunity' };
const serviceEntry: NavigationEntryItem = { id: 'a2', type: 'object', label: 'Cases', objectName: 'case' };

const salesArea: NavigationArea = {
id: 'area-sales',
label: 'Sales',
navigation: [{ id: 'a1', type: 'object', label: 'Opportunities', objectName: 'opportunity' }],
navigation: [salesEntry],
};

const serviceArea: NavigationArea = {
id: 'area-service',
label: 'Service',
navigation: [{ id: 'a2', type: 'object', label: 'Cases', objectName: 'case' }],
navigation: [serviceEntry],
};

const marketingArea: NavigationArea = {
Expand All @@ -137,7 +142,7 @@ const marketingArea: NavigationArea = {

const gatedSales: NavigationArea = {
...salesArea,
navigation: [{ ...salesArea.navigation[0], visible: false }],
navigation: [{ ...salesEntry, visible: false }],
};

function sidebarUi(areas: NavigationArea[]) {
Expand Down Expand Up @@ -183,7 +188,7 @@ describe('UnifiedSidebar derived area visibility (#3319)', () => {
const partialSales: NavigationArea = {
...salesArea,
navigation: [
{ ...salesArea.navigation[0], visible: false },
{ ...salesEntry, visible: false },
{ id: 'a1b', type: 'object', label: 'Quotes', objectName: 'quote' },
],
};
Expand Down Expand Up @@ -217,7 +222,7 @@ describe('UnifiedSidebar derived area visibility (#3319)', () => {
render(
sidebarUi([
gatedSales,
{ ...serviceArea, navigation: [{ ...serviceArea.navigation[0], visible: false }] },
{ ...serviceArea, navigation: [{ ...serviceEntry, visible: false }] },
]),
);
expect(screen.queryByText('Sales')).not.toBeInTheDocument();
Expand Down Expand Up @@ -245,7 +250,7 @@ describe('UnifiedSidebar derived area visibility (#3319)', () => {
it('re-elects when the ACTIVE area is gated away, and a mere reveal does not steal the selection', () => {
const adminSales: NavigationArea = {
...salesArea,
navigation: [{ ...salesArea.navigation[0], requiredPermissions: ['sales:admin'] }],
navigation: [{ ...salesEntry, requiredPermissions: ['sales:admin'] }],
};
const areas = [adminSales, serviceArea, marketingArea];

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,7 @@ const APPS = [
objectName: 'sys_invoice',
requiresObject: 'sys_invoice',
},
{ id: 'divider_1', type: 'separator', label: '' },
{ id: 'divider_1', type: 'separator' },
],
},
{ name: 'ops', label: 'Operations', icon: 'Wrench', navigation: [] },
Expand Down
6 changes: 1 addition & 5 deletions packages/i18n/src/locales/ar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1327,10 +1327,6 @@ const ar = {
appDescription: "الوصف",
appIcon: "الأيقونة",
template: "القالب",
layout: "التخطيط",
layoutSidebar: "الشريط الجانبي",
layoutHeader: "الرأس",
layoutEmpty: "فارغ",
selectObjects: "تحديد الكائنات",
searchObjects: "البحث في الكائنات…",
selectAll: "تحديد الكل",
Expand Down Expand Up @@ -1365,7 +1361,7 @@ const ar = {
appearance: "المظهر",
rowHeight: "ارتفاع الصف",
livePreview: "معاينة مباشرة",
stepBasicDesc: "الاسم والعنوان والتخطيط",
stepBasicDesc: "الاسم والعنوان والأيقونة",
stepObjectsDesc: "اختيار كائنات الأعمال",
stepNavigationDesc: "بناء شجرة التنقل",
stepBrandingDesc: "الشعار والألوان والأيقونة",
Expand Down
6 changes: 1 addition & 5 deletions packages/i18n/src/locales/de.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1307,10 +1307,6 @@ const de = {
appDescription: "Beschreibung",
appIcon: "Symbol",
template: "Vorlage",
layout: "Layout",
layoutSidebar: "Seitenleiste",
layoutHeader: "Kopfzeile",
layoutEmpty: "Leer",
selectObjects: "Objekte auswählen",
searchObjects: "Objekte suchen…",
selectAll: "Alle auswählen",
Expand Down Expand Up @@ -1345,7 +1341,7 @@ const de = {
appearance: "Erscheinungsbild",
rowHeight: "Zeilenhöhe",
livePreview: "Echtzeit-Vorschau",
stepBasicDesc: "Name, Titel und Layout",
stepBasicDesc: "Name, Titel und Symbol",
stepObjectsDesc: "Geschäftsobjekte auswählen",
stepNavigationDesc: "Navigationsbaum erstellen",
stepBrandingDesc: "Logo, Farben und Favicon",
Expand Down
6 changes: 1 addition & 5 deletions packages/i18n/src/locales/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1572,10 +1572,6 @@ const en = {
appDescription: 'Description',
appIcon: 'Icon',
template: 'Template',
layout: 'Layout',
layoutSidebar: 'Sidebar',
layoutHeader: 'Header',
layoutEmpty: 'Empty',
selectObjects: 'Select Objects',
searchObjects: 'Search objects…',
selectAll: 'Select All',
Expand Down Expand Up @@ -1610,7 +1606,7 @@ const en = {
appearance: 'Appearance',
rowHeight: 'Row Height',
livePreview: 'Live Preview',
stepBasicDesc: 'Name, title, and layout',
stepBasicDesc: 'Name, title, and icon',
stepObjectsDesc: 'Select business objects',
stepNavigationDesc: 'Build navigation tree',
stepBrandingDesc: 'Logo, colors, and favicon',
Expand Down
6 changes: 1 addition & 5 deletions packages/i18n/src/locales/es.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1311,10 +1311,6 @@ const es = {
appDescription: "Descripción",
appIcon: "Icono",
template: "Plantilla",
layout: "Diseño",
layoutSidebar: "Barra lateral",
layoutHeader: "Encabezado",
layoutEmpty: "Vacío",
selectObjects: "Seleccionar objetos",
searchObjects: "Buscar objetos…",
selectAll: "Seleccionar todo",
Expand Down Expand Up @@ -1349,7 +1345,7 @@ const es = {
appearance: "Apariencia",
rowHeight: "Altura de fila",
livePreview: "Vista previa en vivo",
stepBasicDesc: "Nombre, título y diseño",
stepBasicDesc: "Nombre, título e icono",
stepObjectsDesc: "Seleccionar objetos de negocio",
stepNavigationDesc: "Construir árbol de navegación",
stepBrandingDesc: "Logo, colores y favicon",
Expand Down
6 changes: 1 addition & 5 deletions packages/i18n/src/locales/fr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1309,10 +1309,6 @@ const fr = {
appDescription: "Description",
appIcon: "Icône",
template: "Modèle",
layout: "Disposition",
layoutSidebar: "Barre latérale",
layoutHeader: "En-tête",
layoutEmpty: "Vide",
selectObjects: "Sélectionner des objets",
searchObjects: "Rechercher des objets…",
selectAll: "Tout sélectionner",
Expand Down Expand Up @@ -1347,7 +1343,7 @@ const fr = {
appearance: "Apparence",
rowHeight: "Hauteur de ligne",
livePreview: "Aperçu en direct",
stepBasicDesc: "Nom, titre et mise en page",
stepBasicDesc: "Nom, titre et icône",
stepObjectsDesc: "Sélectionner les objets métier",
stepNavigationDesc: "Construire l'arbre de navigation",
stepBrandingDesc: "Logo, couleurs et favicon",
Expand Down
6 changes: 1 addition & 5 deletions packages/i18n/src/locales/ja.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1307,10 +1307,6 @@ const ja = {
appDescription: "説明",
appIcon: "アイコン",
template: "テンプレート",
layout: "レイアウト",
layoutSidebar: "サイドバー",
layoutHeader: "ヘッダー",
layoutEmpty: "空",
selectObjects: "オブジェクトを選択",
searchObjects: "オブジェクトを検索…",
selectAll: "すべて選択",
Expand Down Expand Up @@ -1345,7 +1341,7 @@ const ja = {
appearance: "外観",
rowHeight: "行高さ",
livePreview: "リアルタイムプレビュー",
stepBasicDesc: "名前、タイトル、レイアウト",
stepBasicDesc: "名前、タイトル、アイコン",
stepObjectsDesc: "ビジネスオブジェクトを選択",
stepNavigationDesc: "ナビゲーションツリーを構築",
stepBrandingDesc: "ロゴ、色、ファビコン",
Expand Down
6 changes: 1 addition & 5 deletions packages/i18n/src/locales/ko.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1307,10 +1307,6 @@ const ko = {
appDescription: "설명",
appIcon: "아이콘",
template: "템플릿",
layout: "레이아웃",
layoutSidebar: "사이드바",
layoutHeader: "헤더",
layoutEmpty: "비어 있음",
selectObjects: "오브젝트 선택",
searchObjects: "오브젝트 검색…",
selectAll: "모두 선택",
Expand Down Expand Up @@ -1345,7 +1341,7 @@ const ko = {
appearance: "외관",
rowHeight: "행 높이",
livePreview: "실시간 미리보기",
stepBasicDesc: "이름, 제목 및 레이아웃",
stepBasicDesc: "이름, 제목 및 아이콘",
stepObjectsDesc: "비즈니스 객체 선택",
stepNavigationDesc: "네비게이션 트리 구성",
stepBrandingDesc: "로고, 색상 및 파비콘",
Expand Down
6 changes: 1 addition & 5 deletions packages/i18n/src/locales/pt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1306,10 +1306,6 @@ const pt = {
appDescription: "Descrição",
appIcon: "Ícone",
template: "Modelo",
layout: "Layout",
layoutSidebar: "Barra lateral",
layoutHeader: "Cabeçalho",
layoutEmpty: "Vazio",
selectObjects: "Selecionar objetos",
searchObjects: "Pesquisar objetos…",
selectAll: "Selecionar todos",
Expand Down Expand Up @@ -1344,7 +1340,7 @@ const pt = {
appearance: "Aparência",
rowHeight: "Altura da linha",
livePreview: "Pré-visualização em tempo real",
stepBasicDesc: "Nome, título e layout",
stepBasicDesc: "Nome, título e ícone",
stepObjectsDesc: "Selecionar objetos de negócio",
stepNavigationDesc: "Construir árvore de navegação",
stepBrandingDesc: "Logo, cores e favicon",
Expand Down
6 changes: 1 addition & 5 deletions packages/i18n/src/locales/ru.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1327,10 +1327,6 @@ const ru = {
appDescription: "Описание",
appIcon: "Значок",
template: "Шаблон",
layout: "Макет",
layoutSidebar: "Боковая панель",
layoutHeader: "Заголовок",
layoutEmpty: "Пусто",
selectObjects: "Выбрать объекты",
searchObjects: "Поиск объектов…",
selectAll: "Выбрать все",
Expand Down Expand Up @@ -1365,7 +1361,7 @@ const ru = {
appearance: "Внешний вид",
rowHeight: "Высота строки",
livePreview: "Предпросмотр в реальном времени",
stepBasicDesc: "Имя, заголовок и макет",
stepBasicDesc: "Имя, заголовок и значок",
stepObjectsDesc: "Выбрать бизнес-объекты",
stepNavigationDesc: "Построить дерево навигации",
stepBrandingDesc: "Логотип, цвета и фавикон",
Expand Down
Loading
Loading