From c103438e9d0c37b3d04e02a6282320789ecadf4c Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 6 Aug 2026 11:01:14 +0000 Subject: [PATCH] docs(ui): document `areas[]` on the App page, gate asymmetry included (#4880) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `content/docs/ui/apps.mdx` never mentioned `areas[]` — an authorable key with permission-adjacent semantics at zero doc coverage, while three changes landed on it in the 17.0.0 window. An author reading only this page could not learn that areas exist, let alone that the gate they reach for first is the one the server does not enforce. New `## Areas` section: - when an area beats a top-level `group` (a group keeps siblings on screen; an area REPLACES the sidebar, so it fits contexts a user switches between); - the area property table — the whole strict key set, with `description` marked as the annotation no surface renders today; - how `areas[].navigation` relates to the top-level tree: areas take precedence and the top-level tree is the fallback when no area is visible; declaration order IS display order (nothing sorts areas); the switcher appears only above one visible area; area visibility is DERIVED from the items inside, never authored; - the server/client gate asymmetry as the section's safety point: item-level `requiredPermissions` / `requiresService` are stripped server-side in BOTH trees since #4722 and re-checked in the shell, while `visible` (CEL) and `requiresObject` stay client-only at every level — so anything that must never reach the browser goes in `requiredPermissions`, never in `visible`; - the 17.0.0 retirements (`order` #4667, `visible` / `requiredPermissions` #4651) as parse-time rejections, with the fail-open history that motivated them and the note that they do not read back onto item-level gating; - a Complete-Example-grade sample marked `{/* os:check */}` so the gate type-checks it against the built spec (206 prose examples, was 205), showing both gate kinds side by side and carrying none of the three retired keys. Wording follows the corrected `AREA_REQUIRED_PERMISSIONS_RETIRED` / `AREA_VISIBLE_RETIRED` prescriptions and the `areas.navigation` note in `packages/spec/liveness/app.json`; nothing under `packages/**` is touched. Closes #4880 Co-authored-by: Claude --- content/docs/ui/apps.mdx | 197 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 197 insertions(+) diff --git a/content/docs/ui/apps.mdx b/content/docs/ui/apps.mdx index 56c7ac0bad..bba17f06f8 100644 --- a/content/docs/ui/apps.mdx +++ b/content/docs/ui/apps.mdx @@ -48,6 +48,7 @@ const crmApp = defineApp({ | `active` | `boolean` | optional | Is app active (default: `true`) | | `isDefault` | `boolean` | optional | Is default app | | `navigation` | `NavigationItem[]` | optional | Navigation tree | +| `areas` | `NavigationArea[]` | optional | Partition navigation by business domain — see [Areas](#areas) | | `branding` | `AppBranding` | optional | Visual customization | | `requiredPermissions` | `string[]` | optional | Required permissions to access | @@ -213,6 +214,202 @@ rejected — a divider has nothing to label, gate, or badge.) | `requiresObject` | `string` | Hide/disable unless the named object is registered | | `requiresService` | `string` | Hide/disable unless the named kernel service is registered | +## Areas + +An **area** partitions one app's navigation by business domain — Sales, Service, +Settings — and each area carries its own independent navigation tree. The active +area's tree is what the sidebar renders; the shell offers a switcher above it +once more than one area is visible. + +### When to use an area instead of a top-level group + +Both split a long sidebar, but they split it differently, and the choice is +about whether the user needs to see the partitions *at the same time*: + +- A **`group` item** keeps everything on screen — a collapsible section inside + one tree, with every sibling group still visible beside it. Reach for this + first; most apps never need anything else. +- An **area** *replaces* the sidebar. Only the active area's items are listed; + the rest are reached by switching. That is the right shape when a Service rep + and a Sales rep share one app but never work in the other's tree, and the + wrong shape when the two sets are browsed together. + +So: simple apps use `navigation` alone. `areas[]` is for apps large enough that +a single tree would be unusable, and where the domains are contexts a user +switches between rather than sections they scan. + +### Area properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| `id` | `string` | ✅ | Unique area identifier (`snake_case`) — the switcher's identity and active-area state key | +| `label` | `string` | ✅ | Area display label | +| `icon` | `string` | optional | Area icon (Lucide) | +| `description` | `string` | optional | Authoring annotation only — no surface renders it today | +| `navigation` | `NavigationItem[]` | ✅ | The area's own navigation tree | + +That is the whole key set: `NavigationAreaSchema` is **strict**, so any other +key fails the parse — see [Retired area-level keys](#retired-area-level-keys) +for the three that used to be accepted here. + +### How `areas[].navigation` relates to the top-level tree + +Both trees hold the same `NavigationItem` shape, and every item property means +the same thing in either one. What differs is which tree the shell renders: + +- **Areas take precedence.** With areas declared, the active area's + `navigation` is what the sidebar renders; the top-level `navigation` is the + fallback, rendered when no area is visible to this user. +- **Declaration order is display order.** Both the sidebar and + `AppSchemaRenderer` iterate the `areas` array exactly as authored — nothing + sorts areas. To rearrange them, reorder the array itself. +- **The first visible area is the initial one**, and the switcher appears only + when *more than one* area is visible. A single-area app renders as an ordinary + sidebar with no switcher chrome. +- **Area visibility is derived, never authored.** An area is offered if and only + if at least one item inside it survives the item-level gates below. An area + whose every item is gated away disappears from the switcher instead of + stranding the user on an empty tree, and it is never auto-activated. + +### Server-side and client-side gates are not symmetric + + + **Anything that must never reach the browser goes in `requiredPermissions`, + never in `visible`.** + + +An area carries no gate of its own — gating lives on the items inside it, and +the two item-level mechanisms are enforced in *different places*: + +| Item property | Enforced where | What it actually does | +| :--- | :--- | :--- | +| `requiredPermissions`, `requiresService` | **Server**, in both trees — then re-checked in the shell | The entry is never served: it is absent from the `/meta` body | +| `visible` (CEL), `requiresObject` | **Client only**, at every level | Hides an entry the browser has already received | + +Since #4722 the authoritative server-side filter (`filterAppForUser`) runs the +**same** item filter over the app's top-level `navigation` *and* over every +`areas[].navigation`, so an item's `requiredPermissions` / `requiresService` is +enforced identically in both trees: a gated entry — with its `objectName` / +`pageName` / `componentRef` target — never leaves the server. An area emptied +**by** the gate is dropped from the response, mirroring how an emptied `group` +collapses; an area *authored* empty is passed through untouched, because +filtering reports what the caller may not see rather than tidying the metadata. + +`visible` did **not** move server-side with them, and that asymmetry is +deliberate: CEL is evaluated in the browser because server-side evaluation needs +a bound `user` context the read layer does not have. So `visible` is a +decluttering affordance — it hides an entry the response already contains, and +reading the JSON defeats it. Use it to reduce noise, never to keep a secret. The +same holds for `requiresObject`. + +### Retired area-level keys + + + `areas[].order`, `areas[].visible` and `areas[].requiredPermissions` were + removed in `@objectstack/spec` 17.0.0 (ADR-0049 enforce-or-remove) and are now + **refused at parse time** rather than ignored, so one leftover key fails the + whole save. Run `os migrate meta --from 16` to rewrite existing sources + automatically. + + - `order` (#4667) — no renderer ever sorted areas, so declaration order + already *was* display order and an author who set `order` saw nothing move. + Delete the key and reorder the `areas` array. Note the neighbour that + behaves differently: a navigation **item's** `order` is genuinely sorted, + and this removal does not touch it. + - `visible` and `requiredPermissions` (#4651) — these were not merely unread + keys, they were **fail-open gates**. No layer read them, so an area "hidden" + by one, or restricted to `['sales.admin']`, was served to and rendered for + *every* user: the author got a clean parse, a stored value, and no gate at + all. Removing a gate that never gated is strictly safer than shipping one + that looks like it works. Gate the items **inside** the area instead — an + item's `visible` takes the same CEL expression, and its + `requiredPermissions` is the server-enforced one — or gate the whole app + with the app-level `requiredPermissions`, which *is* checked server-side. + + The removals do not read back onto the items: item-level gating inside an area + is fully supported, and since #4722 it is server-enforced. + + +### Complete Example + +Two areas for one field-service app, showing both gate kinds side by side: + +{/* os:check */} +```typescript +import { defineApp } from '@objectstack/spec'; + +const fieldServiceApp = defineApp({ + name: 'field_service', + label: 'Field Service', + description: 'Dispatch, work orders, and service analytics', + icon: 'wrench', + active: true, + + branding: { + primaryColor: '#0f766e', + logo: '/assets/fs-logo.svg', + }, + + // Declaration order IS display order — areas carry no `order` key. + areas: [ + { + id: 'area_dispatch', + label: 'Dispatch', + icon: 'calendar-clock', + navigation: [ + { id: 'nav_board', type: 'page', label: 'Dispatch Board', pageName: 'dispatch_board', icon: 'layout-dashboard' }, + { id: 'nav_work_orders', type: 'object', label: 'Work Orders', objectName: 'work_order', icon: 'clipboard-list', viewName: 'open_work_orders' }, + { + id: 'nav_my_jobs', + type: 'object', + label: 'My Jobs', + objectName: 'work_order', + icon: 'user-check', + filters: { technician_id: '{current_user_id}', status: 'scheduled' }, + }, + ], + }, + { + id: 'area_analytics', + label: 'Analytics', + icon: 'bar-chart', + navigation: [ + { id: 'nav_sla', type: 'dashboard', label: 'SLA Overview', dashboardName: 'service_sla', icon: 'gauge' }, + { + id: 'nav_job_margin', + type: 'report', + label: 'Job Margin', + reportName: 'job_margin', + icon: 'file-bar-chart', + // SERVER-enforced in both trees: a caller without this permission + // never receives this entry, so its `reportName` is not readable + // from the /meta body either. + requiredPermissions: ['service.finance'], + }, + { + id: 'nav_forecast_beta', + type: 'dashboard', + label: 'Forecast (beta)', + dashboardName: 'service_forecast', + icon: 'trending-up', + // CLIENT-only: this entry IS sent and then hidden. Decluttering, + // not access control — never put a secret behind `visible`. + visible: "'service_beta' in current_user.positions", + }, + ], + }, + ], + + // The fallback tree: rendered only when NO area is visible to the caller + // (every area's items gated away, or the areas list filtered empty). + navigation: [ + { id: 'nav_handbook', type: 'url', label: 'Service Handbook', url: 'https://help.example.com/service', icon: 'book-open', target: '_blank' }, + ], + + requiredPermissions: ['service_access'], +}); +``` + ## Branding Customize the visual appearance of the app: