Skip to content

Commit fa7ade4

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/project-thread-l8kwol
2 parents 0d4941f + 80a9f99 commit fa7ade4

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)