Skip to content

Commit 80a9f99

Browse files
feat(spec): an app can name a landing per audience with landing (#28449)
domain:spec#2 · os-tesla · session_01L9qibx5r5nhDMwpRs4SL6D <!-- claude-projects-attribution: {"github_login":"os-tesla"} --> _Requested by **Tesla** · [project thread](https://claude.ai/code/project/chan_01AXsQkLFKRdQCqQ3RTr6Jao?thread=cmsg_01AXsQkLFKRdQCqQ3RTr6Jao85bLkygRuu1iHVbxaGNzrb)_ Fixes #28022 Clause-②: yes (widening) Metadata-protocol: added domain:spec#2 · os-tesla · session_01L9qibwXXtueDQZXqxv2FsL Ruling: record 6545223246 on the card, letter A (maintainer 「同意」 2026-10-31T15:10Z). This is the spec half only. Follow-up: #28450, the domain:ui landing card (console landing redirect, plus attaching `checkAppLandingItems` to objectui's `AppComponentSchema`, which drops the one `notAttachable` entry this PR adds). ## 维护者速读 - 改了什么:应用新增 `landing` 键,按受众声明落地页:`[{ item, visible }]`,第一个 `visible` 成立的条目决定读者打开哪个导航项;无匹配或不写时照旧打开第一个导航项。 - 为什么改:同一个应用给销售和客服共用时,只能有一个落地页;把"销售"挪到"客服"前面,客服的人一进来就落在线索上。 - 风险与回滚:只加不减,旧应用不受影响;`item` 指向不存在的导航项、或指向分组/分隔线/动作项,保存时直接拒收(不会像退役的 `homePageId` 那样悄悄落到第一项)。回滚即还原本 PR。 - 先看:`packages/spec/src/ui/app.zod.ts`(`checkAppLandingItems` 与 `AppLandingEntrySchema`)、`packages/spec/liveness/app.json`(`landing` 标为 planned 并提示作者)、`packages/lint/src/validate-expressions.ts`(落地谓词的表达式检查)。 - 两分钟验证:`pnpm --filter @objectstack/spec exec vitest run src/ui/app-landing.test.ts`,应见 14 个用例全绿,其中悬空 `item` 在 `landing.0.item` 处被拒、并列出存在的导航项 id。 - 需要你做的:无,席位落地。 ## What changes - **`AppSchema.landing`** (`packages/spec/src/ui/app.zod.ts`): an optional, non-empty list of `AppLandingEntrySchema` entries, `{ item, visible }`, both required. The console asks the entries in order and opens the first whose `visible` holds for the reader (and whose `item` the reader can see). No match, or no key: the app opens on its first navigation item by `order`, as today. The describe text states both rules. - **The refusal the `homePageId` tombstone asked for.** `checkAppLandingItems`, a `superRefine` on `AppSchema`, runs on every app parse (so `defineApp`, `os validate` / `os build` and the metadata save door all refuse alike). It refuses, at `landing.N.item`, an `item` that names no entry of the app's own `navigation` or `areas[].navigation` (any depth through `children`), listing the openable ids that exist; and an `item` that names a `group`, `separator` or `action` entry, none of which opens a page. It also refuses, at `landing.N.visible`, a predicate that reads `data` (a landing entry has no `dataSource`), with the same lexer the navigation entry's own `data` refusal uses. It is exported (and barrel-reachable) for the same reason as `navItemVisibleDataNeedsDataSource`: a mirror attaches the function instead of restating the rule. - **Why an id into `navigation`, not an inline target** (the order's either/or): the landing is a sidebar entry, so the sidebar can highlight what the reader landed on, and there is one copy of each navigation target. The id's old failure mode, dangling into a silent fallback, is closed by the parse-time refusal above, so the cross-reference is checked rather than trusted. - **Why `visible` is required**: an entry with no predicate would land every reader there, which is `homePageId` again under a new name. "Open somewhere else for everyone" stays "put that entry first in `navigation`". - **Spellings**: `landings` / `landingPages` route to `landing`; on an entry, `navigationId` / `navItem` / `itemId` / `target` / `page` route to `item`, and `visibleWhen` / `visibleOn` / `when` / `condition` to `visible`. The retired `homePageId` prescription (and its `home` / `homepage` / `landingpage` spellings) now also names `landing`, and the tombstone docblock's last paragraph says how `landing` answers both of its objections. - **Lint** (`packages/lint/src/validate-expressions.ts`): `os validate` / `os build` judge each landing predicate with the shared validator's data-independent verdicts (CEL syntax, size, unknown function, `has()` shape). It passes no object and no `record` scope, because the slot binds no record and a `record`-scoped call would prescribe `record.FIELD`. - **Ledgers and tables the gates force**: liveness `app.landing` row `planned` + `authorWarn` + `authorHint` (children `item`, `visible`); evaluated-slot census 32 → 33; dogfood expression-conformance row `cel-app-landing` (`experimental`, `unevaluated`); form-reconciliation `omit` row (planned, no form control); `/meta/types` `app` count 22 → 23; `dropped-refinements.baseline.json` (+1 site in six entries, the new object-level check, hand-edited per the ledger's rule); objectui census `spec-object-refinements-7715.test.ts` names `checkAppLandingItems` under `notAttachable` (test-only line, allowed by the claim amendment 6545506798), owed to the follow-up ui card. - **Docs**: `content/docs/ui/apps.mdx` gains "Landing per audience" (shape, order rule, refusals, a Planned callout) and a property-table row; references regenerated. - **Changeset**: `.changeset/28022-app-landing-per-audience.md`, `@objectstack/spec` and `@objectstack/lint` minor. ## Why An app shared by more than one audience has one landing: its first navigation entry. In HotCRM, moving Sales above Service moves service staff's landing to Leads (#27386 row 5). The maintainer ruled letter A: an app-level landing list with `visible` predicates, first match wins. ## Tests - `packages/spec/src/ui/app-landing.test.ts` (new, 14 cases): accepted shapes (top-level, nested, area-only items; bare string normalised to the CEL envelope), the control (no key parses as before), refusals at the entry's own path (dangling item listing the existing ids, `group` / `action` / `separator` targets, a `data` read, a missing `visible`, an empty list, near-miss spellings), barrel identity, and both `visible` spellings through the exported function. - `packages/lint/src/validate-expressions.test.ts`: a non-CEL landing predicate is an error at `app 'crm' · landing[0] ('nav_leads') visible`; a healthy reader predicate is clean; the #5017 read-surface table gains `appDef` (AppSchema) and `landingEntry` (AppLandingEntrySchema), and `stack` gains `apps`. - Ablations (one-off, then restored; restore proven by an empty `git diff HEAD`): removing the `.superRefine(checkAppLandingItems)` mount → `app-landing.test.ts` 5 failed / 9 passed; removing the lint `check(...)` call → `validate-expressions.test.ts -t landing` 2 failed (the landing case and the read-surface row). ## Verification <details><summary>Local runs, head 1308512</summary> Derived list (`node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack`), run at 4437a3b, each line with stdin from /dev/null. Reconciled: `dispatch-gates --ran`: 108 derived, 108 run, 0 NOT-MEASURED. Five lines first exited 3 or 1 on a missing prerequisite (an unfetched pinned commit; packages with no built declarations) and were rerun after `git fetch` of the pinned sha and a full `pnpm build` with declarations; the record below carries the rerun codes. ``` node scripts/check-adr-0087-registration.mjs --base origin/main :: exit 0 node scripts/check-adr-0087-registration.mjs --self-test :: exit 0 node scripts/check-adr-links.mjs :: exit 0 node scripts/check-adr-links.mjs --self-test :: exit 0 node scripts/check-changeset-no-major.mjs --base origin/main :: exit 0 node scripts/check-closing-keyword-parity.mjs :: exit 0 node scripts/check-closing-keyword-parity.mjs --self-test :: exit 0 node scripts/check-comment-mask-adoption.mjs :: exit 0 node scripts/check-comment-mask-adoption.mjs --self-test :: exit 0 node scripts/check-comment-mask-corpus.mjs :: exit 0 node scripts/check-dev-prereqs.mjs --self-test :: exit 0 node scripts/check-doc-route-spelling.mjs --advisory :: exit 0 node scripts/check-doc-route-spelling.mjs --self-test :: exit 0 node scripts/check-docs-section-name.mjs :: exit 0 node scripts/check-docs-section-name.mjs --self-test :: exit 0 node scripts/check-dts-emitted.mjs --self-test :: exit 0 node scripts/check-empty-changeset.mjs --base origin/main :: exit 0 node scripts/check-empty-changeset.mjs --self-test :: exit 0 node scripts/check-issue-citations.mjs :: exit 0 node scripts/check-plugin-teardown-shape.mjs :: exit 0 node scripts/check-plugin-teardown-shape.mjs --self-test :: exit 0 node scripts/check-sdui-manifest.mjs :: exit 0 node scripts/check-sdui-manifest.mjs --self-test :: exit 0 node scripts/check-section-landing-index.mjs :: exit 0 node scripts/check-section-landing-index.mjs --self-test :: exit 0 node scripts/ci/assert-turbo-remote-cache.mjs --self-test :: exit 0 node scripts/ci/plan-test-core-shards.mjs --self-test :: exit 0 node scripts/ci/prune-turbo-cache.mjs --self-test :: exit 0 node scripts/docs-audit/check-affected-docs.mjs :: exit 0 node scripts/docs-audit/check-drift-comment.mjs :: exit 0 node scripts/pm/release-rehearsal-clone.mjs --self-test :: exit 0 node scripts/release-pending-publish.mjs --self-test :: exit 0 pnpm --dir ui exec vitest run scripts/ :: exit 0 pnpm --filter @objectstack/lint run check:doc-formula-expressions :: exit 0 pnpm --filter @objectstack/lint run check:doc-security-posture :: exit 0 pnpm --filter @objectstack/spec run check:api-surface :: exit 0 pnpm --filter @objectstack/spec run check:authorable-surface :: exit 0 pnpm --filter @objectstack/spec run check:browser-reachable-entries :: exit 0 pnpm --filter @objectstack/spec run check:docs :: exit 0 pnpm --filter @objectstack/spec run check:dual-source-exports :: exit 0 pnpm --filter @objectstack/spec run check:duration-unit-keys :: exit 0 pnpm --filter @objectstack/spec run check:empty-state :: exit 0 pnpm --filter @objectstack/spec run check:entry-nameability :: exit 0 pnpm --filter @objectstack/spec run check:export-origins :: exit 0 pnpm --filter @objectstack/spec run check:exported-any :: exit 0 pnpm --filter @objectstack/spec run check:generated :: exit 0 pnpm --filter @objectstack/spec run check:liveness :: exit 0 pnpm --filter @objectstack/spec run check:llms-txt :: exit 0 pnpm --filter @objectstack/spec run check:skill-examples :: exit 0 pnpm --filter @objectstack/spec run check:skill-refs :: exit 0 pnpm --filter @objectstack/spec run check:strictness-ledger :: exit 0 pnpm --filter @objectstack/spec run check:variant-docs :: exit 0 pnpm --filter @objectstack/spec run check:yaml-examples :: exit 0 pnpm check:changeset-gate-self-tests :: exit 0 pnpm check:ci-filter-parity :: exit 0 pnpm check:closed-ceremony-text :: exit 0 pnpm check:corpus-claim-drift :: exit 0 pnpm check:cross-package-test-inputs :: exit 0 pnpm check:dispatcher-error-vocabulary :: exit 0 pnpm check:doc-anchors :: exit 0 pnpm check:doc-authoring :: exit 0 pnpm check:doc-frontmatter :: exit 0 pnpm check:docs-audit-scope :: exit 0 pnpm check:docs-redirects :: exit 0 pnpm check:docs-single-h1 :: exit 0 pnpm check:docs-spec-enumerations :: exit 0 pnpm check:docs-transcript-drift :: exit 0 pnpm check:driver-memory-census :: exit 0 pnpm check:dts-closure :: exit 0 pnpm check:dual-build-cjs-loads :: exit 0 pnpm check:durability-log-level :: exit 0 pnpm check:engine-double-contract :: exit 0 pnpm check:gitlink-declared :: exit 0 pnpm check:issue-citations :: exit 0 pnpm check:keyed-text-bounds :: exit 0 pnpm check:lean-entry-closure :: exit 0 pnpm check:logger-receiver-detach :: exit 0 pnpm check:merge-driver :: exit 0 pnpm check:nul-bytes :: exit 0 pnpm check:objectql-double-limit :: exit 0 pnpm check:org-identifier :: exit 0 pnpm check:page-declaration-shape :: exit 0 pnpm check:platform-checklist :: exit 0 pnpm check:platform-object-tenancy-census :: exit 0 pnpm check:pm-changeset-deadline-census :: exit 0 pnpm check:pm-prior-rulings :: exit 0 pnpm check:published-files :: exit 0 pnpm check:published-readme-links :: exit 0 pnpm check:query-options-erasure :: exit 0 pnpm check:react-page-adapter-contract :: exit 0 pnpm check:refd-timer-probe :: exit 0 pnpm check:registry-log-declared :: exit 0 pnpm check:rest-log-spy-declared :: exit 0 pnpm check:role-word :: exit 0 pnpm check:skill-identifier-liveness :: exit 0 pnpm check:slot-lookup :: exit 0 pnpm check:sourcemap-no-sources-content :: exit 0 pnpm check:spec-docblock-symbol-anchors :: exit 0 pnpm check:spec-parsed-alias :: exit 0 pnpm check:system-context-census :: exit 0 pnpm check:test-source-alias :: exit 0 pnpm check:tier-file-adoption :: exit 0 pnpm check:type-check-coverage :: exit 0 pnpm check:type-check-debt :: exit 0 pnpm check:undeclared-dep-imports :: exit 0 pnpm check:vendor-version-stamps :: exit 0 pnpm check:watch-hint-literal :: exit 0 pnpm check:where-matcher :: exit 0 ``` Then `main` was merged (8 commits touching the same generated ui artifacts), the artifacts were regenerated (`check:generated --fix`, then `check:generated` exit 0), and these were rerun at 1308512: ``` pnpm --filter @objectstack/spec exec vitest run src/ui src/shared src/system/metadata-form-zod-reconciliation.test.ts :: exit 0 pnpm --filter @objectstack/spec exec tsc --noEmit -p tsconfig.json :: exit 0 pnpm --filter @objectstack/lint exec vitest run src/validate-expressions.test.ts :: exit 0 pnpm --filter @objectstack/metadata-protocol exec vitest run src/protocol.meta-types-degenerate-derivation.test.ts :: exit 0 pnpm --dir ui exec vitest run packages/types/src/__tests__/spec-object-refinements-7715.test.ts :: exit 0 pnpm --filter @objectstack/dogfood exec vitest run test/expression-conformance :: exit 0 pnpm --filter @objectstack/spec run check:liveness :: exit 0 ``` </details> ## Acceptance notes - **How this meets the tombstone's "enforce-first" sentence.** The maintainer ruled spec first. The liveness row is `planned` with `authorWarn`, so an author who writes `landing` is told no console applies it yet; the describe text opens with `[PLANNED — not yet rendered]`; the docs page carries a Planned callout. The tombstone's other objection, the silent dangling fallback, is closed now, at parse. - **Not judged at the app save door: the predicate's CEL syntax.** `validateStackExpressions` does not list `app` in its `runtimeTypes`, so the lint half runs at `os validate` / `os build` only. The parse refusals (dangling item, non-opening target, `data` read, blank / `ast`-only predicate) do run at the save door. Widening the rule's runtime types is a publish-gate change, left out of this card. - **Not judged: which roots the predicate reads.** A landing predicate reading `record.x` passes lint (no bare-identifier or root verdict on this slot). The console redirect card decides the evaluation scope; a root verdict belongs with it. - **`navigationContributions` items cannot be named.** The refusal reads the app as parsed, and a contributed item is not part of it. Stated in the describe text and the docs. - **Follow-up #28450:** the domain:ui landing card: the console landing redirect reads `landing`, flips the liveness row to `live`, attaches `checkAppLandingItems` to objectui's `AppComponentSchema` and drops the `notAttachable` entry, and re-classifies the `cel-app-landing` conformance row. ## Human Minimum - [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic. - [x] 2. Every name I added uses the casing for its kind. - [x] 3. No formatter ran; `pnpm lint` passes (repo-wide lint is CI's run). - [x] 4. One issue, one branch off an up-to-date `main`, one pull request. - [x] 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset. - [x] 6. `packages/spec` touched: its generated artifacts are regenerated and committed. - [x] 7. No hand edits to `content/docs/references/`, `packages/*/CHANGELOG.md` or `content/docs/releases/`. - [x] 8. Title, body and comments are in English. --- _Generated by [Claude Code](https://claude.ai/code/session_01L9qibwXXtueDQZXqxv2FsL)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 4bc963c commit 80a9f99

23 files changed

Lines changed: 595 additions & 31 deletions

File tree

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
---
2+
'@objectstack/spec': minor
3+
'@objectstack/lint': minor
4+
---
5+
6+
feat(spec): an app can name a landing per audience with `landing`
7+
8+
Clause-②: yes (widening)
9+
Metadata-protocol: added
10+
11+
An app opens on its first navigation item, one landing for every audience. `AppSchema` now declares `landing`, a list of `{ item, visible }` entries (`AppLandingEntrySchema`): the first entry whose `visible` (CEL, the dialect of a navigation entry's `visible`) holds for the reader names the navigation entry the app opens on. With no match, or no key, the app opens where it did before: its first navigation item by `order`.
12+
13+
- **It cannot dangle.** Every app parse runs `checkAppLandingItems`: an `item` that names no entry of the app's own `navigation` or `areas[].navigation` (any depth), or names a `group`, `separator` or `action` entry, is refused at `landing.N.item`, and the message lists the entries that do exist. Items another package contributes through `navigationContributions` cannot be named.
14+
- **It is per audience.** `visible` is required, and an empty list is refused. To change where everyone lands, put that entry first in `navigation`, as before. A `visible` that reads `data` is refused: a landing entry has no `dataSource`.
15+
- **Lint.** `os validate` and `os build` check each landing predicate with the shared expression validator (CEL syntax, size, unknown functions). The metadata save door runs the parse refusals above on every app write.
16+
- **Planned.** No console reads `landing` yet. The liveness ledger carries it as `planned` with an author warning, so `os validate` tells an author that every reader still opens on the first navigation item until the console landing redirect ships.
17+
18+
Nothing to do for existing apps. The retired `homePageId` (and its `home` / `homepage` / `landingpage` spellings) still answers with its removal notice, which now also names `landing`.

‎content/docs/references/api/metadata.mdx‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,7 @@ const result = AppDefinitionResponseSchema.parse(data);
9595
| **navigation** | `({ id: string; label?: string \| Record<string, string>; icon?: string; order?: number; … } \| { type: 'separator'; id?: string; order?: number } \| … +8 more)[]` | optional | Full navigation tree for the app sidebar |
9696
| **areas** | `{ id: string; label: string \| Record<string, string>; icon?: string; description?: string \| Record<string, string>; … }[]` | optional | Navigation areas for partitioning navigation by business domain |
9797
| **contextSelectors** | `{ id: string; label: string \| Record<string, string>; icon?: string; optionsSource: object; … }[]` | optional | App-level scope dropdowns whose value is injected into nav items as `{<id>}` template vars |
98+
| **landing** | `{ item: string; visible: string \| object }[]` | optional | [PLANNED — not yet rendered] Per-audience landing: the first entry whose `visible` (CEL) holds for the reader names the navigation entry (`item`, an `id` of this app's own `navigation` or `areas[].navigation`) the app opens on. An `item` that names no entry of this app, or a `group` / `separator` / `action` entry, is refused at parse. No match, or no key: the app opens on its first navigation item by `order`, as before. No console reads this list yet, so until the landing redirect ships every reader still opens on the first navigation item. |
9899
| **requiredPermissions** | `string[]` | optional | Permissions required to access this app |
99100
| **defaultAgent** | `string` | optional | Platform agent bound to this app's ambient chat ('ask' is the implicit default; 'build' for authoring surfaces) — ADR-0063 §1 |
100101
| **protection** | `{ lock: Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>; reason: string; docsUrl?: string }` | optional | Package author protection block — lock policy for this app. |

‎content/docs/references/index.mdx‎

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
---
22
title: Protocol reference — every schema by module
33
navTitle: Protocol Reference
4-
description: Every schema published by @objectstack/spec — 1574 schemas across 14 protocol modules
4+
description: Every schema published by @objectstack/spec — 1575 schemas across 14 protocol modules
55
---
66

77
{/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */}
@@ -33,8 +33,8 @@ counts are sums of the rows they head. Regenerate with
3333
| [Shared Protocol](/docs/references/shared) | 10 | 31 | Primitives used across every protocol — identifiers, HTTP, expressions, error maps, enums. |
3434
| [Studio Protocol](/docs/references/studio) | 3 | 35 | Studio designer metadata — the authoring surfaces for the protocols above. |
3535
| [System Protocol](/docs/references/system) | 37 | 287 | The runtime environment — logging, jobs, cache, metrics, notifications, i18n and compliance. |
36-
| [UI Protocol](/docs/references/ui) | 16 | 174 | Apps, pages, views, dashboards, reports, actions and themes — the ObjectUI layer. |
37-
| **Total** | **204** | **1574** | 14 protocol modules |
36+
| [UI Protocol](/docs/references/ui) | 16 | 175 | Apps, pages, views, dashboards, reports, actions and themes — the ObjectUI layer. |
37+
| **Total** | **204** | **1575** | 14 protocol modules |
3838

3939
---
4040

@@ -371,15 +371,15 @@ The runtime environment — logging, jobs, cache, metrics, notifications, i18n a
371371

372372
## UI Protocol
373373

374-
**Source:** `packages/spec/src/ui/` · **Import:** `@objectstack/spec/ui` · **16 pages, 174 schemas**
374+
**Source:** `packages/spec/src/ui/` · **Import:** `@objectstack/spec/ui` · **16 pages, 175 schemas**
375375

376376
Apps, pages, views, dashboards, reports, actions and themes — the ObjectUI layer.
377377

378378
| File | Schemas |
379379
| :--- | :--- |
380380
| [`action.zod.ts`](/docs/references/ui/action) | `Action`, `ActionAi`, `ActionLocation`, `ActionParam`, `ActionType`, `InlineAction` |
381381
| [`action-params.zod.ts`](/docs/references/ui/action-params) | `ActionSession` |
382-
| [`app.zod.ts`](/docs/references/ui/app) | `ActionNavItem`, `App`, `AppBranding`, `AppContextSelector`, `ComponentNavItem`, `DashboardNavItem`, `DocNavItem`, `GroupNavItem`, `NavigationArea`, `NavigationContribution`, `NavigationItem`, `ObjectNavItem`, `PageNavItem`, `ReportNavItem`, `UrlNavItem` |
382+
| [`app.zod.ts`](/docs/references/ui/app) | `ActionNavItem`, `App`, `AppBranding`, `AppContextSelector`, `AppLandingEntry`, `ComponentNavItem`, `DashboardNavItem`, `DocNavItem`, `GroupNavItem`, `NavigationArea`, `NavigationContribution`, `NavigationItem`, `ObjectNavItem`, `PageNavItem`, `ReportNavItem`, `UrlNavItem` |
383383
| [`bulk-action.zod.ts`](/docs/references/ui/bulk-action) | `BulkActionDef`, `BulkActionExecution`, `BulkActionOperation`, `BulkActionParam` |
384384
| [`chart.zod.ts`](/docs/references/ui/chart) | `ChartAggregate`, `ChartAggregateFunction`, `ChartAnnotation`, `ChartAxis`, `ChartConfig`, `ChartDrillDown`, `ChartGroupBy`, `ChartInteraction`, `ChartSeries`, `ChartType` |
385385
| [`component.zod.ts`](/docs/references/ui/component) | `ActionButtonProps`, `ActionGroupProps`, `ActionIconProps`, `ActionMenuProps`, `ElementButtonProps`, `ElementDefinitionListProps`, `ElementFilterProps`, `ElementFormProps`, `ElementImageProps`, `ElementMetadataViewerProps`, `ElementNumberProps`, `ElementRecordPickerProps`, `ElementRepeaterProps`, `ElementTextInputProps`, `ElementTextProps`, `ObjectCalendarProps`, `ObjectFormProps`, `ObjectGanttProps`, `ObjectGridProps`, `ObjectKanbanProps`, `ObjectMapProps`, `ObjectMasterDetailFormProps`, `ObjectMetricProps`, `ObjectTimelineProps`, `ObjectTreeProps`, `PageAccordionProps`, `PageCardProps`, `PageContainerProps`, `PageHeaderProps`, `PageTabsProps`, `RecordActivityProps`, `RecordAlertAction`, `RecordAlertProps`, `RecordChatterProps`, `RecordDetailsProps`, `RecordHighlightsField`, `RecordHighlightsProps`, `RecordHistoryProps`, `RecordLineItemsProps`, `RecordPathProps`, `RecordQuickActionsProps`, `RecordReferenceRailProps`, `RecordRelatedListProps`, `ReferenceRailEntry` |

‎content/docs/references/ui/app.mdx‎

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
---
22
title: App schema — UI Protocol property reference
33
navTitle: App
4-
description: "App schemas of the ObjectStack UI Protocol: ActionNavItem, App, AppBranding and 12 more — each property with its type, default and a TypeScript example."
4+
description: "App schemas of the ObjectStack UI Protocol: ActionNavItem, App, AppBranding and 13 more — each property with its type, default and a TypeScript example."
55
---
66

77
{/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */}
@@ -13,8 +13,8 @@ description: "App schemas of the ObjectStack UI Protocol: ActionNavItem, App, Ap
1313
## TypeScript Usage
1414

1515
```typescript
16-
import { ActionNavItemSchema, AppSchema, AppBrandingSchema, AppContextSelectorSchema, ComponentNavItemSchema, DashboardNavItemSchema, DocNavItemSchema, GroupNavItemSchema, NavigationAreaSchema, NavigationContributionSchema, NavigationItemSchema, ObjectNavItemSchema, PageNavItemSchema, ReportNavItemSchema, UrlNavItemSchema } from '@objectstack/spec/ui';
17-
import type { ActionNavItem, App, AppBranding, AppContextSelector, ComponentNavItem, DashboardNavItem, DocNavItem, GroupNavItem, NavigationArea, NavigationContribution, NavigationItem, ObjectNavItem, PageNavItem, ReportNavItem, UrlNavItem } from '@objectstack/spec/ui';
16+
import { ActionNavItemSchema, AppSchema, AppBrandingSchema, AppContextSelectorSchema, AppLandingEntrySchema, ComponentNavItemSchema, DashboardNavItemSchema, DocNavItemSchema, GroupNavItemSchema, NavigationAreaSchema, NavigationContributionSchema, NavigationItemSchema, ObjectNavItemSchema, PageNavItemSchema, ReportNavItemSchema, UrlNavItemSchema } from '@objectstack/spec/ui';
17+
import type { ActionNavItem, App, AppBranding, AppContextSelector, AppLandingEntry, ComponentNavItem, DashboardNavItem, DocNavItem, GroupNavItem, NavigationArea, NavigationContribution, NavigationItem, ObjectNavItem, PageNavItem, ReportNavItem, UrlNavItem } from '@objectstack/spec/ui';
1818

1919
// Validate data
2020
const result = ActionNavItemSchema.parse(data);
@@ -78,6 +78,7 @@ const result = ActionNavItemSchema.parse(data);
7878
| **navigation** | `({ id: string; label?: string \| Record<string, string>; icon?: string; order?: number; … } \| { type: 'separator'; id?: string; order?: number } \| … +8 more)[]` | optional | Full navigation tree for the app sidebar |
7979
| **areas** | `{ id: string; label: string \| Record<string, string>; icon?: string; description?: string \| Record<string, string>; … }[]` | optional | Navigation areas for partitioning navigation by business domain |
8080
| **contextSelectors** | `{ id: string; label: string \| Record<string, string>; icon?: string; optionsSource: object; … }[]` | optional | App-level scope dropdowns whose value is injected into nav items as `{<id>}` template vars |
81+
| **landing** | `{ item: string; visible: string \| object }[]` | optional | [PLANNED — not yet rendered] Per-audience landing: the first entry whose `visible` (CEL) holds for the reader names the navigation entry (`item`, an `id` of this app's own `navigation` or `areas[].navigation`) the app opens on. An `item` that names no entry of this app, or a `group` / `separator` / `action` entry, is refused at parse. No match, or no key: the app opens on its first navigation item by `order`, as before. No console reads this list yet, so until the landing redirect ships every reader still opens on the first navigation item. |
8182
| **requiredPermissions** | `string[]` | optional | Permissions required to access this app |
8283
| **defaultAgent** | `string` | optional | Platform agent bound to this app's ambient chat ('ask' is the implicit default; 'build' for authoring surfaces) — ADR-0063 §1 |
8384
| **protection** | `{ lock: Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>; reason: string; docsUrl?: string }` | optional | Package author protection block — lock policy for this app. |
@@ -302,6 +303,13 @@ Documentation entry on the app menu (ADR-0046). Targets a `book` and/or a `doc`
302303
| **allValue** | `string` | optional (default: `""`) | Sentinel value meaning "no concrete selection yet" (empty string is almost always right) |
303304
| **persist** | `Enum<'query' \| 'session' \| 'none'>` | optional (default: `"query"`) | Persist selection via URL query, sessionStorage, or not at all |
304305

306+
### Nested Shape: `App.landing[number]`
307+
308+
| Property | Type | Required | Description |
309+
| :--- | :--- | :--- | :--- |
310+
| **item** | `string` | ✅ | Id of the app's own navigation entry this audience lands on (any depth of `navigation` or `areas[].navigation`). Refused at parse when no such entry exists, or when it is a `group`, `separator` or `action` entry, which open nothing. |
311+
| **visible** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | ✅ | Audience predicate (CEL), the dialect of a navigation entry's `visible`, e.g. P`'service_agent' in current_user.positions`. Required. It reads the signed-in reader (`current_user`, `features`); there is no `data` here, and a `visible` that reads `data` is refused at parse. |
312+
305313
### Nested Shape: `App.protection`
306314

307315
| Property | Type | Required | Description |
@@ -350,6 +358,18 @@ Documentation entry on the app menu (ADR-0046). Targets a `book` and/or a `doc`
350358
| **filter** | `{ key: string; op?: Enum<'eq' \| 'ne' \| 'in' \| 'nin'>; value: string \| string[] }[]` | optional | Predicates (AND) each option row must satisfy |
351359

352360

361+
---
362+
363+
## AppLandingEntry
364+
365+
### Properties
366+
367+
| Property | Type | Required | Description |
368+
| :--- | :--- | :--- | :--- |
369+
| **item** | `string` | ✅ | Id of the app's own navigation entry this audience lands on (any depth of `navigation` or `areas[].navigation`). Refused at parse when no such entry exists, or when it is a `group`, `separator` or `action` entry, which open nothing. |
370+
| **visible** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | ✅ | Audience predicate (CEL), the dialect of a navigation entry's `visible`, e.g. P`'service_agent' in current_user.positions`. Required. It reads the signed-in reader (`current_user`, `features`); there is no `data` here, and a `visible` that reads `data` is refused at parse. |
371+
372+
353373
---
354374

355375
## ComponentNavItem

‎content/docs/ui/apps.mdx‎

Lines changed: 60 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ const crmApp = defineApp({
4949
| `navigation` | `NavigationItem[]` | optional | Navigation tree |
5050
| `areas` | `NavigationArea[]` | optional | Partition navigation by business domain — see [Areas](#areas) |
5151
| `contextSelectors` | `AppContextSelector[]` | optional | Sidebar scope dropdowns whose value is injected into navigation items — see [Context Selectors](#context-selectors) |
52+
| `landing` | `AppLandingEntry[]` | optional | **Planned.** Per-audience landing: which navigation entry each audience opens on — see [Landing per audience](#landing-per-audience) |
5253
| `branding` | `AppBranding` | optional | Visual customization |
5354
| `requiredPermissions` | `string[]` | optional | Required permissions to access |
5455
| `defaultAgent` | `string` | optional | Platform agent bound to this app's ambient AI chat — see [Default Agent](#default-agent) |
@@ -283,7 +284,8 @@ label — the only keys it accepts are `type`, an optional `id`, and an optional
283284
All navigation items **except `separator`** share these base properties. Every
284285
item **must** declare a unique `id` (lowercase `snake_case`) — it is required by
285286
the schema, and it is what `order` sorts and what the landing page resolves to
286-
(the first item, since `homePageId` was removed in 17.0.0 — see below).
287+
(the first item, since `homePageId` was removed in 17.0.0 — see below — unless a
288+
[`landing`](#landing-per-audience) entry matches the reader).
287289
(On a `separator`, `id` is optional and the remaining properties below are
288290
rejected — a divider has nothing to label, gate, or badge.)
289291

@@ -714,6 +716,63 @@ const studioApp = defineApp({
714716
});
715717
```
716718

719+
## Landing per audience
720+
721+
An app opens on its first navigation item (by `order`). When one app serves
722+
several audiences, that is one landing for all of them: moving Sales above
723+
Service to suit the sales team moves service staff's landing to Leads.
724+
`landing` lets the app name, per audience, which of its own navigation entries
725+
a reader opens on.
726+
727+
```typescript
728+
defineApp({
729+
name: 'crm',
730+
label: 'CRM',
731+
navigation: [
732+
{ id: 'nav_leads', type: 'object', label: 'Leads', objectName: 'lead' },
733+
{ id: 'grp_service', type: 'group', label: 'Service', children: [
734+
{ id: 'nav_service_console', type: 'page', label: 'Service console', pageName: 'service_console' },
735+
]},
736+
],
737+
landing: [
738+
{ item: 'nav_service_console', visible: "'service_agent' in current_user.positions" },
739+
{ item: 'nav_leads', visible: "'sales_rep' in current_user.positions" },
740+
],
741+
});
742+
```
743+
744+
The entries are asked **in order**, and the first whose `visible` holds for the
745+
reader wins. When no entry matches, or the key is absent, the app opens where it
746+
always has: its first navigation item.
747+
748+
| Property | Type | Required | Description |
749+
| :--- | :--- | :--- | :--- |
750+
| `item` | `string` | ✅ | The `id` of a navigation entry of **this app** — its own `navigation` or any `areas[].navigation`, at any depth |
751+
| `visible` | `string` (CEL) | ✅ | Who lands there — the same dialect and reader roots (`current_user`, `features`) as a navigation entry's `visible` |
752+
753+
Refused at parse, at the entry's own path:
754+
755+
- an `item` that names no navigation entry of this app (the error lists the ones
756+
that exist). Items another package contributes through
757+
`navigationContributions` are not part of the app being parsed and cannot be
758+
named;
759+
- an `item` that names a `group`, `separator` or `action` entry — none of them
760+
opens a page;
761+
- an entry without `visible`. A landing for everyone is still "put that entry
762+
first in `navigation`"; `landing` is for different audiences;
763+
- a `visible` that reads `data` — a landing entry has no `dataSource`.
764+
765+
`landing` only decides where a reader **opens**. It grants nothing: the entry
766+
keeps its own `visible` / `requiredPermissions` / `requiresObject` gates, and an
767+
entry the reader cannot see is passed over.
768+
769+
<Callout type="warn">
770+
**Planned — not yet applied.** The key is declared and validated, but no
771+
console reads it yet, so every reader still opens on the app's first
772+
navigation item until the console's landing redirect ships. `os validate`
773+
says so for any app that declares it.
774+
</Callout>
775+
717776
## Default Agent
718777

719778
`defaultAgent` binds this app's **ambient AI chat** — the assistant the shell

‎docs/audits/2026-07-unknown-key-strictness-ledger.counts/ui.md‎

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ The `strict` column is the one the campaign schedules against; it counts both th
2121

2222
| Dir | Sites | strict | passthrough | catchall | strip |
2323
|---|---|---|---|---|---|
24-
| `ui/` | 217 | 208 | 4 | 0 | 5 |
24+
| `ui/` | 218 | 209 | 4 | 0 | 5 |
2525

2626
## `ui/` — sites
2727

@@ -33,7 +33,7 @@ classify and is not listed (it becomes reportable the day it grows its first sit
3333
|---|---|
3434
| `action-params.zod.ts` | 1 |
3535
| `action.zod.ts` | 9 |
36-
| `app.zod.ts` | 19 |
36+
| `app.zod.ts` | 20 |
3737
| `bulk-action.zod.ts` | 4 |
3838
| `chart.zod.ts` | 8 |
3939
| `component.zod.ts` | 81 |
@@ -46,23 +46,23 @@ classify and is not listed (it becomes reportable the day it grows its first sit
4646
| `sharing.zod.ts` | 1 |
4747
| `view.zod.ts` | 59 |
4848
| `widget.zod.ts` | 1 |
49-
| **total** | **217** |
49+
| **total** | **218** |
5050

5151
## `ui/` — open
5252

5353
Per file, how many of its sites still silently discard unknown keys. The `Class`
5454
column that decides the bucket split is hand-written in the ledger; the arithmetic
5555
over it is here.
5656

57-
**5 strip of 217**, in 4 file(s).
57+
**5 strip of 218**, in 4 file(s).
5858

5959
| File | Strip | Sites |
6060
|---|---|---|
6161
| `action-params.zod.ts` | 1 | 1 |
62-
| `app.zod.ts` | 1 | 19 |
62+
| `app.zod.ts` | 1 | 20 |
6363
| `view.zod.ts` | 2 | 59 |
6464
| `widget.zod.ts` | 1 | 1 |
65-
| **total** | **5** | **217** |
65+
| **total** | **5** | **218** |
6666

6767
| Bucket | Sites |
6868
|---|---|

0 commit comments

Comments
 (0)