# leumas-domains

Studio and control-plane domains — the door, its tabs and sub-routes, entity kinds, the 3D room and its card counts. Read before touching an admin surface or domain-entities.js.


# Leumas Studio domains

A **domain** is one section of Studio — Hosting, AI, Devices, Protocols, Information, Dynamic, Apps.
It is not a folder convention; it is a contract that seven separate systems read. Get the contract
right and the sidebar, the breadcrumb trail, the ⌘K palette, the route table, the install gate, the
3D room and the generated diagrams all follow with no extra code. Get it wrong and each of those
fails silently and separately.

## Domains, sub-tabs and entities — the three levels, and the one idea

**A DOMAIN is a section. Its SUB-TABS are the real URLs inside a page. Its ENTITIES are the
operator's own things** — the rows its room draws and the numbers its cards carry. Three different
questions, and each has exactly one home:

| question | home |
|---|---|
| what pages exist, and where do they live | `nav.manifest.js` |
| what sections a page has | `<domain>/model/tabs.js` |
| what THINGS this domain owns | `shared/apps/domain-entities.js` |

A domain that restates any of them somewhere else has two answers that will disagree, and both halves
of that failure are silent.

**Every domain's front door is its Overview**, at `/admin/d/<id>` — the room behind glass, a portal
rail of its sections, a deck of its things, and the seven view modes over the same list. Each domain
is its own small admin portal, and the same entities also populate the user's `/dashboard`, because
**it is the same read**.

The deck follows one rule, stated once in `deckFor.js`: **a domain deals its THINGS when it has them,
else its sections.** Picking a thing makes the surface about that thing (`select: 'toggle'`); picking
a section goes there (`'navigate'`). Whichever it is, the rail beside it is always the domain's pages,
so navigation never costs the subject its stage.

## The shape, top to bottom

```
/admin/d/<id>            THE DOOR          → an Overview HUD (a room + a deck + a readout rail)
  /admin/<id>            A PAGE            → a routed surface, one per manifest sub-item
    /admin/<id>/<tab>    A SUB-TAB         → a REAL route, nested <Routes> inside that page
      …/<tab>/<child>    A SUB-SUB ROUTE   → a real route again, nested one level deeper
```

**Every level is a URL.** That is the whole design and the thing to protect: a tab you can deep-link,
refresh, put in a breadcrumb, reach from ⌘K, and send someone. A tab held in `useState` looks the same
on screen and is none of those things.

**Why tabs and not more sub-items.** A separate sub-item mounts its own shell, so a running scan, a
live poll, a WebGL context or an unsent request is thrown away when you change tab. A splat route
keeps the shell — and everything alive in it — across every tab. That is why Devices and Information
are each one tabbed page rather than a page per instrument.

Fold pages together for the SHELL, never to buy an aside heading — headings work on flat multi-page
domains too. See **The aside** below.

## The seven systems a domain feeds, from one declaration

`products/leumas-studio/src/admin/nav.manifest.js` is the single source. One entry gives you:

| It feeds | How |
|---|---|
| the route table | `AdminRoutes` derives one `<Route>` per sub-item; a sub-item with `tabs` mounts on a **splat** (`path/*`) so it can own nested `<Routes>` |
| the aside | published via `useRegisterContextNav`; a **one-page domain lists its TABS** (`tabsToNavItems`), and a tab's own `tabs` render as indented rows. Grouped into named families — see below |
| the breadcrumbs | `Studio › <domain> › <page> › <segment>`, every crumb carrying its siblings |
| ⌘K | `navToCommands` emits the domain, every page and every tab — tabs are routes, so the palette can jump straight into one |
| the deck / front door | the domain tile, its pillar, its art |
| the install gate | `...app('<id>')` makes a domain installable; `feature:` gates the content instead of vanishing the tile |
| the diagrams | `pnpm diagrams` reads this file + each `overview.js` → `ops/reference/diagrams/` |

**Never restate a tab list.** `nav.manifest.js` imports `DEVICE_TABS` / `INFORMATION_TABS` from the
domain's own `model/tabs.js`. A list written twice drifts on the first rename, and both halves of the
failure are silent: a manifest tab with no panel renders an empty stage; a panel with no manifest tab
has no route.

## The aside — every domain's links, in named families

Seven domains group their aside rows under headings: **AI · Hosting · Devices · Dynamic · Information ·
Protocols · Apps**. Dashboard and Studio are deliberately not domains and are left alone.

**Grouping is a flat list plus adjacency.** `NavMenu` prints a heading wherever `item.group` differs
from the row above it. There is no nested structure, no sort step, and no grouping component — do not
add one. `shared/packages/ui/src/NavMenu.jsx` draws it; `shared/packages/config-registry/src/nav.js`
carries the two fields.

Declare `group` + `groupLabel` where the row is declared — on a **tab** in `<domain>/model/tabs.js`
for a one-page domain, or on a **sub-item** in `nav.manifest.js` for a flat multi-page one. Both reach
the aside; neither needs a URL to move.

**Keyboard.** `Ctrl+↑` / `Ctrl+↓` step tabs AND sub-tabs, `Ctrl+←` / `Ctrl+→` step domains in sidebar
order. Both are remappable in Settings → Keyboard Shortcuts.

**A step SLIDES; it does not cut.** A ring calls `signalNavIntent({ axis, delta })` from
`@leumas/entrance/routing` immediately before it navigates, and the route transition slides the page
region — chrome held still — the way the key pointed: horizontally for the domain ring and for a tab
strip (a row on screen), vertically for the aside (a column). Without that signal the transition only
knows the two paths, and a sibling domain is at the same depth as the one you left, so left and right
animated identically. Both rings also warm the neighbours either side of where you land, on idle, so
the FIRST press is as fast as the ones after it. Nothing per-domain is wired: a domain installed
tomorrow inherits all of it.

One gesture covers both levels because the strip **hands off at its edge** rather than wrapping
(`stepTabs` in `shortcuts/navRings.js`). While the page's own strip has somewhere left to go the
press is a sub-tab move; when it runs out, the press falls through to the aside and becomes a jump
to the next section. Two registries feed it:

1. **the tab ring** — a strip the page renders. `RouteTabs` and `HudTabs` register themselves.
2. **the section ring** — the domain's ASIDE, in the order it is drawn. `DomainRoutes` registers the
   published `contextNav` for **every** domain, so this needs no per-domain wiring and cannot be
   lost by a door choosing not to draw a strip (Devices did exactly that).

[critical] Do NOT add `useTabRing` to a domain hub to "give it" the gesture — it already has it, and a second
ring at the same depth is a coin flip. The deck row is never a destination: leaving the domain is
`Ctrl+←/→`.

### The rules

1. **Order IS the grouping.** A family's members must be contiguous. One filed out of order re-prints
   its family's heading instead of joining it.
2. **Repeat `groupLabel` on every member.** It falls back to `group`, so the first member alone
   *looks* sufficient — until an edition or feature flag removes exactly that row and the family reads
   as its raw id (`tools`, not `Its tools`). Nothing fails and no guard notices.
3. **2–6 families per domain, 2–7 rows each.** Never a heading over one row — an indent that says
   nothing. Count what a build actually RENDERS, not what is declared: a `deferred` sub-item is
   filtered out before the aside sees it, which is how Hosting's `Traffic` family became a heading over
   a single row (folded into `Serve & watch`).
   *Three known exceptions, all pre-dating this rule:* AI's `How it thinks` and `What it did`, and
   Devices' `Everything`. Do not add a fourth.
4. **Labels are plain language about the domain's job** — `What it makes`, `Your websites`,
   `Outside this app`. Sentence case, no trailing punctuation. `group` is a stable id a tint or palette
   can key off; `groupLabel` is the words. Two fields, so rewording a heading never renames an id.
5. **Every row is in a family.** No ungrouped stragglers above or below the headings.
6. **A runtime-contributed row carries a `group` too.** `spliceNavRows` places it beside that family
   when a declared row shares it, and appends it under its own heading when nothing does — which is
   how Protocols keeps its four declared rows put and still labels the installed `comms-*` packs
   `Your transports`.
7. **A nested row inherits its parent's family** — `tabsToNavItems` and `sectionRows` do this for you.
   A group-less row in the middle of a family is a hole in it, and the row after the expansion prints
   the heading a second time.

### The whitelist trap [critical]

A field must survive **both** the manifest's own `.map((t) => ({ … }))` literal **and** `normalizeTab`
/ `normalizeSubItem` in `nav.js`. This exact failure shipped once: Devices declared `group`, the
normalizer carried it, `tabsToNavItems` carried it — and the aside still drew twenty flat rows,
because the `.map()` in `nav.manifest.js` named five fields and these were not among them. Every layer
looked correctly wired. `pnpm check:nav` rule (g) diffs the authored node against the normalized one
for precisely this.

## The components a domain may use

Everything below already exists. Building any of it again is the expensive mistake here, because the
new copy never looks wrong.

### The door — `@leumas/domain-kit`

**It is a PACKAGE, not a folder in Studio, and TWO products render it** — Leumas Studio and Leumas
Admin (the control plane). It left `admin/_shared/hud/` the moment a second console needed a domain
door. A fix here reaches both; a copy of it in one product is the drift `check:huds` exists to stop,
and that guard asserts every door imports the shell `from '@leumas/domain-kit'`.

| Import | What it is |
|---|---|
| **`DomainOverviewHud`** | THE shell every door renders. Owns the layering contract, focus seeding, `onDeck`, the arrow keys and the hovered tail stat. It never fetches and never gates — **and it never names `three`**. |
| **`useSeededFocus(items, level)`** | the shell's own focus-seeding rule, exported for a console that must hold focus itself (AI's snapshots and Hosting's dial both read it). Seeds once; never yanks a reader back to card one — except when the LEVEL changes, because a drill swaps one non-empty deck for a different one and the held id then names a card that is not there. |
| **`DomainActionBar`** | THE BOTTOM BAR every door ends in — one row: what is centred, this door's `extra`, and the acts. Icons at rest, words on hover. Rendered by the shell for `footer: 'pitch'`; you almost never import it. |
| **`actsFor(item, ctx)`** / **`primaryActFor`** | WHAT YOU CAN DO with the centred card, resolved: the product's declared acts, then the card's own rung, capped at three. `primaryActFor` is what a click, the Enter key and the bar's primary all run — one decision table, so they cannot disagree. Pure and bare-Node. |
| **`useDrill()`** / **`DRILL_PARAM`** | the funnel's LEVEL, in the URL (`?in=<pageId>`). The shell uses it; a door that deals its own level 2 (Devices opens an instrument into the operator's own devices) uses the same hook, so `replace` policy — and therefore the Back key — cannot differ between doors. |
| **`DomainRail`** | THE READOUT — this domain's NUMBERS, in `CanvasHud`'s `left` slot. Pass `rail` to the shell and it derives one from `stats`; pass a function and you draw your own panels. **It does not list the domain's pages.** The contextual aside already does, from the same `sectionRows` derivation, on every route of the domain — a rail that repeated them printed the sidebar twice and spent the stage doing it. A door with no `stats` therefore renders **no rail at all** and its room goes full-bleed. It renders INSIDE `.lms-chud`'s sealed ladder, which is why it structurally cannot repeat the `beside` layering bug. [warning] **A door may have a rail OR a full-bleed `beside`, never both** — `check:huds` fails one that passes both. |
| **`useDomainSections(domain, {counts, room, api, pathname})`** | the ONE derivation the rail, the deck and the aside share, so a rail row and an aside row cannot disagree about what is in this domain. Adds the numbers, and the rule that a one-tabbed-page domain deals its TABS. |
| **`deckItemsFor(domain, {room, sections, open})`** | the deck rule, written once: **its pages and its things at level 1, one page's sub-tabs at level 2**. `items.source` says which rung answered, so a door can say "no websites yet" rather than "no sections". A page's children come from its `tabs`, or — for the flat shape a one-tabbed-page domain arrives in — from the rows that follow it at a greater depth. |
| **`DomainViewHost`** | wraps `NavViewHost` so a door can be read as console / grid / wheel / flux / command / channels / solar over **its own things**. `storagePrefix` is PER PRODUCT (the store is per person, so two consoles sharing a domain id would share one choice), ⌘K `View: …` commands. |
| **`registerDomainRoom` / `getDomainRoom`** | the seam that keeps the renderer optional. The shell asks the registry; a product opts in with `import '@leumas/domain-kit/room'` in its **entry**. |
| `@leumas/domain-kit/console-view` | side-effect: registers the canvas HUD as the `console` nav view. One registration serves every domain — it takes the actual console from `viewProps`. |
| `@leumas/domain-kit/room` | side-effect: registers the 3D room. **The only path in the kit that reaches `three`.** Omit the import and every door still works, minus the backdrop. |
| `@leumas/domain-kit/room/backdrop` | `DomainWorldBackdrop` itself, for a surface drawing its own stage. Import it lazily. |
| `@leumas/domain-kit/domain-hud.css` | `lms-dhud__*`: host, the action bar and its acts, card icon + `data-state`, card meta. The shell imports it itself. |

**A `lazy(() => import(...))` inside the shell is NOT free** — it is an unconditional module edge, so
the renderer chunk is emitted into every consumer whether or not a door draws a room. That is why the
registry exists, and why `check:huds` asserts the shell mentions neither `three` nor
`@leumas/cinematic`.

### The route table — `@leumas/domain-kit/routes`

`<DomainRoutes manifest surfaces landings gate index basePath …>` derives, from one manifest: a route
per routable sub-item (a `/*` splat when it declares `tabs`), the contextual aside, the breadcrumb
trail with sibling menus, the ⌘K entries, the Ctrl+←/→ domain ring, the hover-prefetch registry, the
route error boundary, and the `d/:domainId` door → index → hub ladder. `gate` is the ONE injection
point for a product's capability/entitlement/install rules (Studio passes `SurfaceGate`).

**A door may warm its own data.** The route data warm normally asks a page's chunk for a `read`
export, which needs a STATIC cache key — one module, one URL. The generic overview breaks that: one
component answers thirty-one domain doors and its data is each domain's own entity kinds, so a static
key would warm domain A and then serve A's room to B. Such a door declares `Component.warmRoute(api,
ctx)` instead — it owns its caching and is handed the RESOLVED DOMAIN on `ctx.domain`, and it wins
over the module warm. [critical] A door component that is a plain wrapper must also forward
`Component.preload`, or it is registered in NO warm registry and hover, ⌘K, the idle sweep and the
Ctrl+←/→ ring all silently no-op for it — `prefetchRoute` resolves quietly for an href it does not know.

Also exports `DomainLanding`, `DomainHub`, `domainsToRoutes`, `blockingFeature`, `useCrumbTitle`.

### The chrome — `@leumas/ui`

`AdminSurface` (one per page, never nested) · `CanvasHud` + `CanvasHudBar` / `CanvasHudStat` /
`CanvasHudCarousel` / `CanvasHudDial` / `CanvasHudPanel` · `Glyph`, `Button`, `StatusPill`, `Notice`,
`EmptyState`, `Search`, `ScrollNav` · `useStatus`.

### Routing helpers — `@leumas/features`

`RouteTabs` + `activeTabId(pathname, base, tabs)` — a tab strip whose tabs are real URLs. Use it for
a page's sections. A domain DOOR should prefer the deck (see below).

## The nine surface types, and the composition each one owes

**A page is exactly one type, and it says which.** Put `surface-type: <id>` in the file's header
comment. `pnpm check:surfaces` reads it and holds the page to that type's shape; without it the page
counts against the `untypedSurfaces` ratchet.

The declaration is not bureaucracy — it is what lets the rules be strict instead of approximate. A
guide page that never fetches and a collection page that forgot its skeleton look identical to a
regex, and only the author can say which is which. Which surface is currently which, and what each
one is still missing, is generated: **`reference/surfaces.md`**.

| id | Type | The question it answers | Loading | Numbers |
|---|---|---|---|---|
| `a1` | Collection | "what have I got?" — many rows, narrowing, a create action | `cards`/`table`/`rows` | shell `kpis` |
| `a2` | Gallery | "what have I made?" — rows with a preview | `gallery` | shell `kpis` |
| `a3` | Catalog | "what does this instance know how to do?" — a read-only registry | `cards` | none |
| `b` | Workbench | "pick one and change it without leaving" | `split` | shell `kpis` |
| `c` | Report | "what happened / what state is this in?" — no create | `cards`/`table` | a `StatTile` grid is legal here |
| `d` | Record editor | reached by a route param; saves; has dirty state | `form` | none |
| `e` | Canvas | the body **is** the subject — a graph, a globe, an iframe | bespoke | shell `kpis` only |
| `f` | Form | "do this one thing", one submit | `form` | none |
| `g` | Guide | prose + `CodeBlock`, **no fetch at all** | none | none |

**Every type opens the same way.** `header` is never passed — `row` is the default and paints neither
`title` nor `subtitle`, because the breadcrumb one line above already ends in the page's name. Keep
passing both anyway: the crumb tail, the document title and ⌘K all read them.

```jsx
<AdminSurface
  eyebrow="Dynamic" title="Variables"
  subtitle="Set a value once here and read it from a rule, a workflow or a webhook."
  status={status} resetKey={BASE}
  tabs={<RouteTabs base={BASE} tabs={TABS} />}
  /* …the type's own props… */
>
```

Then, by type:

- **`a1` / `a2`** — `kpis` (totals, never a tally of the rows on screen) · `actions` with a
  `RefreshButton` and the create control · `loading` + `skeleton` matching the renderer · `empty`
  gated on *loaded*, not on *no error* · `fill` · `toolbar={<FilterBar …/>}` · a body ending in ONE
  container, with `<Pager sticky>` under it. `a2` passes no `empty` — `Gallery` owns it, because the
  create card has to survive an empty result.
- **`a3`** — three lines. `loading` + `skeleton="cards"`, and a body that is one `<CatalogBrowser>`.
  No `FilterBar`, no `Pager`, no `empty`: that component already owns the search, the facets, the
  count, the skeleton and the empty state. More tabs should be `a3` than currently are.
- **`b`** — `.st-split--inspector` (list `1fr`, detail `minmax(320px, 420px)`, a bottom sheet under
  900px) with an `InspectorDrawer` whose `footer` holds the save row. **`Split cols={2}` is retired
  for this shape**: it pins the LIST at 320px and gives the detail `1fr`, which is backwards and is
  the "so much white space" complaint. `Workbench cols="rail-list-detail"` only for a genuine third
  facet column over thousands of rows.
- **`d`** — `crumbs` · `title={record?.name}` · a save control and a dirty notice. **No `empty`** — a
  record that is not there is a 404 notice, not an empty state.
- **`e`** — `fill` **and** `bleed`, one child, controls in `actions` or floating over the stage.
- **`f`** — `skeleton="form"`; `SchemaForm` + `FormActions` in a Card. Over ~7 fields or more than one
  decision point, use `FormWizard` instead.
- **`g`** — the only type that may pass no `loading`, no `skeleton` and no `empty`.

### One `AdminSurface` per type

A page whose tabs are different types is **two pages**. Hoist the `tabs` element into a `const` and
let each tab render its own surface — `dynamic/DynamicInputs.jsx` is the pattern. For three or more
tabs sharing one live fetch, keep one surface and drive it from a per-page literal:

```js
const TAB_SPEC = {
  '':      { type: 'a1', skeleton: 'table', fill: true },
  builder: { type: 'd',  skeleton: 'form' },
  feed:    { type: 'c',  skeleton: 'rows', fill: true },
};
const t = TAB_SPEC[tabId] ?? TAB_SPEC[''];
```

### [critical] `fill` makes the body's LAST DOM CHILD the only scroller

`.st-surface.st-fill > :last-child` is `overflow: auto`. A React fragment produces no DOM, so a tab
body of `<>…<span>Run limit: 30000ms.</span></>` hands the scrolling job to a one-line span: the page
cannot scroll and everything past the fold is unreachable. It builds, it type-checks, every other
guard is green, and on a short list it looks perfect. **Give each tab body one container.**
`check:surfaces` S6 is the only thing that can see this, and it found a live case.

### Nav data — `@leumas/config-registry/nav`

`tabHref(sub, tab)` · `tabsToNavItems(sub)` (incl. a tab's own sub-routes) · `navFromItems(items, map)`
— turns ANY list into the `{domains:[…]}` shape every nav view reads · `navFromEntities` ·
`navToCommands` · `resolveNav`.

### The room's cast — `@leumas/entity-kit`

`BODIES` (39 silhouettes, a **closed** list — no new geometry), `IDLE_MODES`, `APPROACH_MODES`,
`REACTION_VERBS`, `styleFor`, `reactionsFor`. A domain either hard-codes its silhouettes in its
populator or reads the server's entity spec (Hosting does the latter).

## The spec: `<domain>/hud/overview.js`

PURE DATA — no React, no JSX. `check:huds` imports it in bare Node and `pnpm diagrams` reads it; both
break the moment it grows an import that needs a bundler.

```js
export const X_OVERVIEW = {
  id: 'protocols',                 // must equal the folder; the guard checks
  base: '/admin/protocols',
  hudLabel: 'Protocols console',   // the HUD region's accessible name
  deckLabel: 'Transports',         // the carousel's accessible name
  surface: { title: 'Protocols' }, // AdminSurface's static props, spread verbatim
  select: 'navigate',              // 'navigate' | 'toggle' | (a function, passed by the console)
  footer: 'pitch',                 // 'pitch' | 'none' | 'custom'
  verb: 'Open console',            // what the bar's PRIMARY act is called — not whether one exists
  downgrade: 'page',               // 'page' (five domains) | 'stage' (Devices only)
  room: { per: 14, max: 22 },      // stars per unit of the domain's one honest scalar
};
```

Also conventional in that file: `X_COPY = { noun, nounPlural, hint, verb, title }` — this domain's
vocabulary for the three nav views that would otherwise print "▸ DEPLOY — OPEN MY-BLOG" over a list of
your own websites.

## The bar at the bottom, and the funnel it drives

**Every door ends in one thin row**, drawn by the shell for `footer: 'pitch'`: what is centred, this
door's own `extra` (a search box, a composer), and up to three acts. Icons at rest; the words appear on
hover or on tab — inside the bar, never in a tooltip, because `CanvasHud` fullscreens the HUD root and a
body-level tip is unpainted there.

**A control exists because the card can be acted on, not because the spec named a `verb`.** That is the
whole of `actsFor`, and it is what ended the dead click: a card with no address inside a door that only
navigates now shows no primary at all, with its reason where the button was.

| the card is | its acts |
|---|---|
| a page with sub-tabs | **Inside** (drills) · **Open** |
| a leaf page | **Open** |
| a sub-tab | **↑ its page** · **Open** |
| the way back | **↑ where it goes** |
| locked | none — the lock's reason instead |

**Drilling deals that page's sub-tabs**, each with its own acts, and the level is `?in=<pageId>` — so
Back leaves it, it can be linked, and a reload cannot strand you. A door opts in by passing `sections`;
one that does not is byte-identical to a door written before the funnel existed, which is what keeps
the nine control-plane doors (flat lists, nothing behind them) unchanged.

**A verb a domain cannot derive is DECLARED**, in `_shared/model/navActs.js`, keyed exactly as
`navEntities.js` is (`<domainId>/<subItemId>[:<tabId>]`). Not in the manifest and not in a
`model/tabs.js`: `normalizeTab` is a whitelist and silently drops anything it does not know, so an
`acts:` field written there would vanish with no error. An act is a real destination — `check:huds`
matches every one against the routes this Studio actually has — or one of `ACT_VERBS`.

## What lives in a domain's room — the three rungs

The room is not decoration. It is the domain's own things, and the same read fills the card counts on
its rail and its deck. There are exactly three answers, and picking the right one is the first
decision when you add a domain:

**① The domain owns entity KINDS.** One row in `shared/apps/domain-entities.js` and one `kinds:` line
in the product's overview table. **No new file, no change to `app.js`, no second registration path.**
This is the rung to aim for, and the recipe is below.

**② No kinds, but its pages declare `tabs`.** The tabs become standing pillars — one level below the
deck, so the room is still about this domain rather than a copy of the cards in front of it.

**③ Neither: no room at all.** No `load`, so no WebGL is fetched and the stage keeps its wash. That is
the honest answer for a domain with nothing of the operator's in it — `forge` persists only to
localStorage, `dev` is live views of the machine — and it is a **decision, not a gap**. Say so in the
table rather than inventing a thing that does not exist.

### A populator is for a BESPOKE door only

`(api) => Promise<inhabitant[]>`, and **it must never reject** — one dead endpoint costs a species,
never the room. Hold it with a `catch` per read, with `Promise.allSettled`, or by doing no async work
at all (Devices' is curried over rows the console already has). `check:huds` enforces all three.

An inhabitant is `{ id, kind, label, sub, href, color, body, idle, approach, scale?, emissive?,
opacity?, model? }`. `color` must be a hex literal — three cannot parse a CSS token.

**Do not write one for a generic door.** A table row is the same room for one line, and a hand-written
populator is a second description of the domain that will drift from the first.

**Do not call `registerPopulator()`** for a domain room either. That seam adds a species to the
*shared* `/dashboard` room, which already has sites, bots and apps in it.

## Giving a domain real things

> **Writing the entity itself — the spec, its body, its motion, the `face` seed, the silent
> failures — is the `leumas-entities` skill.** What follows is only the half a DOMAIN owns:
> where the declaration goes, and how the door claims it.

One table, two doors out of it — `shared/apps/domain-entities.js`:

| the domain is | declare in | reaches the API via |
|---|---|---|
| a store app (`media`, `wardrobe`, …) | `DOMAIN_ENTITIES` | `defineDomainApp` → `defineLeumasApp` → `FIRST_PARTY_APPS` |
| a core section (`commerce`, `lmx`, …) | `CORE_ROOMS` | compiled there, spread into `app.js`'s `specs:` |
| a HAND-WRITTEN app (`graphs`, `prospecting`) | **its own `index.js`** | `domainAppsExcept` filters those two out, so a table entry compiles into **nothing** |

Then name the kinds in the product's overview table (`kinds: ['commerce.product']`), and the card
counts become free — `{kind}` reads off the room fetch that already happened, instead of a
`?limit=1` query per card per visit.

**An app can declare its own door.** `defineLeumasApp` takes an `overview` bag (`kinds`, `counts`,
`verb`, `copy`, `room`, `empty`) — see `@leumas/app-kit`'s `defineOverview`. Studio merges it *under*
its own table, so a product's decision about one of its doors always wins, and a bespoke-landing
domain takes nothing. First-party only: a third-party listing's nav is rebuilt server-side into the
shared `plugins` domain, so an `overview` there would be last-writer-wins across every plugin.

### The four traps, each of which has already cost a real bug

1. **`trust: 'first-party'` whenever the source names a shared collection.** The default is
   `sandboxed` — correctly, fail-closed — and it rewrites `products` to `app_commerce_products`,
   which does not exist. The read then reports `ok` with zero rows, which is indistinguishable from
   an account that owns nothing.
2. **The collection is the one the surface actually WRITES to**, never the one the domain ought to
   have. `reasoning_graphs`, not `graphs`. Verify with
   `node ops/infra/scripts/audit-platform-collections.mjs` before writing it down.
3. [critical] **`scope: 'user'` drops every row with no `owner`.** `canRead` is `public === true || owner ===
   you`. Several collections stamp `userId` instead, which `canRead` does not read — those rooms are
   empty on **every** account, forever. Read the create path before choosing a scope.
4. **`map` cannot filter, join, do arithmetic, or take an array's length** — by design, because that
   is exactly the power a sandboxed plugin must not have. Two producers sharing one collection needs
   a `{route}` source and a resolver in `app.js`; `count` needs a real scalar column.

### Two guards, pointing opposite ways

`check:huds` asserts **declared ⇒ real**: every kind a domain names exists in a compiled spec. That
catches a typo, which is otherwise silent forever — the server matches an unknown kind against
nothing and answers a perfectly valid empty room.

It also asserts **real ⇒ declared**: every registered kind is claimed by some domain, or named in
`UNCLAIMED_OK` with a sentence saying why. That is the direction that was missing, and `media.station`
and `devices.device` were both registered, both drawn on `/dashboard`, and asked for by no door at all.

## Recipes

### Add a sub-tab to an existing page
1. Add `{ id, label, icon, blurb }` to that domain's `model/tabs.js` — the one list.
2. Add a `<Route path="<id>" …>` to the page's nested `<Routes>`, and a `lazy()` import for its panel.
3. Nothing else. The manifest already maps that list; the aside, ⌘K, the crumb and the deck follow.

### Add a sub-sub route (a tab that owns children)
1. Give the tab its own `tabs: [{ id, label, icon }]` in `model/tabs.js`.
2. Make the parent route a splat (`path="<tab>/*"`) and render the children inside the panel.
3. Forward it in the manifest: `tabs: X_TABS.map((t) => ({ …, tabs: t.tabs }))`. Without that field the
   normalizer's whitelist drops it and the aside lists the tab and stops. (Devices' Compute → Jobs.)

### Add a whole domain
1. `nav.manifest.js`: a domain entry (`...app('<id>')` if it is installable) with its sub-items.
2. `<domain>/model/tabs.js` if the page has tabs; import it into the manifest, never restate it.
3. `<domain>/hud/overview.js` — the spec above.
4. `<domain>/hud/<x>Populators.js` — the room's cast.
5. `<domain>/hud/<X>Hud.jsx` — render `DomainOverviewHud`, gate with `useCanvasCapable()` in the
   default export (or set `downgrade: 'stage'` and skip the gate entirely — then only the room
   degrades and the console over it stays), wrap in `DomainViewHost` for the other view modes.
6. Register the surface, add the id to `DOMAIN_LANDINGS` in `AdminRoutes.jsx`, and add it to the lists
   in `ops/infra/scripts/check-domain-huds.mjs` and `ops/tools/domain-diagrams/index.mjs`.

Steps 2–5 are mechanical, so there is a generator for them:

```sh
node ops/tools/domain-scaffold/index.mjs --product admin --domain fleet   # or --all
```

It reads the product's manifest and route file, and writes `model/tabs.js`, `hud/overview.js`,
`hud/<x>Populators.js`, `hud/<X>Hud.jsx` and `pages/<X>Hub.jsx`. It never touches the manifest or the
route table (steps 1 and 6 carry real decisions) and never overwrites an existing file without
`--force`. Only `--product admin` is wired today; add a `PRODUCTS` entry to teach it another.

### A domain in the CONTROL PLANE (`products/leumas-admin`)

Same kit, three differences worth knowing before you copy a Studio door:

- the route base is `/`, not `/admin` — `<DomainRoutes basePath="/">`;
- a domain is ONE splat sub-item (`<Domain>Hub`) carrying its tabs, so today's page URLs survive
  becoming tabs, and the door lives at `/d/<id>` via `LANDINGS`;
- `downgrade: 'stage'` everywhere and no `useCanvasCapable()` in any door: there is no second card
  landing per domain to maintain, so only the room degrades.

**Leviathan is off in that console on purpose** (`assistant={false}`). Its page tools drive the page
they are on, and those pages revoke customer licences.

## The invariants that have already cost bugs

- **ONE `AdminSurface` per page.** The shell renders it; a console must not wrap it in another.
- **The capability gate lives in the domain's DEFAULT EXPORT**, not inside the shell — otherwise every
  domain's data hooks mount and fire on a device that was never going to draw the room.
- **MEMOISE `items`.** A fresh array identity remounts every card and restarts every entrance.
- **Never let `reloadKey` follow a search box.** It re-reads the room's cast; bound to a query it tears
  down and rebuilds the WebGL context on every keystroke, behind a console that looks perfect.
- **The layering contract**: `.lms-chud__stage` receives everything; every chrome layer above it is
  `pointer-events:none` with live children. A full-bleed transparent `pointer-events:auto` element
  over the stage kills all interaction and looks identical in a screenshot.
- **A domain's internals are internal.** Cross-domain imports must be named in that domain's
  `public.js`, and consumers still import the deep path (a barrel hop would drag the whole barrel into
  the chunk).
- **`three` only through a dynamic import**, or it lands on a boot path.

## Prove it

`pnpm check:huds` — every domain has an overview, it is the shared one, no console draws its own
surface/HUD/host, the gate is written once, every populator can't reject, and the renderer is only
ever dynamically imported. `--self-test` proves its matchers still reject bad input.

Then, per what you touched: `check:nav` (every domain has a reachable door) · `check:routes` ·
`check:admin` (surfaces render through `AdminSurface`; ratchets inline styles + CSS namespaces) ·
`check:ui-kit` · `check:devices` · `check:frontend` · `check:theme` · `check:props-coverage`.
`pnpm diagrams` regenerates `ops/reference/diagrams/` — run it after changing a manifest or a spec.

See also: `leumas-capabilities` (what already exists, and its `placement.md` lane for where code goes),
`leumas-guards` (which guard to run), `build-leumas-plugin` (a domain that ships in the app store).


---
Source: .claude/skills/leumas-domains/SKILL.md
Canonical: https://docs.leumas.tech/p/skills/leumas-domains
