You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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>
|`requiredPermissions`|`string[]`| optional | Required permissions to access |
53
54
@@ -213,6 +214,202 @@ rejected — a divider has nothing to label, gate, or badge.)
213
214
|`requiresObject`|`string`| Hide/disable unless the named object is registered |
214
215
|`requiresService`|`string`| Hide/disable unless the named kernel service is registered |
215
216
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
+
<Callouttype="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
+
<Callouttype="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.
0 commit comments