Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions .changeset/28022-app-landing-per-audience.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
---
'@objectstack/spec': minor
'@objectstack/lint': minor
---

feat(spec): an app can name a landing per audience with `landing`

Clause-②: yes (widening)
Metadata-protocol: added

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

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

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`.
1 change: 1 addition & 0 deletions content/docs/references/api/metadata.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@ const result = AppDefinitionResponseSchema.parse(data);
| **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 |
| **areas** | `{ id: string; label: string \| Record<string, string>; icon?: string; description?: string \| Record<string, string>; … }[]` | optional | Navigation areas for partitioning navigation by business domain |
| **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 |
| **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. |
| **requiredPermissions** | `string[]` | optional | Permissions required to access this app |
| **defaultAgent** | `string` | optional | Platform agent bound to this app's ambient chat ('ask' is the implicit default; 'build' for authoring surfaces) — ADR-0063 §1 |
| **protection** | `{ lock: Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>; reason: string; docsUrl?: string }` | optional | Package author protection block — lock policy for this app. |
Expand Down
10 changes: 5 additions & 5 deletions content/docs/references/index.mdx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
---
title: Protocol reference — every schema by module
navTitle: Protocol Reference
description: Every schema published by @objectstack/spec — 1574 schemas across 14 protocol modules
description: Every schema published by @objectstack/spec — 1575 schemas across 14 protocol modules
---

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

---

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

## UI Protocol

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

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

| File | Schemas |
| :--- | :--- |
| [`action.zod.ts`](/docs/references/ui/action) | `Action`, `ActionAi`, `ActionLocation`, `ActionParam`, `ActionType`, `InlineAction` |
| [`action-params.zod.ts`](/docs/references/ui/action-params) | `ActionSession` |
| [`app.zod.ts`](/docs/references/ui/app) | `ActionNavItem`, `App`, `AppBranding`, `AppContextSelector`, `ComponentNavItem`, `DashboardNavItem`, `DocNavItem`, `GroupNavItem`, `NavigationArea`, `NavigationContribution`, `NavigationItem`, `ObjectNavItem`, `PageNavItem`, `ReportNavItem`, `UrlNavItem` |
| [`app.zod.ts`](/docs/references/ui/app) | `ActionNavItem`, `App`, `AppBranding`, `AppContextSelector`, `AppLandingEntry`, `ComponentNavItem`, `DashboardNavItem`, `DocNavItem`, `GroupNavItem`, `NavigationArea`, `NavigationContribution`, `NavigationItem`, `ObjectNavItem`, `PageNavItem`, `ReportNavItem`, `UrlNavItem` |
| [`bulk-action.zod.ts`](/docs/references/ui/bulk-action) | `BulkActionDef`, `BulkActionExecution`, `BulkActionOperation`, `BulkActionParam` |
| [`chart.zod.ts`](/docs/references/ui/chart) | `ChartAggregate`, `ChartAggregateFunction`, `ChartAnnotation`, `ChartAxis`, `ChartConfig`, `ChartDrillDown`, `ChartGroupBy`, `ChartInteraction`, `ChartSeries`, `ChartType` |
| [`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` |
Expand Down
26 changes: 23 additions & 3 deletions content/docs/references/ui/app.mdx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
---
title: App schema — UI Protocol property reference
navTitle: App
description: "App schemas of the ObjectStack UI Protocol: ActionNavItem, App, AppBranding and 12 more — each property with its type, default and a TypeScript example."
description: "App schemas of the ObjectStack UI Protocol: ActionNavItem, App, AppBranding and 13 more — each property with its type, default and a TypeScript example."
---

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

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

// Validate data
const result = ActionNavItemSchema.parse(data);
Expand Down Expand Up @@ -78,6 +78,7 @@ const result = ActionNavItemSchema.parse(data);
| **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 |
| **areas** | `{ id: string; label: string \| Record<string, string>; icon?: string; description?: string \| Record<string, string>; … }[]` | optional | Navigation areas for partitioning navigation by business domain |
| **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 |
| **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. |
| **requiredPermissions** | `string[]` | optional | Permissions required to access this app |
| **defaultAgent** | `string` | optional | Platform agent bound to this app's ambient chat ('ask' is the implicit default; 'build' for authoring surfaces) — ADR-0063 §1 |
| **protection** | `{ lock: Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>; reason: string; docsUrl?: string }` | optional | Package author protection block — lock policy for this app. |
Expand Down Expand Up @@ -302,6 +303,13 @@ Documentation entry on the app menu (ADR-0046). Targets a `book` and/or a `doc`
| **allValue** | `string` | optional (default: `""`) | Sentinel value meaning "no concrete selection yet" (empty string is almost always right) |
| **persist** | `Enum<'query' \| 'session' \| 'none'>` | optional (default: `"query"`) | Persist selection via URL query, sessionStorage, or not at all |

### Nested Shape: `App.landing[number]`

| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **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. |
| **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. |

### Nested Shape: `App.protection`

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


---

## AppLandingEntry

### Properties

| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **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. |
| **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. |


---

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

Expand Down Expand Up @@ -714,6 +716,63 @@ const studioApp = defineApp({
});
```

## Landing per audience

An app opens on its first navigation item (by `order`). When one app serves
several audiences, that is one landing for all of them: moving Sales above
Service to suit the sales team moves service staff's landing to Leads.
`landing` lets the app name, per audience, which of its own navigation entries
a reader opens on.

```typescript
defineApp({
name: 'crm',
label: 'CRM',
navigation: [
{ id: 'nav_leads', type: 'object', label: 'Leads', objectName: 'lead' },
{ id: 'grp_service', type: 'group', label: 'Service', children: [
{ id: 'nav_service_console', type: 'page', label: 'Service console', pageName: 'service_console' },
]},
],
landing: [
{ item: 'nav_service_console', visible: "'service_agent' in current_user.positions" },
{ item: 'nav_leads', visible: "'sales_rep' in current_user.positions" },
],
});
```

The entries are asked **in order**, and the first whose `visible` holds for the
reader wins. When no entry matches, or the key is absent, the app opens where it
always has: its first navigation item.

| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| `item` | `string` | ✅ | The `id` of a navigation entry of **this app** — its own `navigation` or any `areas[].navigation`, at any depth |
| `visible` | `string` (CEL) | ✅ | Who lands there — the same dialect and reader roots (`current_user`, `features`) as a navigation entry's `visible` |

Refused at parse, at the entry's own path:

- an `item` that names no navigation entry of this app (the error lists the ones
that exist). Items another package contributes through
`navigationContributions` are not part of the app being parsed and cannot be
named;
- an `item` that names a `group`, `separator` or `action` entry — none of them
opens a page;
- an entry without `visible`. A landing for everyone is still "put that entry
first in `navigation`"; `landing` is for different audiences;
- a `visible` that reads `data` — a landing entry has no `dataSource`.

`landing` only decides where a reader **opens**. It grants nothing: the entry
keeps its own `visible` / `requiredPermissions` / `requiresObject` gates, and an
entry the reader cannot see is passed over.

<Callout type="warn">
**Planned — not yet applied.** The key is declared and validated, but no
console reads it yet, so every reader still opens on the app's first
navigation item until the console's landing redirect ships. `os validate`
says so for any app that declares it.
</Callout>

## Default Agent

`defaultAgent` binds this app's **ambient AI chat** — the assistant the shell
Expand Down
12 changes: 6 additions & 6 deletions docs/audits/2026-07-unknown-key-strictness-ledger.counts/ui.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ The `strict` column is the one the campaign schedules against; it counts both th

| Dir | Sites | strict | passthrough | catchall | strip |
|---|---|---|---|---|---|
| `ui/` | 217 | 208 | 4 | 0 | 5 |
| `ui/` | 218 | 209 | 4 | 0 | 5 |

## `ui/` — sites

Expand All @@ -33,7 +33,7 @@ classify and is not listed (it becomes reportable the day it grows its first sit
|---|---|
| `action-params.zod.ts` | 1 |
| `action.zod.ts` | 9 |
| `app.zod.ts` | 19 |
| `app.zod.ts` | 20 |
| `bulk-action.zod.ts` | 4 |
| `chart.zod.ts` | 8 |
| `component.zod.ts` | 81 |
Expand All @@ -46,23 +46,23 @@ classify and is not listed (it becomes reportable the day it grows its first sit
| `sharing.zod.ts` | 1 |
| `view.zod.ts` | 59 |
| `widget.zod.ts` | 1 |
| **total** | **217** |
| **total** | **218** |

## `ui/` — open

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

**5 strip of 217**, in 4 file(s).
**5 strip of 218**, in 4 file(s).

| File | Strip | Sites |
|---|---|---|
| `action-params.zod.ts` | 1 | 1 |
| `app.zod.ts` | 1 | 19 |
| `app.zod.ts` | 1 | 20 |
| `view.zod.ts` | 2 | 59 |
| `widget.zod.ts` | 1 | 1 |
| **total** | **5** | **217** |
| **total** | **5** | **218** |

| Bucket | Sites |
|---|---|
Expand Down
Loading
Loading