Skip to content

Commit 6dc6576

Browse files
os-zhuangclaude
andauthored
docs(ui): document areas[] on the App page, gate asymmetry included (#4880) (#5890)
`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 <noreply@anthropic.com>
1 parent 444a07c commit 6dc6576

1 file changed

Lines changed: 197 additions & 0 deletions

File tree

content/docs/ui/apps.mdx

Lines changed: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ const crmApp = defineApp({
4848
| `active` | `boolean` | optional | Is app active (default: `true`) |
4949
| `isDefault` | `boolean` | optional | Is default app |
5050
| `navigation` | `NavigationItem[]` | optional | Navigation tree |
51+
| `areas` | `NavigationArea[]` | optional | Partition navigation by business domain — see [Areas](#areas) |
5152
| `branding` | `AppBranding` | optional | Visual customization |
5253
| `requiredPermissions` | `string[]` | optional | Required permissions to access |
5354

@@ -213,6 +214,202 @@ rejected — a divider has nothing to label, gate, or badge.)
213214
| `requiresObject` | `string` | Hide/disable unless the named object is registered |
214215
| `requiresService` | `string` | Hide/disable unless the named kernel service is registered |
215216

217+
## Areas
218+
219+
An **area** partitions one app's navigation by business domain — Sales, Service,
220+
Settings — and each area carries its own independent navigation tree. The active
221+
area's tree is what the sidebar renders; the shell offers a switcher above it
222+
once more than one area is visible.
223+
224+
### When to use an area instead of a top-level group
225+
226+
Both split a long sidebar, but they split it differently, and the choice is
227+
about whether the user needs to see the partitions *at the same time*:
228+
229+
- A **`group` item** keeps everything on screen — a collapsible section inside
230+
one tree, with every sibling group still visible beside it. Reach for this
231+
first; most apps never need anything else.
232+
- An **area** *replaces* the sidebar. Only the active area's items are listed;
233+
the rest are reached by switching. That is the right shape when a Service rep
234+
and a Sales rep share one app but never work in the other's tree, and the
235+
wrong shape when the two sets are browsed together.
236+
237+
So: simple apps use `navigation` alone. `areas[]` is for apps large enough that
238+
a single tree would be unusable, and where the domains are contexts a user
239+
switches between rather than sections they scan.
240+
241+
### Area properties
242+
243+
| Property | Type | Required | Description |
244+
| :--- | :--- | :--- | :--- |
245+
| `id` | `string` || Unique area identifier (`snake_case`) — the switcher's identity and active-area state key |
246+
| `label` | `string` || Area display label |
247+
| `icon` | `string` | optional | Area icon (Lucide) |
248+
| `description` | `string` | optional | Authoring annotation only — no surface renders it today |
249+
| `navigation` | `NavigationItem[]` || The area's own navigation tree |
250+
251+
That is the whole key set: `NavigationAreaSchema` is **strict**, so any other
252+
key fails the parse — see [Retired area-level keys](#retired-area-level-keys)
253+
for the three that used to be accepted here.
254+
255+
### How `areas[].navigation` relates to the top-level tree
256+
257+
Both trees hold the same `NavigationItem` shape, and every item property means
258+
the same thing in either one. What differs is which tree the shell renders:
259+
260+
- **Areas take precedence.** With areas declared, the active area's
261+
`navigation` is what the sidebar renders; the top-level `navigation` is the
262+
fallback, rendered when no area is visible to this user.
263+
- **Declaration order is display order.** Both the sidebar and
264+
`AppSchemaRenderer` iterate the `areas` array exactly as authored — nothing
265+
sorts areas. To rearrange them, reorder the array itself.
266+
- **The first visible area is the initial one**, and the switcher appears only
267+
when *more than one* area is visible. A single-area app renders as an ordinary
268+
sidebar with no switcher chrome.
269+
- **Area visibility is derived, never authored.** An area is offered if and only
270+
if at least one item inside it survives the item-level gates below. An area
271+
whose every item is gated away disappears from the switcher instead of
272+
stranding the user on an empty tree, and it is never auto-activated.
273+
274+
### Server-side and client-side gates are not symmetric
275+
276+
<Callout type="warn">
277+
**Anything that must never reach the browser goes in `requiredPermissions`,
278+
never in `visible`.**
279+
</Callout>
280+
281+
An area carries no gate of its own — gating lives on the items inside it, and
282+
the two item-level mechanisms are enforced in *different places*:
283+
284+
| Item property | Enforced where | What it actually does |
285+
| :--- | :--- | :--- |
286+
| `requiredPermissions`, `requiresService` | **Server**, in both trees — then re-checked in the shell | The entry is never served: it is absent from the `/meta` body |
287+
| `visible` (CEL), `requiresObject` | **Client only**, at every level | Hides an entry the browser has already received |
288+
289+
Since #4722 the authoritative server-side filter (`filterAppForUser`) runs the
290+
**same** item filter over the app's top-level `navigation` *and* over every
291+
`areas[].navigation`, so an item's `requiredPermissions` / `requiresService` is
292+
enforced identically in both trees: a gated entry — with its `objectName` /
293+
`pageName` / `componentRef` target — never leaves the server. An area emptied
294+
**by** the gate is dropped from the response, mirroring how an emptied `group`
295+
collapses; an area *authored* empty is passed through untouched, because
296+
filtering reports what the caller may not see rather than tidying the metadata.
297+
298+
`visible` did **not** move server-side with them, and that asymmetry is
299+
deliberate: CEL is evaluated in the browser because server-side evaluation needs
300+
a bound `user` context the read layer does not have. So `visible` is a
301+
decluttering affordance — it hides an entry the response already contains, and
302+
reading the JSON defeats it. Use it to reduce noise, never to keep a secret. The
303+
same holds for `requiresObject`.
304+
305+
### Retired area-level keys
306+
307+
<Callout type="warn">
308+
`areas[].order`, `areas[].visible` and `areas[].requiredPermissions` were
309+
removed in `@objectstack/spec` 17.0.0 (ADR-0049 enforce-or-remove) and are now
310+
**refused at parse time** rather than ignored, so one leftover key fails the
311+
whole save. Run `os migrate meta --from 16` to rewrite existing sources
312+
automatically.
313+
314+
- `order` (#4667) — no renderer ever sorted areas, so declaration order
315+
already *was* display order and an author who set `order` saw nothing move.
316+
Delete the key and reorder the `areas` array. Note the neighbour that
317+
behaves differently: a navigation **item's** `order` is genuinely sorted,
318+
and this removal does not touch it.
319+
- `visible` and `requiredPermissions` (#4651) — these were not merely unread
320+
keys, they were **fail-open gates**. No layer read them, so an area "hidden"
321+
by one, or restricted to `['sales.admin']`, was served to and rendered for
322+
*every* user: the author got a clean parse, a stored value, and no gate at
323+
all. Removing a gate that never gated is strictly safer than shipping one
324+
that looks like it works. Gate the items **inside** the area instead — an
325+
item's `visible` takes the same CEL expression, and its
326+
`requiredPermissions` is the server-enforced one — or gate the whole app
327+
with the app-level `requiredPermissions`, which *is* checked server-side.
328+
329+
The removals do not read back onto the items: item-level gating inside an area
330+
is fully supported, and since #4722 it is server-enforced.
331+
</Callout>
332+
333+
### Complete Example
334+
335+
Two areas for one field-service app, showing both gate kinds side by side:
336+
337+
{/* os:check */}
338+
```typescript
339+
import { defineApp } from '@objectstack/spec';
340+
341+
const fieldServiceApp = defineApp({
342+
name: 'field_service',
343+
label: 'Field Service',
344+
description: 'Dispatch, work orders, and service analytics',
345+
icon: 'wrench',
346+
active: true,
347+
348+
branding: {
349+
primaryColor: '#0f766e',
350+
logo: '/assets/fs-logo.svg',
351+
},
352+
353+
// Declaration order IS display order — areas carry no `order` key.
354+
areas: [
355+
{
356+
id: 'area_dispatch',
357+
label: 'Dispatch',
358+
icon: 'calendar-clock',
359+
navigation: [
360+
{ id: 'nav_board', type: 'page', label: 'Dispatch Board', pageName: 'dispatch_board', icon: 'layout-dashboard' },
361+
{ id: 'nav_work_orders', type: 'object', label: 'Work Orders', objectName: 'work_order', icon: 'clipboard-list', viewName: 'open_work_orders' },
362+
{
363+
id: 'nav_my_jobs',
364+
type: 'object',
365+
label: 'My Jobs',
366+
objectName: 'work_order',
367+
icon: 'user-check',
368+
filters: { technician_id: '{current_user_id}', status: 'scheduled' },
369+
},
370+
],
371+
},
372+
{
373+
id: 'area_analytics',
374+
label: 'Analytics',
375+
icon: 'bar-chart',
376+
navigation: [
377+
{ id: 'nav_sla', type: 'dashboard', label: 'SLA Overview', dashboardName: 'service_sla', icon: 'gauge' },
378+
{
379+
id: 'nav_job_margin',
380+
type: 'report',
381+
label: 'Job Margin',
382+
reportName: 'job_margin',
383+
icon: 'file-bar-chart',
384+
// SERVER-enforced in both trees: a caller without this permission
385+
// never receives this entry, so its `reportName` is not readable
386+
// from the /meta body either.
387+
requiredPermissions: ['service.finance'],
388+
},
389+
{
390+
id: 'nav_forecast_beta',
391+
type: 'dashboard',
392+
label: 'Forecast (beta)',
393+
dashboardName: 'service_forecast',
394+
icon: 'trending-up',
395+
// CLIENT-only: this entry IS sent and then hidden. Decluttering,
396+
// not access control — never put a secret behind `visible`.
397+
visible: "'service_beta' in current_user.positions",
398+
},
399+
],
400+
},
401+
],
402+
403+
// The fallback tree: rendered only when NO area is visible to the caller
404+
// (every area's items gated away, or the areas list filtered empty).
405+
navigation: [
406+
{ id: 'nav_handbook', type: 'url', label: 'Service Handbook', url: 'https://help.example.com/service', icon: 'book-open', target: '_blank' },
407+
],
408+
409+
requiredPermissions: ['service_access'],
410+
});
411+
```
412+
216413
## Branding
217414

218415
Customize the visual appearance of the app:

0 commit comments

Comments
 (0)