From 1d24a9ef9d12788804ec802af633b12b85e7bd3b Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 24 Sep 2026 14:29:37 +0000 Subject: [PATCH 1/5] feat(types): the App/Dashboard/Page twins take the spec by reference like their mirrors Each hand-written TypeScript twin now extends Omit over the spec's exported input type (App / Dashboard / Page) using the SAME as-const exclusion array its zod mirror's specFieldsExcept call reads (APP_SPEC_EXCLUDED, DASHBOARD_SPEC_EXCLUDED, PAGE_SPEC_EXCLUDED), so the published validator and the published type project one spec surface. Dashboard header and Page slots are withheld on the type side only (hand-written members not assignable to the spec's; already ledgered drift). contextSelectors is declared on the App twin over the mirror's own element schema. The MirroredUndeclared rows for the three pairs go to zero; spec tombstones now surface as optional members typed undefined, so authoring one is a compile error. Types-only: no runtime change, no mirror narrowed. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01877XiBYSaRCk2CU7cMSg3S --- ...hboard-aria-retired-contract-twins.test.ts | 46 +++--- .../twins-spec-by-reference-9736.test.ts | 132 ++++++++++++++++++ .../src/__tests__/zod-mirror-parity.test.ts | 111 ++++++--------- packages/types/src/app.ts | 50 ++++++- packages/types/src/complex.ts | 43 +++++- packages/types/src/layout.ts | 34 ++++- packages/types/src/zod/app.zod.ts | 10 +- packages/types/src/zod/complex.zod.ts | 10 +- packages/types/src/zod/layout.zod.ts | 10 +- 9 files changed, 347 insertions(+), 99 deletions(-) create mode 100644 packages/types/src/__tests__/twins-spec-by-reference-9736.test.ts diff --git a/packages/types/src/__tests__/dashboard-aria-retired-contract-twins.test.ts b/packages/types/src/__tests__/dashboard-aria-retired-contract-twins.test.ts index 8c14cb7f43..52f33dafb1 100644 --- a/packages/types/src/__tests__/dashboard-aria-retired-contract-twins.test.ts +++ b/packages/types/src/__tests__/dashboard-aria-retired-contract-twins.test.ts @@ -21,17 +21,19 @@ * `AriaPropsSchema`, the opposite of the contract: the member-level instance * of #4631's "declared surfaces disagree". * - * What the deletion changes at the type level, stated honestly: `BaseSchema` - * carries `[key: string]: any`, so an authored `aria:` on a dashboard literal - * still COMPILES after the removal — it falls to the index signature. A - * `@ts-expect-error` pin on an authored literal therefore cannot stick here - * (unlike `default-children-retired-contract-twins.test.ts`, whose interface - * has no index signature). The pinnable effect is that `aria` stops being a - * DECLARED member: the probe below extracts the interface's literal key set — - * the index signature is filtered out by `string extends K` — and asserts - * `aria` is out while its former neighbours stay in. Real enforcement because - * `packages/types/tsconfig.test.json` is chained from this package's - * `type-check` script (#3009). + * What the deletion changed at the type level, stated honestly: `BaseSchema` + * carries `[key: string]: any`, so after the removal an authored `aria:` on a + * dashboard literal still COMPILED — it fell to the index signature, and a + * `@ts-expect-error` pin on an authored literal could not stick. + * + * objectui#9736 closed that half. The interface now extends the spec's own + * `Dashboard` input type (`Omit< Dashboard, … >` over the exclusion list its + * mirror reads), so it inherits the spec's tombstone as a DECLARED member typed + * `undefined` — `aria` is back in the declared key set, but only as a refusal, + * and an authored value is a compile error: the same verdict the Zod twin gives + * at parse. The pins below assert exactly that, with the former neighbours as + * the control. Real enforcement because `packages/types/tsconfig.test.json` is + * chained from this package's `type-check` script (#3009). */ import { describe, it, expect } from 'vitest'; @@ -43,18 +45,24 @@ import { DashboardComponentSchema as DashboardComponentZodSchema } from '../zod/ type DeclaredKeys = { [K in keyof T as string extends K ? never : K]: T[K] }; type Declared = keyof DeclaredKeys; -describe('the TS interface no longer declares `aria` (objectui#5830)', () => { - it('`aria` is not a declared member; the neighbours it stood beside still are', () => { - // Type-level pin, erased at runtime: if the member came back, the first - // annotation would collapse to `false` and this file would fail - // `type-check`. (Reverse-verified at the PR: with the member restored, - // `tsc -p tsconfig.test.json` goes red on exactly this line.) - const ariaNotDeclared: 'aria' extends Declared ? false : true = true; +describe('the TS interface declares `aria` only as the spec tombstone (objectui#5830, objectui#9736)', () => { + it('`aria` is declared, typed `undefined`; the neighbours it stood beside still are', () => { + // Type-level pins, erased at runtime. `aria` is a DECLARED key again — the + // spec projection carries the tombstone — and its type admits no value. + const ariaDeclared: 'aria' extends Declared ? true : false = true; + const ariaAdmitsNoValue: [DashboardComponentSchema['aria']] extends [undefined] ? true : false = true; // Positive controls through the same extraction: a probe that saw no // members at all would also report `aria` absent. const widgetsDeclared: 'widgets' extends Declared ? true : false = true; const dateRangeDeclared: 'dateRange' extends Declared ? true : false = true; - expect(ariaNotDeclared && widgetsDeclared && dateRangeDeclared).toBe(true); + expect(ariaDeclared && ariaAdmitsNoValue && widgetsDeclared && dateRangeDeclared).toBe(true); + }); + + it('an authored `aria` value is a compile error — no longer absorbed by the index signature', () => { + const legal: DashboardComponentSchema = { type: 'dashboard', widgets: [] }; + // @ts-expect-error — `aria` is the spec's retirement tombstone (objectui#9736). + const authored: DashboardComponentSchema = { type: 'dashboard', widgets: [], aria: { ariaLabel: 'Ops' } }; + expect([legal.type, authored.type]).toEqual(['dashboard', 'dashboard']); }); }); diff --git a/packages/types/src/__tests__/twins-spec-by-reference-9736.test.ts b/packages/types/src/__tests__/twins-spec-by-reference-9736.test.ts new file mode 100644 index 0000000000..e515c27518 --- /dev/null +++ b/packages/types/src/__tests__/twins-spec-by-reference-9736.test.ts @@ -0,0 +1,132 @@ +/** + * 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. + */ + +/** + * objectui#9736 — the three hand-written TypeScript twins take the spec BY + * REFERENCE, the way their zod mirrors do (ruling batch #167 item 4, letter 甲). + * + * `AppComponentSchema` / `DashboardComponentSchema` / `PageNodeSchema` each + * extend `Omit< App | Dashboard | Page, … >` over the SAME `as const` exclusion + * array their mirror's `specFieldsExcept` call reads (`APP_SPEC_EXCLUDED`, + * `DASHBOARD_SPEC_EXCLUDED`, `PAGE_SPEC_EXCLUDED`), plus — on the TypeScript + * face only — the one twin member per pair whose hand-written type is not + * assignable to the spec's (`header` on the dashboard, `slots` on the page; + * both already ledgered as drift in `zod-mirror-parity.test.ts`). + * + * ## Why the positive pins are TYPE equalities, not assignments + * + * `BaseSchema` carries `[key: string]: any`, so an object literal carrying ANY + * key compiles against these interfaces whether or not the key is declared — + * an "it compiles" pin would be green on the tree before this change. So: + * - declared-ness is read through `DeclaredKeys`, which filters the index + * signature out (`string extends K`); + * - member types are compared with `Equal`, against the spec's own member; + * - the tombstone half is a `@ts-expect-error` on an authored value, which + * only sticks BECAUSE the key is now declared (the index signature would + * have absorbed it as `any` — `dashboard-aria-retired-contract-twins` is the + * record of that pin not sticking before). + * + * Real enforcement because `packages/types/tsconfig.test.json` is chained from + * this package's `type-check` script; the runtime half asserts the mirror + * gives the same verdict on the same literal. + */ + +import { describe, it, expect } from 'vitest'; +import type { App, Dashboard, Page } from '@objectstack/spec/ui'; +import type { AppComponentSchema } from '../app'; +import type { DashboardComponentSchema } from '../complex'; +import type { PageNodeSchema } from '../layout'; +import { + AppComponentSchema as AppMirror, + DashboardComponentSchema as DashboardMirror, + PageNodeSchema as PageMirror, +} from '../zod/index.zod'; + +type Equal = (() => T extends A ? 1 : 2) extends (() => T extends B ? 1 : 2) ? true : false; +type DeclaredKeys = keyof { [K in keyof T as string extends K ? never : K]: T[K] }; + +/** Keys the spec's document type declares that the twin does NOT declare. */ +type SpecKeysUndeclaredOn = Exclude>; + +describe('each twin declares every key its spec document type declares', () => { + it('App / Dashboard / Page: the spec-minus-declared key set is empty', () => { + // Before objectui#9736 these read as the MirroredUndeclared rows (20 / 11 / 10 + // keys). ⭐ Control: `DeclaredKeys` must see real members, or `never` above is + // vacuous — `protection` / `requires` are keys ONLY the spec projection brings. + const app: Equal, never> = true; + const dashboard: Equal, never> = true; + const page: Equal, never> = true; + const controlApp: 'protection' extends DeclaredKeys ? true : false = true; + const controlPage: 'requires' extends DeclaredKeys ? true : false = true; + const controlNegative: 'notASpecKey9736' extends DeclaredKeys ? true : false = false; + expect([app, dashboard, page, controlApp, controlPage, controlNegative]).toEqual([true, true, true, true, true, false]); + }); +}); + +describe('admitted spec keys land on the twin with the SPEC\'S type, not `any`', () => { + it('the package-lock envelope and document keys equal the spec member', () => { + const lock: Equal = true; + const appProtection: Equal = true; + const defaultAgent: Equal = true; + const dashProtection: Equal = true; + const dashPackageId: Equal = true; + const pageSource: Equal = true; + const pageRequires: Equal = true; + const pageInterfaceConfig: Equal = true; + // Control: an undeclared key reads as `any` through the index signature, and + // `Equal` tells `any` apart from a real member type. + const anyControl: Equal = false; + expect([lock, appProtection, defaultAgent, dashProtection, dashPackageId, pageSource, pageRequires, pageInterfaceConfig, anyControl]) + .toEqual([true, true, true, true, true, true, true, true, false]); + }); + + it('`contextSelectors` — withheld from the projection — is declared over the mirror\'s own element', () => { + const declared: 'contextSelectors' extends DeclaredKeys ? true : false = true; + const selectors: AppComponentSchema['contextSelectors'] = [ + { id: 'pkg', label: 'Package', optionsSource: { endpoint: '/api/v1/packages' } }, + ]; + expect(declared).toBe(true); + expect(AppMirror.safeParse({ type: 'app', contextSelectors: selectors }).success).toBe(true); + }); +}); + +describe('spec tombstones surface on the twin as a refusal — the verdict the mirror gives', () => { + it('App `version` / Dashboard `refreshInterval`: authoring a value is a compile error AND a parse failure', () => { + // @ts-expect-error — `version` is the spec's `retiredKey()` tombstone. + const app: AppComponentSchema = { type: 'app', version: '1.0.0' }; + // @ts-expect-error — `refreshInterval` is the #15680 rename's tombstone. + const dashboard: DashboardComponentSchema = { type: 'dashboard', widgets: [], refreshInterval: 30 }; + expect(AppMirror.safeParse(app).success).toBe(false); + expect(DashboardMirror.safeParse(dashboard).success).toBe(false); + }); + + it('the controls: the same documents without the tombstone key pass both faces', () => { + const app: AppComponentSchema = { type: 'app' }; + const dashboard: DashboardComponentSchema = { type: 'dashboard', widgets: [], refreshIntervalSeconds: 30 }; + expect(AppMirror.safeParse(app).success).toBe(true); + expect(DashboardMirror.safeParse(dashboard).success).toBe(true); + }); + + it('`Page` carries no tombstone on the installed pin — its admitted keys pass both faces', () => { + // ⚠️ Recorded rather than assumed: the spec's `PageSchema` has no + // `retiredKey()` member on @objectstack/spec 17.4.0, so the page twin's pin + // is the admitted half only. + const page: PageNodeSchema = { type: 'page', source: 'pages/home.tsx', requires: ['crm'] }; + expect(PageMirror.safeParse(page).success).toBe(true); + }); +}); + +describe('the twin-only omissions keep the twin\'s own member, unwidened', () => { + it('Dashboard `header` and Page `slots` are the hand-written types, not the spec\'s', () => { + type HeaderAction = NonNullable['actions']>[number]; + const headerLabel: Equal = true; + const headerNotSpec: Equal = false; + const slotsNotSpec: Equal = false; + expect([headerLabel, headerNotSpec, slotsNotSpec]).toEqual([true, false, false]); + }); +}); diff --git a/packages/types/src/__tests__/zod-mirror-parity.test.ts b/packages/types/src/__tests__/zod-mirror-parity.test.ts index 11e4c55a5b..c04bf9add7 100644 --- a/packages/types/src/__tests__/zod-mirror-parity.test.ts +++ b/packages/types/src/__tests__/zod-mirror-parity.test.ts @@ -3464,7 +3464,7 @@ export const assertionWiderLedgerRecordsEveryKey: never = 0 as unknown as WiderL * Exact MIRRORED-BUT-UNDECLARED key set per pair — the seed of the direction * objectui#9711 opened, measured on the tree this ledger landed on. * - * `this ledger seeds **7 entries** carrying **101 keys**` — and ⛔ read that off the + * `this ledger seeds **4 entries** carrying **60 keys**` — and ⛔ read that off the * census at the bottom of this file, not off this sentence: both figures are pinned * to this ledger's own AST by 'the fourth direction is enumerated and sized at test * time', so an entry or an arm added here moves them or the file reddens. @@ -3475,6 +3475,13 @@ export const assertionWiderLedgerRecordsEveryKey: never = 0 as unknown as WiderL * row stood says which ruling took it and why the neighbouring `operators` row * stayed after a ruling of its own. * + * ⭐ Then by three entries at once, when objectui#9736 executed ruling batch #167 + * item 4 (letter 甲): the SPEC-OWNED INFLOW rows — App, Dashboard, Page — left + * because the three TypeScript twins now take the spec by reference over the SAME + * exclusion arrays their mirrors read. That is not the "declare the keys one by + * one" remedy the next section refuses: no key was hand-declared, the twin and the + * mirror were put on one derivation, and the ruling named the mechanism. + * * ## ⛔ What this ledger is NOT * * ⛔ It is NOT a worklist to be drained by DECLARING the keys it names. Declaring a @@ -3518,57 +3525,25 @@ export const assertionWiderLedgerRecordsEveryKey: never = 0 as unknown as WiderL * `never` and would never be caught at all. */ interface MirroredUndeclared { - /** - * SPEC-OWNED INFLOW. The mirror is `BaseSchema.extend(SpecAppFields.shape).extend({…})`, - * so the spec's App surface — the `_lock*` / `_package*` / `_provenance` package-lock - * envelope, `protection`, `sharing`, `embed`, `objects`, `apis`, `homePageId`, - * `version`, `isDefault`, `_unpublished`, `aria`, `mobileNavigation`, `defaultAgent` — - * is validated here while the TypeScript interface restates only the component - * envelope its renderers read. `contextSelectors` is the one LOCAL arm: the spec - * fields deliberately omit it and the mirror's own `.extend({…})` adds it back, so - * it is mirrored by this file's own hand and still undeclared. - */ - "app.zod.ts#AppComponentSchema": - | "contextSelectors" - | "_lock" - | "_lockReason" - | "_lockSource" - | "_provenance" - | "_packageId" - | "_packageVersion" - | "_lockDocsUrl" - | "version" - | "isDefault" - | "_unpublished" - | "homePageId" - | "objects" - | "apis" - | "sharing" - | "embed" - | "mobileNavigation" - | "defaultAgent" - | "aria" - | "protection"; + // `app.zod.ts#AppComponentSchema` HAD a row here — 20 keys of SPEC-OWNED INFLOW + // (the package-lock envelope, `protection`, `isDefault`, `_unpublished`, + // `defaultAgent`, the spec's tombstones, and the LOCAL `contextSelectors` arm) — + // and its DELETION is the ratchet moving in its only permitted direction. + // objectui#9736 (ruling batch #167 item 4, letter 甲) made the TypeScript twin take + // the spec BY REFERENCE the way its mirror does: `AppComponentSchema extends + // BaseSchema, Omit< App, … >` over `APP_SPEC_EXCLUDED`, the one `as const` array the + // mirror's `specFieldsExcept` call also reads, and `contextSelectors` declared on + // the twin over the mirror's own element schema. The pair now measures `never`; + // re-adding the row without re-adding the defect reddens the reconciliation below. + + + // `complex.zod.ts#DashboardComponentSchema` HAD a row here — 11 keys (the envelope, + // `protection`, and the tombstones `aria` / `refreshInterval` / `performance`) — + // deleted by objectui#9736 for the reason the App note above gives: the twin + // extends `Omit< Dashboard, … >` over `DASHBOARD_SPEC_EXCLUDED`. Its `header` + // member is withheld from that projection on the TypeScript face only, and stays + // in `KnownDrift` / `WiderThanDeclared` where it was. - /** - * SPEC-OWNED INFLOW, same envelope as the App pair above plus this surface's own - * `refreshInterval` and `performance`. ⚠️ This pair already carries entries in - * `KnownDrift` and has carried them in `UnmirroredDeclared`; the three directions - * are independent measurements of one pair and an entry in one says nothing about - * the others. - */ - "complex.zod.ts#DashboardComponentSchema": - | "_lock" - | "_lockReason" - | "_lockSource" - | "_provenance" - | "_packageId" - | "_packageVersion" - | "_lockDocsUrl" - | "aria" - | "protection" - | "refreshInterval" - | "performance"; /** * LOCAL, and a single key: the mirror states `aria` and the declaration does not. @@ -3659,22 +3634,12 @@ interface MirroredUndeclared { | "minDate" | "maxDate"; - /** - * SPEC-OWNED INFLOW. The package-lock envelope again, plus `source`, - * `interfaceConfig` and `requires` — page-level spec keys the mirror validates and - * the TypeScript node does not state. - */ - "layout.zod.ts#PageNodeSchema": - | "source" - | "_lock" - | "_lockReason" - | "_lockSource" - | "_provenance" - | "_packageId" - | "_packageVersion" - | "_lockDocsUrl" - | "interfaceConfig" - | "requires"; + // `layout.zod.ts#PageNodeSchema` HAD a row here — 10 keys (the envelope, `source`, + // `interfaceConfig`, `requires`) — deleted by objectui#9736 for the same reason: + // the twin extends `Omit< Page, … >` over `PAGE_SPEC_EXCLUDED`. Its `slots` member + // is withheld from that projection on the TypeScript face only, and stays in + // `KnownDrift` / `WiderThanDeclared` where it was. + /** * LOCAL, one key — and the row STAYS after its ruling, which is the thing to @@ -3987,6 +3952,18 @@ const EXCLUSIONS: Readonly> = { "a union (`string | { dialect?, source }`) with no `.shape` of its own — the predicate WIRE shape `BaseSchema`'s `visible` / `hidden` / `disabled` and the form predicate keys carry (objectui#7530); its TS twin `ExpressionWire` (`../expression.ts`) is a type alias, not a key set, and the two faces are pinned equal in `base-schema-predicate-envelope-7530.test.ts`", 'index.zod.ts#SCHEMA_VERSION': "a version string, not a schema", + // objectui#9736 — the three exclusion lists each spec-derived mirror's + // `specFieldsExcept` call reads, hoisted to one `as const` array so the + // TypeScript twin can `Omit` over the SAME list. Key-name tuples, not schemas; + // what they govern is measured on the pairs they feed + // (`app.zod.ts#AppComponentSchema` / `complex.zod.ts#DashboardComponentSchema` / + // `layout.zod.ts#PageNodeSchema`), pinned in `./twins-spec-by-reference-9736.test.ts`. + 'app.zod.ts#APP_SPEC_EXCLUDED': + "a key-name tuple, not a schema — the exclusion list `SpecAppFields` and the `AppComponentSchema` twin both read (objectui#9736)", + 'complex.zod.ts#DASHBOARD_SPEC_EXCLUDED': + "a key-name tuple, not a schema — the exclusion list `SpecDashboardFields` and the `DashboardComponentSchema` twin both read (objectui#9736)", + 'layout.zod.ts#PAGE_SPEC_EXCLUDED': + "a key-name tuple, not a schema — the exclusion list `SpecPageFields` and the `PageNodeSchema` twin both read (objectui#9736)", 'objectql.zod.ts#KanbanConditionalFormattingRuleSchema': "a union of two rule dialects (native `{ field, operator, value }` | spec `{ condition, style }`) with no `.shape` of its own — exported by objectui#7664 so the `'kanban'` arm (`complex.zod.ts#KanbanSchema`) and the `'object-kanban'` arm mirror `conditionalFormatting` from ONE rule declaration; its TS twin `KanbanConditionalFormattingRule` (`../objectql.ts`) is a type union, not a key set, and both arms' `conditionalFormatting` keys are compared where they are declared", }; diff --git a/packages/types/src/app.ts b/packages/types/src/app.ts index 850ee60718..f9f026e8f9 100644 --- a/packages/types/src/app.ts +++ b/packages/types/src/app.ts @@ -51,8 +51,11 @@ import type { UrlNavItem as SpecUrlNavItem, ActionNavItem as SpecActionNavItem, ComponentNavItem as SpecComponentNavItem, + App as SpecApp, } from '@objectstack/spec/ui'; import type { BaseSchema } from './base.js'; +import type { z } from 'zod'; +import type { APP_SPEC_EXCLUDED, AppContextSelectorSchema } from './zod/app.zod.js'; // ============================================================================ // Unified Navigation Model (aligned with @objectstack/spec) @@ -366,8 +369,41 @@ export interface NavigationArea extends Omit { /** * Top-level Application Configuration (app.json) + * + * ## The spec half is taken BY REFERENCE (objectui#9736) + * + * Its zod mirror (`zod/app.zod.ts` `AppComponentSchema`) has long been + * `BaseSchema.extend(SpecAppFields.shape).extend({…})`: every key + * `@objectstack/spec/ui`'s `AppSchema` declares flows into the published + * validator by reference. This interface used to restate only the subset the + * renderers read, so the published validator admitted keys the published type + * did not declare — the package-lock envelope (`_lock*` / `_package*` / + * `_provenance`, written by the packaging pipeline, never by an author), + * `protection`, `isDefault`, `_unpublished`, `defaultAgent` — and the spec's + * retirement tombstones (`version`, `homePageId`, `objects`, `apis`, + * `sharing`, `embed`, `mobileNavigation`, `aria`) reached this type only as + * `any` through `BaseSchema`'s index signature. + * + * Now both faces project the SAME spec surface: this interface extends + * `Omit< App, … >` over `APP_SPEC_EXCLUDED`, the one `as const` array the + * mirror's `specFieldsExcept` call also reads. A key the spec adds on the next + * pin bump reaches both faces together; a key it retires with `retiredKey()` + * surfaces here as an optional member typed `undefined`, so authoring a value + * is a compile error — the verdict the validator gives at parse. + * + * The members this interface writes itself override the spec's where both + * exist (`icon`, `branding`, `active`, `hidden`, `requiredPermissions`), and + * each is assignable to the spec's type, so nothing beyond the shared list is + * omitted. The three keys the shared list withholds from the spec projection + * (`navigation` / `areas` / `contextSelectors`) are declared below with the + * same local element types the mirror re-adds; `name` / `label` / + * `description` are the component envelope. + * + * Pinned by `__tests__/twins-spec-by-reference-9736.test.ts`; the key-level + * reconciliation is the `MirroredUndeclared` ledger in + * `__tests__/zod-mirror-parity.test.ts`, which has no row for this pair. */ -export interface AppComponentSchema extends BaseSchema { +export interface AppComponentSchema extends BaseSchema, Omit { type: 'app'; /** @@ -518,6 +554,18 @@ export interface AppComponentSchema extends BaseSchema { */ areas?: NavigationArea[]; + /** + * App-level scope dropdowns (sidebar / topbar), whose selected value is + * injected into navigation items as a `{}` template var. + * + * Withheld from the spec projection by `APP_SPEC_EXCLUDED` because the + * mirror re-adds it with its own element schema (`AppContextSelectorSchema`, + * whose `label` also takes objectui's i18n label envelope), so this member + * takes that element BY REFERENCE (objectui#9736) rather than restating it — + * the two faces read one declaration. + */ + contextSelectors?: Array>; + /** * Global Actions (User Profile, Settings, etc) */ diff --git a/packages/types/src/complex.ts b/packages/types/src/complex.ts index e3787c6aa6..073d790f5a 100644 --- a/packages/types/src/complex.ts +++ b/packages/types/src/complex.ts @@ -19,8 +19,10 @@ import type { DashboardWidget as SpecDashboardWidget, DateRangeDefaultRange as SpecDateRangeDefaultRange, GlobalFilter as SpecGlobalFilter, + Dashboard as SpecDashboard, } from '@objectstack/spec/ui'; import type { BaseSchema, SchemaNode } from './base.js'; +import type { DASHBOARD_SPEC_EXCLUDED } from './zod/complex.zod.js'; // `GroupingConfig`, `KanbanConditionalFormattingRule` and `ViewNavigationConfig` // were imported for `KanbanSchema`'s `grouping`, `conditionalFormatting` and // `navigation` members and had no other reader in this module; they left with @@ -2132,8 +2134,41 @@ export interface DashboardWidgetSlotComponentSchema extends BaseSchema { /** * Dashboard Schema + * + * ## The spec half is taken BY REFERENCE (objectui#9736) + * + * The zod mirror (`zod/complex.zod.ts` `DashboardComponentSchema`) is + * `BaseSchema.extend(SpecDashboardFields.shape).extend({…})`, so every key + * `@objectstack/spec/ui`'s `DashboardSchema` declares reaches the published + * validator by reference. This interface used to restate only the members the + * renderers read, which left the validator admitting keys the type never + * declared (the package-lock envelope `_lock*` / `_package*` / `_provenance`, + * written by the packaging pipeline, and `protection`) and the spec's + * tombstones (`aria`, `refreshInterval`, `performance`) typed `any` through + * `BaseSchema`'s index signature. + * + * Now it extends `Omit< Dashboard, … >` over `DASHBOARD_SPEC_EXCLUDED`, the one + * `as const` array the mirror's `specFieldsExcept` call also reads, so the two + * faces project the same spec surface and move together on a pin bump. A + * tombstone surfaces as an optional member typed `undefined`: authoring a + * value is a compile error, the verdict the validator gives at parse. + * + * ONE key is omitted from the spec projection beyond the shared list, on the + * TypeScript face only: + * - `header` — the member below is a hand-written restatement that DISAGREES + * with the spec's in both directions (`actions[].label` narrower: `string` + * against `I18nLabel`; `actions[].actionUrl` optional where the spec + * requires it; `actions[].actionType` an open `string` against the spec's + * enum), so it is not assignable to the spec's member and cannot sit beside + * it. That disagreement is already ledgered as `KnownDrift` and + * `WiderThanDeclared` for this pair in `__tests__/zod-mirror-parity.test.ts`; + * re-aligning it is that ledger's decision, ⛔ not widened here. The mirror + * keeps validating the spec's `header` — the omission is type-side only, + * which is why it is spelled beside the shared list, not inside it. + * + * Pinned by `__tests__/twins-spec-by-reference-9736.test.ts`. */ -export interface DashboardComponentSchema extends BaseSchema { +export interface DashboardComponentSchema extends BaseSchema, Omit { type: 'dashboard'; // `title` was DECLARED here until objectui#7623, under the comment "Dashboard // title displayed in the header" — by then a description of behaviour that had @@ -2260,9 +2295,9 @@ export interface DashboardComponentSchema extends BaseSchema { // `DashboardSchema.shape.aria` is a tombstone that refuses any value, the // Zod twin (`zod/complex.zod.ts`) inherits that refusal through // `SpecDashboardFields`, and `plugin-dashboard` has no `schema.aria` read - // site. Note `BaseSchema`'s index signature still types an authored `aria` - // as `any` — this deletion removes the type-level suggestion and the false - // parity claim, not a key that ever rendered. Pinned by + // site. Since objectui#9736 this interface inherits the same tombstone from + // the spec projection (`aria?: undefined`), so an authored `aria` is now a + // compile error rather than `any` through the index signature. Pinned by // `__tests__/dashboard-aria-retired-contract-twins.test.ts`. /** * REFUSED BY NAME (objectui#9256, ADR-0049) — `dashboard` reads NEITHER diff --git a/packages/types/src/layout.ts b/packages/types/src/layout.ts index 6acdee8dac..973a6505d7 100644 --- a/packages/types/src/layout.ts +++ b/packages/types/src/layout.ts @@ -16,8 +16,9 @@ * @packageDocumentation */ -import type { I18nLabel, PageType as SpecPageType } from '@objectstack/spec/ui'; +import type { I18nLabel, Page as SpecPage, PageType as SpecPageType } from '@objectstack/spec/ui'; import type { BaseSchema, SchemaNode } from './base.js'; +import type { PAGE_SPEC_EXCLUDED } from './zod/layout.zod.js'; import type { BreakpointName } from './mobile.js'; /** @@ -1284,6 +1285,35 @@ export interface PageNodeRegion { * Top-level container for a page route. * Aligned with @objectstack/spec PageSchema * + * ## The spec half is taken BY REFERENCE (objectui#9736) + * + * The zod mirror (`zod/layout.zod.ts` `PageNodeSchema`) is + * `BaseSchema.extend(SpecPageFields.shape).extend({…})`, so every key the + * spec's `PageSchema` declares reaches the published validator by reference. + * This interface used to restate only the members the renderers read, leaving + * the validator admitting keys the type never declared — the package-lock + * envelope (`_lock*` / `_package*` / `_provenance`, written by the packaging + * pipeline), `source`, `interfaceConfig`, `requires`. It now extends + * `Omit< Page, … >` over `PAGE_SPEC_EXCLUDED`, the one `as const` array the + * mirror's `specFieldsExcept` call also reads, so both faces project the same + * spec surface and move together on a pin bump. + * + * ONE key is omitted from the spec projection beyond the shared list, on the + * TypeScript face only: + * - `slots` — the member below types each slot as objectui's `SchemaNode` + * (which admits primitives and `null`), not the spec's page-component + * shape, so it is not assignable to the spec's member and cannot sit beside + * it. That divergence is already ledgered as `KnownDrift` and + * `WiderThanDeclared` for this pair in `__tests__/zod-mirror-parity.test.ts`; + * ⛔ not changed here. The mirror keeps validating the spec's `slots` — the + * omission is type-side only, so it is spelled beside the shared list, not + * inside it. + * + * The other members this interface writes itself (`icon`, `object`, + * `template`, `variables`, `isDefault`, `assignedProfiles`, `aria`, `kind`) + * are each assignable to the spec's, so they override it without an omission. + * Pinned by `__tests__/twins-spec-by-reference-9736.test.ts`. + * * This is the SDUI NODE, not the authored page DOCUMENT — the spec's `Page` * is that, and the two are deliberately different types (same layer split as * {@link PageNodeRegion}). @@ -1293,7 +1323,7 @@ export interface PageNodeRegion { * `@object-ui/components` registers `PageRenderer` under, i.e. the wire key * authored metadata carries. Nothing else in the repo pins it. */ -export interface PageNodeSchema extends BaseSchema { +export interface PageNodeSchema extends BaseSchema, Omit { type: 'page'; /** * ⛔ REFUSED BY NAME — `actions` is not a member of this node and never was diff --git a/packages/types/src/zod/app.zod.ts b/packages/types/src/zod/app.zod.ts index edba7cd23e..3020220e92 100644 --- a/packages/types/src/zod/app.zod.ts +++ b/packages/types/src/zod/app.zod.ts @@ -389,14 +389,20 @@ export const AppContextSelectorSchema = stripImportedDefaults(SpecAppContextSele * `.partial()` guarantees no *future* spec field can become required and * silently invalidate stored objectui apps. */ -const SpecAppFields = specFieldsExcept(stripImportedDefaults(SpecAppSchema).shape, [ +export const APP_SPEC_EXCLUDED = [ 'name', 'label', 'description', 'navigation', 'areas', 'contextSelectors', -] as const); +] as const; + +// One list, two readers (objectui#9736): this call and the `AppComponentSchema` +// TypeScript twin in `../app.ts`, which extends `Omit< App, … >` over the same +// array — so the published validator and the published type project one spec +// surface and cannot drift apart again. +const SpecAppFields = specFieldsExcept(stripImportedDefaults(SpecAppSchema).shape, APP_SPEC_EXCLUDED); /** * App Schema — the objectui app-shell renderer node, derived from diff --git a/packages/types/src/zod/complex.zod.ts b/packages/types/src/zod/complex.zod.ts index 430ab45b47..47248a2fe1 100644 --- a/packages/types/src/zod/complex.zod.ts +++ b/packages/types/src/zod/complex.zod.ts @@ -1161,14 +1161,20 @@ export const GlobalFilterSchema = z.object({ * `.partial()` guarantees no *future* spec field can become required and * silently invalidate stored objectui dashboards. */ -const SpecDashboardFields = specFieldsExcept(stripImportedDefaults(SpecDashboardSchema).shape, [ +export const DASHBOARD_SPEC_EXCLUDED = [ 'name', 'label', 'description', 'widgets', 'globalFilters', 'dateRange', -] as const); +] as const; + +// One list, two readers (objectui#9736): this call and the `DashboardComponentSchema` +// TypeScript twin in `../complex.ts`, which extends `Omit< Dashboard, … >` over the same +// array — so the published validator and the published type project one spec +// surface and cannot drift apart again. +const SpecDashboardFields = specFieldsExcept(stripImportedDefaults(SpecDashboardSchema).shape, DASHBOARD_SPEC_EXCLUDED); /** * Dashboard Schema — the objectui dashboard renderer node, derived from diff --git a/packages/types/src/zod/layout.zod.ts b/packages/types/src/zod/layout.zod.ts index 47b0ec6578..a21b3fa44f 100644 --- a/packages/types/src/zod/layout.zod.ts +++ b/packages/types/src/zod/layout.zod.ts @@ -631,13 +631,19 @@ export const PageTypeSchema = stripImportedDefaults(SpecPageTypeSchema); * `.partial()` guarantees no *future* spec field can become required and * silently invalidate stored objectui pages. */ -const SpecPageFields = specFieldsExcept(stripImportedDefaults(SpecPageSchema).shape, [ +export const PAGE_SPEC_EXCLUDED = [ 'name', 'label', 'description', 'type', 'regions', -] as const); +] as const; + +// One list, two readers (objectui#9736): this call and the `PageNodeSchema` +// TypeScript twin in `../layout.ts`, which extends `Omit< Page, … >` over the same +// array — so the published validator and the published type project one spec +// surface and cannot drift apart again. +const SpecPageFields = specFieldsExcept(stripImportedDefaults(SpecPageSchema).shape, PAGE_SPEC_EXCLUDED); /** * The `actions` REFUSAL on the `page` node (objectui#7926, maintainer ruling From 761c2d4c90475cc445ca4bcd10b336356cf8dd6d Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 24 Sep 2026 14:44:08 +0000 Subject: [PATCH 2/5] chore(changeset): @object-ui/types minor for the by-reference twins Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01877XiBYSaRCk2CU7cMSg3S --- .changeset/9736-twins-spec-by-reference.md | 37 ++++++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 .changeset/9736-twins-spec-by-reference.md diff --git a/.changeset/9736-twins-spec-by-reference.md b/.changeset/9736-twins-spec-by-reference.md new file mode 100644 index 0000000000..64e3d5413b --- /dev/null +++ b/.changeset/9736-twins-spec-by-reference.md @@ -0,0 +1,37 @@ +--- +'@object-ui/types': minor +--- + +feat(types): `AppComponentSchema`, `DashboardComponentSchema` and `PageNodeSchema` take the spec by reference, like their zod mirrors + +The three published zod mirrors have long taken `@objectstack/spec`'s `App` / `Dashboard` / +`Page` surface by reference (`BaseSchema.extend(SpecXFields.shape)`), while their hand-written +TypeScript twins restated only the members the renderers read. So the published validator +admitted keys the published type did not declare: the package-lock envelope (`_lock`, +`_lockReason`, `_lockSource`, `_provenance`, `_packageId`, `_packageVersion`, `_lockDocsUrl`, +written by the packaging pipeline), `protection`, the app's `isDefault` / `_unpublished` / +`defaultAgent` / `contextSelectors`, and the page's `source` / `interfaceConfig` / `requires`. +The spec's retirement tombstones reached the type only as `any`, through `BaseSchema`'s index +signature. + +Each twin now extends `Omit` over the spec's exported input type, and that `Omit` reads the same +`as const` exclusion array the mirror's `specFieldsExcept` call reads. The two faces therefore +project one spec surface, and a later spec bump moves both of them together: + +- **Admitted keys are declared** with the spec's own member types. A reader of a served document + can read `app.protection`, `page.requires` or the envelope without a cast. +- **Tombstones refuse at compile time.** `version`, `homePageId`, `objects`, `apis`, `sharing`, + `embed`, `mobileNavigation` and `aria` on an app, and `aria`, `refreshInterval` and + `performance` on a dashboard, are now optional members typed `undefined`. Authoring a value is + a TypeScript error, which is the verdict the validator already gave at parse. ⚠️ This is the + one breaking edge: code that wrote one of these keys used to compile against the index + signature and was refused at parse. It is now refused by `tsc`, before it runs. +- `DashboardComponentSchema.header` and `PageNodeSchema.slots` keep their own hand-written types. + Both are withheld from the spec projection on the type side only, because neither type is + assignable to the spec's member. The drift is already recorded in the parity ledgers. + +Why this is a minor bump: the published type surface gains members, and one authoring +spelling moves from a parse-time refusal to a compile-time refusal. This repo marks breaking +semantics `minor` rather than `major`. + +This change affects types only. It changes no runtime code and narrows no mirror. From 8e11943303f8a162fadb8053d1f863e62ac862d4 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 24 Sep 2026 15:41:06 +0000 Subject: [PATCH 3/5] fix(types): withhold Page assignedProfiles from the spec projection (forward-compat with spec main) objectstack main retires PageSchema.assignedProfiles (retiredKey, input type undefined), so the hand-written string[] member made PageNodeSchema fail its extends clause under the Spec Main Shape Gate (TS2430). The key is now omitted beside the shared list on the TypeScript face only; the member stays string[]. Its retirement is objectui#9409's decision. Pinned in the 9736 test. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01877XiBYSaRCk2CU7cMSg3S --- .../twins-spec-by-reference-9736.test.ts | 20 ++++++++++++++++--- packages/types/src/layout.ts | 18 +++++++++++++---- 2 files changed, 31 insertions(+), 7 deletions(-) diff --git a/packages/types/src/__tests__/twins-spec-by-reference-9736.test.ts b/packages/types/src/__tests__/twins-spec-by-reference-9736.test.ts index e515c27518..72865568a4 100644 --- a/packages/types/src/__tests__/twins-spec-by-reference-9736.test.ts +++ b/packages/types/src/__tests__/twins-spec-by-reference-9736.test.ts @@ -14,9 +14,11 @@ * extend `Omit< App | Dashboard | Page, … >` over the SAME `as const` exclusion * array their mirror's `specFieldsExcept` call reads (`APP_SPEC_EXCLUDED`, * `DASHBOARD_SPEC_EXCLUDED`, `PAGE_SPEC_EXCLUDED`), plus — on the TypeScript - * face only — the one twin member per pair whose hand-written type is not - * assignable to the spec's (`header` on the dashboard, `slots` on the page; - * both already ledgered as drift in `zod-mirror-parity.test.ts`). + * face only — the twin members whose hand-written type is not assignable to + * the spec's (`header` on the dashboard, `slots` on the page, both already + * ledgered as drift in `zod-mirror-parity.test.ts`), plus the page's + * `assignedProfiles`, which objectstack `main` retires (a forward-compat + * omission, objectui#9409). * * ## Why the positive pins are TYPE equalities, not assignments * @@ -129,4 +131,16 @@ describe('the twin-only omissions keep the twin\'s own member, unwidened', () => const slotsNotSpec: Equal = false; expect([headerLabel, headerNotSpec, slotsNotSpec]).toEqual([true, false, false]); }); + + it('Page `assignedProfiles` keeps the hand-written `string[]` whatever the spec pin declares', () => { + // A FORWARD-COMPAT omission: objectstack `main` retires the key (its input + // type becomes `undefined`), and the hand-written member would then stop + // compiling in the `extends` clause. Spelling it beside the shared list keeps + // the twin compiling against both the installed pin and `main` (the + // `Spec Main Shape Gate`). Retiring it is objectui#9409's decision, ⛔ not + // this file's, so the member must stay exactly what it was. + const assignedProfiles: Equal = true; + const declared: 'assignedProfiles' extends DeclaredKeys ? true : false = true; + expect([assignedProfiles, declared]).toEqual([true, true]); + }); }); diff --git a/packages/types/src/layout.ts b/packages/types/src/layout.ts index 973a6505d7..aeb7d9ec1e 100644 --- a/packages/types/src/layout.ts +++ b/packages/types/src/layout.ts @@ -1298,8 +1298,8 @@ export interface PageNodeRegion { * mirror's `specFieldsExcept` call also reads, so both faces project the same * spec surface and move together on a pin bump. * - * ONE key is omitted from the spec projection beyond the shared list, on the - * TypeScript face only: + * TWO keys are omitted from the spec projection beyond the shared list, on + * the TypeScript face only: * - `slots` — the member below types each slot as objectui's `SchemaNode` * (which admits primitives and `null`), not the spec's page-component * shape, so it is not assignable to the spec's member and cannot sit beside @@ -1308,9 +1308,19 @@ export interface PageNodeRegion { * ⛔ not changed here. The mirror keeps validating the spec's `slots` — the * omission is type-side only, so it is spelled beside the shared list, not * inside it. + * - `assignedProfiles` — a FORWARD-COMPAT omission. On the installed spec the + * spec's member is `string[]` and the one below matches it, but objectstack + * `main` has retired the key (`retiredKey()`, so its input type is + * `undefined`), and the hand-written `string[]` is not assignable to that. + * Without this omission the twin would stop compiling at the next pin bump, + * which `Spec Main Shape Gate` measures today. Retiring the member here is + * objectui#9409's decision, which is on hold until the installed spec + * refuses the key. ⛔ It is not retired, and not widened, here. Like + * `slots`, it is spelled beside the shared list, so the mirror keeps + * validating whatever the installed spec declares. * * The other members this interface writes itself (`icon`, `object`, - * `template`, `variables`, `isDefault`, `assignedProfiles`, `aria`, `kind`) + * `template`, `variables`, `isDefault`, `aria`, `kind`) * are each assignable to the spec's, so they override it without an omission. * Pinned by `__tests__/twins-spec-by-reference-9736.test.ts`. * @@ -1323,7 +1333,7 @@ export interface PageNodeRegion { * `@object-ui/components` registers `PageRenderer` under, i.e. the wire key * authored metadata carries. Nothing else in the repo pins it. */ -export interface PageNodeSchema extends BaseSchema, Omit { +export interface PageNodeSchema extends BaseSchema, Omit { type: 'page'; /** * ⛔ REFUSED BY NAME — `actions` is not a member of this node and never was From 8cdc5bdf762053138e43d620dbabf486e2222258 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 24 Sep 2026 16:00:27 +0000 Subject: [PATCH 4/5] chore(changeset): note the forward-compat assignedProfiles omission Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01877XiBYSaRCk2CU7cMSg3S --- .changeset/9736-twins-spec-by-reference.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.changeset/9736-twins-spec-by-reference.md b/.changeset/9736-twins-spec-by-reference.md index 64e3d5413b..e2e64d2b0a 100644 --- a/.changeset/9736-twins-spec-by-reference.md +++ b/.changeset/9736-twins-spec-by-reference.md @@ -29,6 +29,9 @@ project one spec surface, and a later spec bump moves both of them together: - `DashboardComponentSchema.header` and `PageNodeSchema.slots` keep their own hand-written types. Both are withheld from the spec projection on the type side only, because neither type is assignable to the spec's member. The drift is already recorded in the parity ledgers. + `PageNodeSchema.assignedProfiles` is withheld the same way, but for forward compatibility: + objectstack `main` retires the key, and the hand-written `string[]` member (unchanged) would + otherwise stop compiling at the next spec bump. Its retirement is tracked separately. Why this is a minor bump: the published type surface gains members, and one authoring spelling moves from a parse-time refusal to a compile-time refusal. This repo marks breaking From 86421c5c009cdc357890e060d95585c4c748010a Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 24 Sep 2026 17:00:34 +0000 Subject: [PATCH 5/5] fix(plugin-designer): NAV_TYPE_META has an entry for the spec's `doc` navigation item type objectstack#19789 widened the spec's navigation item union with `doc`, and `NAV_TYPE_META` is a Record keyed by that spec-derived union, so objectui stopped compiling against @objectstack/spec built from objectstack main (the Spec Main Shape Gate, TS2741 on NavigationDesigner). - `doc` entry: `appDesigner.navTypeDoc`, `bg-blue-100 text-blue-700`, `BookOpen`. - The map is typed `Record`: the pinned spec 17.4.0 has no `doc`, so a plain `doc:` key is an excess property there. The `| 'doc'` goes at the pin bump that ships `doc`. - `appDesigner.navTypeDoc` fallback in useDesignerTranslation and all ten locale packs. - Guard test: every discriminant of the installed spec's NavigationItemSchema renders a row with a resolved type badge (spec subset of map, not equality). - `doc` is deliberately NOT in QUICK_ADD_TYPES: an empty doc item fails the spec's book-or-doc requirement; authoring it is objectui#10188. Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01BA3nKVUwKQJf8DBxrSVtNC (cherry picked from commit 6f970da0588a6671432a9cdee71ac759aa67be21) --- .../10287-navigation-designer-doc-type.md | 21 +++++ packages/i18n/src/locales/ar.ts | 1 + packages/i18n/src/locales/de.ts | 1 + packages/i18n/src/locales/en.ts | 1 + packages/i18n/src/locales/es.ts | 1 + packages/i18n/src/locales/fr.ts | 1 + packages/i18n/src/locales/ja.ts | 1 + packages/i18n/src/locales/ko.ts | 1 + packages/i18n/src/locales/pt.ts | 1 + packages/i18n/src/locales/ru.ts | 1 + packages/i18n/src/locales/zh.ts | 1 + .../src/NavigationDesigner.tsx | 13 ++- .../NavigationDesigner.specNavTypes.test.tsx | 93 +++++++++++++++++++ .../src/hooks/useDesignerTranslation.ts | 1 + 14 files changed, 137 insertions(+), 1 deletion(-) create mode 100644 .changeset/10287-navigation-designer-doc-type.md create mode 100644 packages/plugin-designer/src/__tests__/NavigationDesigner.specNavTypes.test.tsx diff --git a/.changeset/10287-navigation-designer-doc-type.md b/.changeset/10287-navigation-designer-doc-type.md new file mode 100644 index 0000000000..457e6b739a --- /dev/null +++ b/.changeset/10287-navigation-designer-doc-type.md @@ -0,0 +1,21 @@ +--- +'@object-ui/plugin-designer': patch +'@object-ui/i18n': patch +--- + +fix(plugin-designer): the Navigation Designer has an entry for the spec's `doc` navigation item type + +objectstack#19789 added a `doc` member to the spec's navigation item union (an +item that targets a book and/or a doc). `NAV_TYPE_META` in `NavigationDesigner` +is keyed by that spec-derived union, so objectui stopped compiling against +`@objectstack/spec` built from objectstack `main`, and every row reads its badge, +colour and icon from that map. The map now has a `doc` entry (a `BookOpen` icon, +its own colour and the `appDesigner.navTypeDoc` label key). The key has an +English fallback in the designer's defaults and a translation in all ten locale +packs. + +The map is typed `Record` so it compiles both +against the pinned `@objectstack/spec`, which predates `doc`, and against +objectstack `main`. The `| 'doc'` goes away at the pin bump that ships `doc`. +`doc` is not added to the quick-add buttons: an empty `doc` item fails the spec's +book-or-doc requirement, and authoring one is objectui#10188. diff --git a/packages/i18n/src/locales/ar.ts b/packages/i18n/src/locales/ar.ts index c419304a7e..62350bdddf 100644 --- a/packages/i18n/src/locales/ar.ts +++ b/packages/i18n/src/locales/ar.ts @@ -1405,6 +1405,7 @@ const ar = { navTypeSeparator: "فاصل", navTypeAction: "إجراء", navTypeComponent: "مكوّن", + navTypeDoc: "مستند", navEditIcon: "تعديل الأيقونة", navToggleVisible: "تبديل الرؤية", navHidden: "مخفي", diff --git a/packages/i18n/src/locales/de.ts b/packages/i18n/src/locales/de.ts index e5baca0494..f2ae6e0d7f 100644 --- a/packages/i18n/src/locales/de.ts +++ b/packages/i18n/src/locales/de.ts @@ -1398,6 +1398,7 @@ const de = { navTypeSeparator: "Trenner", navTypeAction: "Aktion", navTypeComponent: "Komponente", + navTypeDoc: "Dokument", navEditIcon: "Symbol bearbeiten", navToggleVisible: "Sichtbarkeit umschalten", navHidden: "Ausgeblendet", diff --git a/packages/i18n/src/locales/en.ts b/packages/i18n/src/locales/en.ts index b0772d1c52..df400a3bad 100644 --- a/packages/i18n/src/locales/en.ts +++ b/packages/i18n/src/locales/en.ts @@ -1647,6 +1647,7 @@ const en = { navTypeSeparator: 'Separator', navTypeAction: 'Action', navTypeComponent: 'Component', + navTypeDoc: 'Doc', navEditIcon: 'Edit icon', navToggleVisible: 'Toggle visibility', navHidden: 'Hidden', diff --git a/packages/i18n/src/locales/es.ts b/packages/i18n/src/locales/es.ts index abe8f946b7..408c88ecb6 100644 --- a/packages/i18n/src/locales/es.ts +++ b/packages/i18n/src/locales/es.ts @@ -1402,6 +1402,7 @@ const es = { navTypeSeparator: "Separador", navTypeAction: "Acción", navTypeComponent: "Componente", + navTypeDoc: "Documento", navEditIcon: "Editar icono", navToggleVisible: "Alternar visibilidad", navHidden: "Oculto", diff --git a/packages/i18n/src/locales/fr.ts b/packages/i18n/src/locales/fr.ts index 21949ab87c..e71ca35695 100644 --- a/packages/i18n/src/locales/fr.ts +++ b/packages/i18n/src/locales/fr.ts @@ -1400,6 +1400,7 @@ const fr = { navTypeSeparator: "Séparateur", navTypeAction: "Action", navTypeComponent: "Composant", + navTypeDoc: "Document", navEditIcon: "Modifier l'icône", navToggleVisible: "Basculer la visibilité", navHidden: "Masqué", diff --git a/packages/i18n/src/locales/ja.ts b/packages/i18n/src/locales/ja.ts index 6706238a5b..3dcbb9d28c 100644 --- a/packages/i18n/src/locales/ja.ts +++ b/packages/i18n/src/locales/ja.ts @@ -1398,6 +1398,7 @@ const ja = { navTypeSeparator: "区切り", navTypeAction: "アクション", navTypeComponent: "コンポーネント", + navTypeDoc: "ドキュメント", navEditIcon: "アイコンを編集", navToggleVisible: "表示を切り替え", navHidden: "非表示", diff --git a/packages/i18n/src/locales/ko.ts b/packages/i18n/src/locales/ko.ts index 645e690ca5..019f9f10ea 100644 --- a/packages/i18n/src/locales/ko.ts +++ b/packages/i18n/src/locales/ko.ts @@ -1398,6 +1398,7 @@ const ko = { navTypeSeparator: "구분선", navTypeAction: "작업", navTypeComponent: "컴포넌트", + navTypeDoc: "문서", navEditIcon: "아이콘 편집", navToggleVisible: "가시성 토글", navHidden: "숨김", diff --git a/packages/i18n/src/locales/pt.ts b/packages/i18n/src/locales/pt.ts index 92d0bee665..b6b50b8b5f 100644 --- a/packages/i18n/src/locales/pt.ts +++ b/packages/i18n/src/locales/pt.ts @@ -1397,6 +1397,7 @@ const pt = { navTypeSeparator: "Separador", navTypeAction: "Ação", navTypeComponent: "Componente", + navTypeDoc: "Documento", navEditIcon: "Editar ícone", navToggleVisible: "Alternar visibilidade", navHidden: "Oculto", diff --git a/packages/i18n/src/locales/ru.ts b/packages/i18n/src/locales/ru.ts index 682ec698dd..f30ac8280b 100644 --- a/packages/i18n/src/locales/ru.ts +++ b/packages/i18n/src/locales/ru.ts @@ -1408,6 +1408,7 @@ const ru = { navTypeSeparator: "Разделитель", navTypeAction: "Действие", navTypeComponent: "Компонент", + navTypeDoc: "Документ", navEditIcon: "Редактировать значок", navToggleVisible: "Переключить видимость", navHidden: "Скрыто", diff --git a/packages/i18n/src/locales/zh.ts b/packages/i18n/src/locales/zh.ts index 6285b0ea38..703ed32126 100644 --- a/packages/i18n/src/locales/zh.ts +++ b/packages/i18n/src/locales/zh.ts @@ -1463,6 +1463,7 @@ const zh = { navTypeSeparator: '分隔线', navTypeAction: '操作', navTypeComponent: '组件', + navTypeDoc: '文档', navEditIcon: '编辑图标', navToggleVisible: '切换可见性', navHidden: '已隐藏', diff --git a/packages/plugin-designer/src/NavigationDesigner.tsx b/packages/plugin-designer/src/NavigationDesigner.tsx index 677ae3f20f..dc6cc9b5f0 100644 --- a/packages/plugin-designer/src/NavigationDesigner.tsx +++ b/packages/plugin-designer/src/NavigationDesigner.tsx @@ -17,6 +17,7 @@ import React, { useState, useCallback, useRef } from 'react'; import type { NavigationItem, NavigationItemType } from '@object-ui/types'; import { + BookOpen, ChevronDown, ChevronRight, ChevronUp, @@ -79,7 +80,16 @@ function createId(prefix: string): string { return `${prefix}_${Date.now()}_${ndCounter}`; } -const NAV_TYPE_META: Record }> = { +// Keyed by the spec-derived union, so a nav type the spec adds stops this file +// compiling until it has an entry -- keep it a `Record`, never `Partial` or +// `Record`. +// +// `| 'doc'` is the published pin lagging the spec: objectstack#19789 added the +// `doc` nav item, and the pinned `@objectstack/spec` predates it, so without +// the extra key a `doc:` entry is an excess property against the pin while its +// absence fails the compile against objectstack `main` (Spec Main Shape Gate). +// Drop `| 'doc'` at the pin bump that ships `doc`; the entry itself stays. +const NAV_TYPE_META: Record }> = { object: { labelKey: 'appDesigner.navTypeObject', color: 'bg-green-100 text-green-700', Icon: Database }, dashboard: { labelKey: 'appDesigner.navTypeDashboard', color: 'bg-amber-100 text-amber-700', Icon: LayoutDashboard }, page: { labelKey: 'appDesigner.navTypePage', color: 'bg-teal-100 text-teal-700', Icon: FileText }, @@ -89,6 +99,7 @@ const NAV_TYPE_META: Record = [ diff --git a/packages/plugin-designer/src/__tests__/NavigationDesigner.specNavTypes.test.tsx b/packages/plugin-designer/src/__tests__/NavigationDesigner.specNavTypes.test.tsx new file mode 100644 index 0000000000..6b747efac7 --- /dev/null +++ b/packages/plugin-designer/src/__tests__/NavigationDesigner.specNavTypes.test.tsx @@ -0,0 +1,93 @@ +/** + * 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. + */ + +/** + * objectui#10287 — every navigation item type the spec declares gets a row + * the Navigation Designer can draw. + * + * `NAV_TYPE_META` is a `Record` keyed by the spec-derived `NavigationItemType`, + * so the compiler already refuses a missing entry. This pin is the runtime + * half, and it reads the vocabulary from the INSTALLED `@objectstack/spec`'s + * `NavigationItemSchema` discriminants rather than from a list written here, + * so it follows whatever spec it is run against: + * + * - against the pinned release it covers that release's members; + * - against a spec built from objectstack `main` (the Spec Main Shape Gate's + * injected install) it also covers members the pin does not have yet — + * objectstack#19789's `doc` was the one that went unhandled. + * + * The map may carry MORE keys than the spec it is run against (`doc` ahead of + * the pin bump), so this asserts spec ⊆ map, never equality. + * + * A missing entry fails as a render error: every row reads `meta.Icon` from + * the map. An entry whose label key has no English fallback fails the + * raw-key assertion. + */ + +import { describe, it, expect } from 'vitest'; +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import { NavigationItemSchema } from '@objectstack/spec/ui'; +import type { NavigationItem } from '@object-ui/types'; +import { NavigationDesigner } from '../NavigationDesigner'; + +/** + * Walk `.unwrap()` (the spec wraps its schemas lazily) until the node carries + * `key`. Bounded, and answers `undefined` rather than looping on a shape it + * does not recognise. + */ +function unwrapUntil(node: unknown, key: string): Record | undefined { + let current = node as Record | undefined; + for (let depth = 0; depth < 8 && current; depth += 1) { + if (key in current) return current; + const unwrap = current.unwrap; + if (typeof unwrap !== 'function') return undefined; + current = unwrap.call(current) as Record | undefined; + } + return current && key in current ? current : undefined; +} + +/** The discriminant values of the spec's nav-item union. Throws when unreadable. */ +function specNavItemTypes(): string[] { + const union = unwrapUntil(NavigationItemSchema, 'options'); + const options = union?.options; + if (!Array.isArray(options) || options.length === 0) { + throw new Error('could not read NavigationItemSchema options from @objectstack/spec'); + } + return options.flatMap((option, index) => { + const shape = unwrapUntil(option, 'shape')?.shape as Record | undefined; + const literal = shape?.type as { values?: unknown } | undefined; + if (!(literal?.values instanceof Set) || literal.values.size === 0) { + throw new Error(`could not read the \`type\` literal of NavigationItemSchema option ${index}`); + } + return [...literal.values].map(String); + }); +} + +const SPEC_NAV_ITEM_TYPES = specNavItemTypes(); + +describe('NavigationDesigner — a row for every spec navigation item type (#10287)', () => { + it('reads a non-empty vocabulary from the installed spec', () => { + // Non-vacuity: an empty or mis-read list would make every case below pass. + expect(SPEC_NAV_ITEM_TYPES).toContain('object'); + expect(SPEC_NAV_ITEM_TYPES).toContain('group'); + expect(new Set(SPEC_NAV_ITEM_TYPES).size).toBe(SPEC_NAV_ITEM_TYPES.length); + }); + + it.each(SPEC_NAV_ITEM_TYPES)('draws the `%s` type in the tree and the live preview', (type) => { + // Cast at the fixture boundary only: the type comes from the installed + // spec at runtime, which can name a member the pinned types do not have. + const item = { id: `probe_${type}`, type, label: 'Probe item' } as unknown as NavigationItem; + + render( {}} showPreview />); + + const row = screen.getByTestId(`nav-designer-item-probe_${type}`); + // The type badge resolves to a label, not to its raw translation key. + expect(row.textContent ?? '').not.toMatch(/appDesigner\./); + }); +}); diff --git a/packages/plugin-designer/src/hooks/useDesignerTranslation.ts b/packages/plugin-designer/src/hooks/useDesignerTranslation.ts index f540bc504e..3cecda4251 100644 --- a/packages/plugin-designer/src/hooks/useDesignerTranslation.ts +++ b/packages/plugin-designer/src/hooks/useDesignerTranslation.ts @@ -104,6 +104,7 @@ export const DESIGNER_DEFAULT_TRANSLATIONS: Record = { 'appDesigner.navTypeSeparator': 'Separator', 'appDesigner.navTypeAction': 'Action', 'appDesigner.navTypeComponent': 'Component', + 'appDesigner.navTypeDoc': 'Doc', 'appDesigner.navEditIcon': 'Edit icon', 'appDesigner.navToggleVisible': 'Toggle visibility', 'appDesigner.navHidden': 'Hidden',