{
  "schema": "leumas.docs.page/1",
  "id": "skill:leumas-domains",
  "slug": "skills/leumas-domains",
  "kind": "tools",
  "bucket": "skill",
  "title": "leumas-domains",
  "name": "Leumas Domains",
  "eyebrow": null,
  "chip": null,
  "summary": "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.",
  "keywords": [
    "leumas-domains",
    "sub-routes",
    "domain-entities",
    "invariants cost bugs",
    "control-plane",
    "room",
    "shape top bottom",
    "domains"
  ],
  "audience": "both",
  "funnel": {
    "product": null,
    "cta": null
  },
  "body": "\n# Leumas Studio domains\n\nA **domain** is one section of Studio — Hosting, AI, Devices, Protocols, Information, Dynamic, Apps.\nIt is not a folder convention; it is a contract that seven separate systems read. Get the contract\nright and the sidebar, the breadcrumb trail, the ⌘K palette, the route table, the install gate, the\n3D room and the generated diagrams all follow with no extra code. Get it wrong and each of those\nfails silently and separately.\n\n## Domains, sub-tabs and entities — the three levels, and the one idea\n\n**A DOMAIN is a section. Its SUB-TABS are the real URLs inside a page. Its ENTITIES are the\noperator's own things** — the rows its room draws and the numbers its cards carry. Three different\nquestions, and each has exactly one home:\n\n| question | home |\n|---|---|\n| what pages exist, and where do they live | `nav.manifest.js` |\n| what sections a page has | `<domain>/model/tabs.js` |\n| what THINGS this domain owns | `shared/apps/domain-entities.js` |\n\nA domain that restates any of them somewhere else has two answers that will disagree, and both halves\nof that failure are silent.\n\n**Every domain's front door is its Overview**, at `/admin/d/<id>` — the room behind glass, a portal\nrail of its sections, a deck of its things, and the seven view modes over the same list. Each domain\nis its own small admin portal, and the same entities also populate the user's `/dashboard`, because\n**it is the same read**.\n\nThe deck follows one rule, stated once in `deckFor.js`: **a domain deals its THINGS when it has them,\nelse its sections.** Picking a thing makes the surface about that thing (`select: 'toggle'`); picking\na section goes there (`'navigate'`). Whichever it is, the rail beside it is always the domain's pages,\nso navigation never costs the subject its stage.\n\n## The shape, top to bottom\n\n```\n/admin/d/<id>            THE DOOR          → an Overview HUD (a room + a deck + a readout rail)\n  /admin/<id>            A PAGE            → a routed surface, one per manifest sub-item\n    /admin/<id>/<tab>    A SUB-TAB         → a REAL route, nested <Routes> inside that page\n      …/<tab>/<child>    A SUB-SUB ROUTE   → a real route again, nested one level deeper\n```\n\n**Every level is a URL.** That is the whole design and the thing to protect: a tab you can deep-link,\nrefresh, put in a breadcrumb, reach from ⌘K, and send someone. A tab held in `useState` looks the same\non screen and is none of those things.\n\n**Why tabs and not more sub-items.** A separate sub-item mounts its own shell, so a running scan, a\nlive poll, a WebGL context or an unsent request is thrown away when you change tab. A splat route\nkeeps the shell — and everything alive in it — across every tab. That is why Devices and Information\nare each one tabbed page rather than a page per instrument.\n\nFold pages together for the SHELL, never to buy an aside heading — headings work on flat multi-page\ndomains too. See **The aside** below.\n\n## The seven systems a domain feeds, from one declaration\n\n`products/leumas-studio/src/admin/nav.manifest.js` is the single source. One entry gives you:\n\n| It feeds | How |\n|---|---|\n| 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>` |\n| 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 |\n| the breadcrumbs | `Studio › <domain> › <page> › <segment>`, every crumb carrying its siblings |\n| ⌘K | `navToCommands` emits the domain, every page and every tab — tabs are routes, so the palette can jump straight into one |\n| the deck / front door | the domain tile, its pillar, its art |\n| the install gate | `...app('<id>')` makes a domain installable; `feature:` gates the content instead of vanishing the tile |\n| the diagrams | `pnpm diagrams` reads this file + each `overview.js` → `ops/reference/diagrams/` |\n\n**Never restate a tab list.** `nav.manifest.js` imports `DEVICE_TABS` / `INFORMATION_TABS` from the\ndomain's own `model/tabs.js`. A list written twice drifts on the first rename, and both halves of the\nfailure are silent: a manifest tab with no panel renders an empty stage; a panel with no manifest tab\nhas no route.\n\n## The aside — every domain's links, in named families\n\nSeven domains group their aside rows under headings: **AI · Hosting · Devices · Dynamic · Information ·\nProtocols · Apps**. Dashboard and Studio are deliberately not domains and are left alone.\n\n**Grouping is a flat list plus adjacency.** `NavMenu` prints a heading wherever `item.group` differs\nfrom the row above it. There is no nested structure, no sort step, and no grouping component — do not\nadd one. `shared/packages/ui/src/NavMenu.jsx` draws it; `shared/packages/config-registry/src/nav.js`\ncarries the two fields.\n\nDeclare `group` + `groupLabel` where the row is declared — on a **tab** in `<domain>/model/tabs.js`\nfor a one-page domain, or on a **sub-item** in `nav.manifest.js` for a flat multi-page one. Both reach\nthe aside; neither needs a URL to move.\n\n**Keyboard.** `Ctrl+↑` / `Ctrl+↓` step tabs AND sub-tabs, `Ctrl+←` / `Ctrl+→` step domains in sidebar\norder. Both are remappable in Settings → Keyboard Shortcuts.\n\n**A step SLIDES; it does not cut.** A ring calls `signalNavIntent({ axis, delta })` from\n`@leumas/entrance/routing` immediately before it navigates, and the route transition slides the page\nregion — chrome held still — the way the key pointed: horizontally for the domain ring and for a tab\nstrip (a row on screen), vertically for the aside (a column). Without that signal the transition only\nknows the two paths, and a sibling domain is at the same depth as the one you left, so left and right\nanimated identically. Both rings also warm the neighbours either side of where you land, on idle, so\nthe FIRST press is as fast as the ones after it. Nothing per-domain is wired: a domain installed\ntomorrow inherits all of it.\n\nOne gesture covers both levels because the strip **hands off at its edge** rather than wrapping\n(`stepTabs` in `shortcuts/navRings.js`). While the page's own strip has somewhere left to go the\npress is a sub-tab move; when it runs out, the press falls through to the aside and becomes a jump\nto the next section. Two registries feed it:\n\n1. **the tab ring** — a strip the page renders. `RouteTabs` and `HudTabs` register themselves.\n2. **the section ring** — the domain's ASIDE, in the order it is drawn. `DomainRoutes` registers the\n   published `contextNav` for **every** domain, so this needs no per-domain wiring and cannot be\n   lost by a door choosing not to draw a strip (Devices did exactly that).\n\n[critical] Do NOT add `useTabRing` to a domain hub to \"give it\" the gesture — it already has it, and a second\nring at the same depth is a coin flip. The deck row is never a destination: leaving the domain is\n`Ctrl+←/→`.\n\n### The rules\n\n1. **Order IS the grouping.** A family's members must be contiguous. One filed out of order re-prints\n   its family's heading instead of joining it.\n2. **Repeat `groupLabel` on every member.** It falls back to `group`, so the first member alone\n   *looks* sufficient — until an edition or feature flag removes exactly that row and the family reads\n   as its raw id (`tools`, not `Its tools`). Nothing fails and no guard notices.\n3. **2–6 families per domain, 2–7 rows each.** Never a heading over one row — an indent that says\n   nothing. Count what a build actually RENDERS, not what is declared: a `deferred` sub-item is\n   filtered out before the aside sees it, which is how Hosting's `Traffic` family became a heading over\n   a single row (folded into `Serve & watch`).\n   *Three known exceptions, all pre-dating this rule:* AI's `How it thinks` and `What it did`, and\n   Devices' `Everything`. Do not add a fourth.\n4. **Labels are plain language about the domain's job** — `What it makes`, `Your websites`,\n   `Outside this app`. Sentence case, no trailing punctuation. `group` is a stable id a tint or palette\n   can key off; `groupLabel` is the words. Two fields, so rewording a heading never renames an id.\n5. **Every row is in a family.** No ungrouped stragglers above or below the headings.\n6. **A runtime-contributed row carries a `group` too.** `spliceNavRows` places it beside that family\n   when a declared row shares it, and appends it under its own heading when nothing does — which is\n   how Protocols keeps its four declared rows put and still labels the installed `comms-*` packs\n   `Your transports`.\n7. **A nested row inherits its parent's family** — `tabsToNavItems` and `sectionRows` do this for you.\n   A group-less row in the middle of a family is a hole in it, and the row after the expansion prints\n   the heading a second time.\n\n### The whitelist trap [critical]\n\nA field must survive **both** the manifest's own `.map((t) => ({ … }))` literal **and** `normalizeTab`\n/ `normalizeSubItem` in `nav.js`. This exact failure shipped once: Devices declared `group`, the\nnormalizer carried it, `tabsToNavItems` carried it — and the aside still drew twenty flat rows,\nbecause the `.map()` in `nav.manifest.js` named five fields and these were not among them. Every layer\nlooked correctly wired. `pnpm check:nav` rule (g) diffs the authored node against the normalized one\nfor precisely this.\n\n## The components a domain may use\n\nEverything below already exists. Building any of it again is the expensive mistake here, because the\nnew copy never looks wrong.\n\n### The door — `@leumas/domain-kit`\n\n**It is a PACKAGE, not a folder in Studio, and TWO products render it** — Leumas Studio and Leumas\nAdmin (the control plane). It left `admin/_shared/hud/` the moment a second console needed a domain\ndoor. A fix here reaches both; a copy of it in one product is the drift `check:huds` exists to stop,\nand that guard asserts every door imports the shell `from '@leumas/domain-kit'`.\n\n| Import | What it is |\n|---|---|\n| **`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`**. |\n| **`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. |\n| **`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. |\n| **`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. |\n| **`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. |\n| **`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. |\n| **`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. |\n| **`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. |\n| **`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. |\n| **`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**. |\n| `@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`. |\n| `@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. |\n| `@leumas/domain-kit/room/backdrop` | `DomainWorldBackdrop` itself, for a surface drawing its own stage. Import it lazily. |\n| `@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. |\n\n**A `lazy(() => import(...))` inside the shell is NOT free** — it is an unconditional module edge, so\nthe renderer chunk is emitted into every consumer whether or not a door draws a room. That is why the\nregistry exists, and why `check:huds` asserts the shell mentions neither `three` nor\n`@leumas/cinematic`.\n\n### The route table — `@leumas/domain-kit/routes`\n\n`<DomainRoutes manifest surfaces landings gate index basePath …>` derives, from one manifest: a route\nper routable sub-item (a `/*` splat when it declares `tabs`), the contextual aside, the breadcrumb\ntrail with sibling menus, the ⌘K entries, the Ctrl+←/→ domain ring, the hover-prefetch registry, the\nroute error boundary, and the `d/:domainId` door → index → hub ladder. `gate` is the ONE injection\npoint for a product's capability/entitlement/install rules (Studio passes `SurfaceGate`).\n\n**A door may warm its own data.** The route data warm normally asks a page's chunk for a `read`\nexport, which needs a STATIC cache key — one module, one URL. The generic overview breaks that: one\ncomponent answers thirty-one domain doors and its data is each domain's own entity kinds, so a static\nkey would warm domain A and then serve A's room to B. Such a door declares `Component.warmRoute(api,\nctx)` instead — it owns its caching and is handed the RESOLVED DOMAIN on `ctx.domain`, and it wins\nover the module warm. [critical] A door component that is a plain wrapper must also forward\n`Component.preload`, or it is registered in NO warm registry and hover, ⌘K, the idle sweep and the\nCtrl+←/→ ring all silently no-op for it — `prefetchRoute` resolves quietly for an href it does not know.\n\nAlso exports `DomainLanding`, `DomainHub`, `domainsToRoutes`, `blockingFeature`, `useCrumbTitle`.\n\n### The chrome — `@leumas/ui`\n\n`AdminSurface` (one per page, never nested) · `CanvasHud` + `CanvasHudBar` / `CanvasHudStat` /\n`CanvasHudCarousel` / `CanvasHudDial` / `CanvasHudPanel` · `Glyph`, `Button`, `StatusPill`, `Notice`,\n`EmptyState`, `Search`, `ScrollNav` · `useStatus`.\n\n### Routing helpers — `@leumas/features`\n\n`RouteTabs` + `activeTabId(pathname, base, tabs)` — a tab strip whose tabs are real URLs. Use it for\na page's sections. A domain DOOR should prefer the deck (see below).\n\n## The nine surface types, and the composition each one owes\n\n**A page is exactly one type, and it says which.** Put `surface-type: <id>` in the file's header\ncomment. `pnpm check:surfaces` reads it and holds the page to that type's shape; without it the page\ncounts against the `untypedSurfaces` ratchet.\n\nThe declaration is not bureaucracy — it is what lets the rules be strict instead of approximate. A\nguide page that never fetches and a collection page that forgot its skeleton look identical to a\nregex, and only the author can say which is which. Which surface is currently which, and what each\none is still missing, is generated: **`reference/surfaces.md`**.\n\n| id | Type | The question it answers | Loading | Numbers |\n|---|---|---|---|---|\n| `a1` | Collection | \"what have I got?\" — many rows, narrowing, a create action | `cards`/`table`/`rows` | shell `kpis` |\n| `a2` | Gallery | \"what have I made?\" — rows with a preview | `gallery` | shell `kpis` |\n| `a3` | Catalog | \"what does this instance know how to do?\" — a read-only registry | `cards` | none |\n| `b` | Workbench | \"pick one and change it without leaving\" | `split` | shell `kpis` |\n| `c` | Report | \"what happened / what state is this in?\" — no create | `cards`/`table` | a `StatTile` grid is legal here |\n| `d` | Record editor | reached by a route param; saves; has dirty state | `form` | none |\n| `e` | Canvas | the body **is** the subject — a graph, a globe, an iframe | bespoke | shell `kpis` only |\n| `f` | Form | \"do this one thing\", one submit | `form` | none |\n| `g` | Guide | prose + `CodeBlock`, **no fetch at all** | none | none |\n\n**Every type opens the same way.** `header` is never passed — `row` is the default and paints neither\n`title` nor `subtitle`, because the breadcrumb one line above already ends in the page's name. Keep\npassing both anyway: the crumb tail, the document title and ⌘K all read them.\n\n```jsx\n<AdminSurface\n  eyebrow=\"Dynamic\" title=\"Variables\"\n  subtitle=\"Set a value once here and read it from a rule, a workflow or a webhook.\"\n  status={status} resetKey={BASE}\n  tabs={<RouteTabs base={BASE} tabs={TABS} />}\n  /* …the type's own props… */\n>\n```\n\nThen, by type:\n\n- **`a1` / `a2`** — `kpis` (totals, never a tally of the rows on screen) · `actions` with a\n  `RefreshButton` and the create control · `loading` + `skeleton` matching the renderer · `empty`\n  gated on *loaded*, not on *no error* · `fill` · `toolbar={<FilterBar …/>}` · a body ending in ONE\n  container, with `<Pager sticky>` under it. `a2` passes no `empty` — `Gallery` owns it, because the\n  create card has to survive an empty result.\n- **`a3`** — three lines. `loading` + `skeleton=\"cards\"`, and a body that is one `<CatalogBrowser>`.\n  No `FilterBar`, no `Pager`, no `empty`: that component already owns the search, the facets, the\n  count, the skeleton and the empty state. More tabs should be `a3` than currently are.\n- **`b`** — `.st-split--inspector` (list `1fr`, detail `minmax(320px, 420px)`, a bottom sheet under\n  900px) with an `InspectorDrawer` whose `footer` holds the save row. **`Split cols={2}` is retired\n  for this shape**: it pins the LIST at 320px and gives the detail `1fr`, which is backwards and is\n  the \"so much white space\" complaint. `Workbench cols=\"rail-list-detail\"` only for a genuine third\n  facet column over thousands of rows.\n- **`d`** — `crumbs` · `title={record?.name}` · a save control and a dirty notice. **No `empty`** — a\n  record that is not there is a 404 notice, not an empty state.\n- **`e`** — `fill` **and** `bleed`, one child, controls in `actions` or floating over the stage.\n- **`f`** — `skeleton=\"form\"`; `SchemaForm` + `FormActions` in a Card. Over ~7 fields or more than one\n  decision point, use `FormWizard` instead.\n- **`g`** — the only type that may pass no `loading`, no `skeleton` and no `empty`.\n\n### One `AdminSurface` per type\n\nA page whose tabs are different types is **two pages**. Hoist the `tabs` element into a `const` and\nlet each tab render its own surface — `dynamic/DynamicInputs.jsx` is the pattern. For three or more\ntabs sharing one live fetch, keep one surface and drive it from a per-page literal:\n\n```js\nconst TAB_SPEC = {\n  '':      { type: 'a1', skeleton: 'table', fill: true },\n  builder: { type: 'd',  skeleton: 'form' },\n  feed:    { type: 'c',  skeleton: 'rows', fill: true },\n};\nconst t = TAB_SPEC[tabId] ?? TAB_SPEC[''];\n```\n\n### [critical] `fill` makes the body's LAST DOM CHILD the only scroller\n\n`.st-surface.st-fill > :last-child` is `overflow: auto`. A React fragment produces no DOM, so a tab\nbody of `<>…<span>Run limit: 30000ms.</span></>` hands the scrolling job to a one-line span: the page\ncannot scroll and everything past the fold is unreachable. It builds, it type-checks, every other\nguard is green, and on a short list it looks perfect. **Give each tab body one container.**\n`check:surfaces` S6 is the only thing that can see this, and it found a live case.\n\n### Nav data — `@leumas/config-registry/nav`\n\n`tabHref(sub, tab)` · `tabsToNavItems(sub)` (incl. a tab's own sub-routes) · `navFromItems(items, map)`\n— turns ANY list into the `{domains:[…]}` shape every nav view reads · `navFromEntities` ·\n`navToCommands` · `resolveNav`.\n\n### The room's cast — `@leumas/entity-kit`\n\n`BODIES` (39 silhouettes, a **closed** list — no new geometry), `IDLE_MODES`, `APPROACH_MODES`,\n`REACTION_VERBS`, `styleFor`, `reactionsFor`. A domain either hard-codes its silhouettes in its\npopulator or reads the server's entity spec (Hosting does the latter).\n\n## The spec: `<domain>/hud/overview.js`\n\nPURE DATA — no React, no JSX. `check:huds` imports it in bare Node and `pnpm diagrams` reads it; both\nbreak the moment it grows an import that needs a bundler.\n\n```js\nexport const X_OVERVIEW = {\n  id: 'protocols',                 // must equal the folder; the guard checks\n  base: '/admin/protocols',\n  hudLabel: 'Protocols console',   // the HUD region's accessible name\n  deckLabel: 'Transports',         // the carousel's accessible name\n  surface: { title: 'Protocols' }, // AdminSurface's static props, spread verbatim\n  select: 'navigate',              // 'navigate' | 'toggle' | (a function, passed by the console)\n  footer: 'pitch',                 // 'pitch' | 'none' | 'custom'\n  verb: 'Open console',            // what the bar's PRIMARY act is called — not whether one exists\n  downgrade: 'page',               // 'page' (five domains) | 'stage' (Devices only)\n  room: { per: 14, max: 22 },      // stars per unit of the domain's one honest scalar\n};\n```\n\nAlso conventional in that file: `X_COPY = { noun, nounPlural, hint, verb, title }` — this domain's\nvocabulary for the three nav views that would otherwise print \"▸ DEPLOY — OPEN MY-BLOG\" over a list of\nyour own websites.\n\n## The bar at the bottom, and the funnel it drives\n\n**Every door ends in one thin row**, drawn by the shell for `footer: 'pitch'`: what is centred, this\ndoor's own `extra` (a search box, a composer), and up to three acts. Icons at rest; the words appear on\nhover or on tab — inside the bar, never in a tooltip, because `CanvasHud` fullscreens the HUD root and a\nbody-level tip is unpainted there.\n\n**A control exists because the card can be acted on, not because the spec named a `verb`.** That is the\nwhole of `actsFor`, and it is what ended the dead click: a card with no address inside a door that only\nnavigates now shows no primary at all, with its reason where the button was.\n\n| the card is | its acts |\n|---|---|\n| a page with sub-tabs | **Inside** (drills) · **Open** |\n| a leaf page | **Open** |\n| a sub-tab | **↑ its page** · **Open** |\n| the way back | **↑ where it goes** |\n| locked | none — the lock's reason instead |\n\n**Drilling deals that page's sub-tabs**, each with its own acts, and the level is `?in=<pageId>` — so\nBack leaves it, it can be linked, and a reload cannot strand you. A door opts in by passing `sections`;\none that does not is byte-identical to a door written before the funnel existed, which is what keeps\nthe nine control-plane doors (flat lists, nothing behind them) unchanged.\n\n**A verb a domain cannot derive is DECLARED**, in `_shared/model/navActs.js`, keyed exactly as\n`navEntities.js` is (`<domainId>/<subItemId>[:<tabId>]`). Not in the manifest and not in a\n`model/tabs.js`: `normalizeTab` is a whitelist and silently drops anything it does not know, so an\n`acts:` field written there would vanish with no error. An act is a real destination — `check:huds`\nmatches every one against the routes this Studio actually has — or one of `ACT_VERBS`.\n\n## What lives in a domain's room — the three rungs\n\nThe room is not decoration. It is the domain's own things, and the same read fills the card counts on\nits rail and its deck. There are exactly three answers, and picking the right one is the first\ndecision when you add a domain:\n\n**① The domain owns entity KINDS.** One row in `shared/apps/domain-entities.js` and one `kinds:` line\nin the product's overview table. **No new file, no change to `app.js`, no second registration path.**\nThis is the rung to aim for, and the recipe is below.\n\n**② No kinds, but its pages declare `tabs`.** The tabs become standing pillars — one level below the\ndeck, so the room is still about this domain rather than a copy of the cards in front of it.\n\n**③ Neither: no room at all.** No `load`, so no WebGL is fetched and the stage keeps its wash. That is\nthe honest answer for a domain with nothing of the operator's in it — `forge` persists only to\nlocalStorage, `dev` is live views of the machine — and it is a **decision, not a gap**. Say so in the\ntable rather than inventing a thing that does not exist.\n\n### A populator is for a BESPOKE door only\n\n`(api) => Promise<inhabitant[]>`, and **it must never reject** — one dead endpoint costs a species,\nnever the room. Hold it with a `catch` per read, with `Promise.allSettled`, or by doing no async work\nat all (Devices' is curried over rows the console already has). `check:huds` enforces all three.\n\nAn inhabitant is `{ id, kind, label, sub, href, color, body, idle, approach, scale?, emissive?,\nopacity?, model? }`. `color` must be a hex literal — three cannot parse a CSS token.\n\n**Do not write one for a generic door.** A table row is the same room for one line, and a hand-written\npopulator is a second description of the domain that will drift from the first.\n\n**Do not call `registerPopulator()`** for a domain room either. That seam adds a species to the\n*shared* `/dashboard` room, which already has sites, bots and apps in it.\n\n## Giving a domain real things\n\n> **Writing the entity itself — the spec, its body, its motion, the `face` seed, the silent\n> failures — is the `leumas-entities` skill.** What follows is only the half a DOMAIN owns:\n> where the declaration goes, and how the door claims it.\n\nOne table, two doors out of it — `shared/apps/domain-entities.js`:\n\n| the domain is | declare in | reaches the API via |\n|---|---|---|\n| a store app (`media`, `wardrobe`, …) | `DOMAIN_ENTITIES` | `defineDomainApp` → `defineLeumasApp` → `FIRST_PARTY_APPS` |\n| a core section (`commerce`, `lmx`, …) | `CORE_ROOMS` | compiled there, spread into `app.js`'s `specs:` |\n| a HAND-WRITTEN app (`graphs`, `prospecting`) | **its own `index.js`** | `domainAppsExcept` filters those two out, so a table entry compiles into **nothing** |\n\nThen name the kinds in the product's overview table (`kinds: ['commerce.product']`), and the card\ncounts become free — `{kind}` reads off the room fetch that already happened, instead of a\n`?limit=1` query per card per visit.\n\n**An app can declare its own door.** `defineLeumasApp` takes an `overview` bag (`kinds`, `counts`,\n`verb`, `copy`, `room`, `empty`) — see `@leumas/app-kit`'s `defineOverview`. Studio merges it *under*\nits own table, so a product's decision about one of its doors always wins, and a bespoke-landing\ndomain takes nothing. First-party only: a third-party listing's nav is rebuilt server-side into the\nshared `plugins` domain, so an `overview` there would be last-writer-wins across every plugin.\n\n### The four traps, each of which has already cost a real bug\n\n1. **`trust: 'first-party'` whenever the source names a shared collection.** The default is\n   `sandboxed` — correctly, fail-closed — and it rewrites `products` to `app_commerce_products`,\n   which does not exist. The read then reports `ok` with zero rows, which is indistinguishable from\n   an account that owns nothing.\n2. **The collection is the one the surface actually WRITES to**, never the one the domain ought to\n   have. `reasoning_graphs`, not `graphs`. Verify with\n   `node ops/infra/scripts/audit-platform-collections.mjs` before writing it down.\n3. [critical] **`scope: 'user'` drops every row with no `owner`.** `canRead` is `public === true || owner ===\n   you`. Several collections stamp `userId` instead, which `canRead` does not read — those rooms are\n   empty on **every** account, forever. Read the create path before choosing a scope.\n4. **`map` cannot filter, join, do arithmetic, or take an array's length** — by design, because that\n   is exactly the power a sandboxed plugin must not have. Two producers sharing one collection needs\n   a `{route}` source and a resolver in `app.js`; `count` needs a real scalar column.\n\n### Two guards, pointing opposite ways\n\n`check:huds` asserts **declared ⇒ real**: every kind a domain names exists in a compiled spec. That\ncatches a typo, which is otherwise silent forever — the server matches an unknown kind against\nnothing and answers a perfectly valid empty room.\n\nIt also asserts **real ⇒ declared**: every registered kind is claimed by some domain, or named in\n`UNCLAIMED_OK` with a sentence saying why. That is the direction that was missing, and `media.station`\nand `devices.device` were both registered, both drawn on `/dashboard`, and asked for by no door at all.\n\n## Recipes\n\n### Add a sub-tab to an existing page\n1. Add `{ id, label, icon, blurb }` to that domain's `model/tabs.js` — the one list.\n2. Add a `<Route path=\"<id>\" …>` to the page's nested `<Routes>`, and a `lazy()` import for its panel.\n3. Nothing else. The manifest already maps that list; the aside, ⌘K, the crumb and the deck follow.\n\n### Add a sub-sub route (a tab that owns children)\n1. Give the tab its own `tabs: [{ id, label, icon }]` in `model/tabs.js`.\n2. Make the parent route a splat (`path=\"<tab>/*\"`) and render the children inside the panel.\n3. Forward it in the manifest: `tabs: X_TABS.map((t) => ({ …, tabs: t.tabs }))`. Without that field the\n   normalizer's whitelist drops it and the aside lists the tab and stops. (Devices' Compute → Jobs.)\n\n### Add a whole domain\n1. `nav.manifest.js`: a domain entry (`...app('<id>')` if it is installable) with its sub-items.\n2. `<domain>/model/tabs.js` if the page has tabs; import it into the manifest, never restate it.\n3. `<domain>/hud/overview.js` — the spec above.\n4. `<domain>/hud/<x>Populators.js` — the room's cast.\n5. `<domain>/hud/<X>Hud.jsx` — render `DomainOverviewHud`, gate with `useCanvasCapable()` in the\n   default export (or set `downgrade: 'stage'` and skip the gate entirely — then only the room\n   degrades and the console over it stays), wrap in `DomainViewHost` for the other view modes.\n6. Register the surface, add the id to `DOMAIN_LANDINGS` in `AdminRoutes.jsx`, and add it to the lists\n   in `ops/infra/scripts/check-domain-huds.mjs` and `ops/tools/domain-diagrams/index.mjs`.\n\nSteps 2–5 are mechanical, so there is a generator for them:\n\n```sh\nnode ops/tools/domain-scaffold/index.mjs --product admin --domain fleet   # or --all\n```\n\nIt reads the product's manifest and route file, and writes `model/tabs.js`, `hud/overview.js`,\n`hud/<x>Populators.js`, `hud/<X>Hud.jsx` and `pages/<X>Hub.jsx`. It never touches the manifest or the\nroute table (steps 1 and 6 carry real decisions) and never overwrites an existing file without\n`--force`. Only `--product admin` is wired today; add a `PRODUCTS` entry to teach it another.\n\n### A domain in the CONTROL PLANE (`products/leumas-admin`)\n\nSame kit, three differences worth knowing before you copy a Studio door:\n\n- the route base is `/`, not `/admin` — `<DomainRoutes basePath=\"/\">`;\n- a domain is ONE splat sub-item (`<Domain>Hub`) carrying its tabs, so today's page URLs survive\n  becoming tabs, and the door lives at `/d/<id>` via `LANDINGS`;\n- `downgrade: 'stage'` everywhere and no `useCanvasCapable()` in any door: there is no second card\n  landing per domain to maintain, so only the room degrades.\n\n**Leviathan is off in that console on purpose** (`assistant={false}`). Its page tools drive the page\nthey are on, and those pages revoke customer licences.\n\n## The invariants that have already cost bugs\n\n- **ONE `AdminSurface` per page.** The shell renders it; a console must not wrap it in another.\n- **The capability gate lives in the domain's DEFAULT EXPORT**, not inside the shell — otherwise every\n  domain's data hooks mount and fire on a device that was never going to draw the room.\n- **MEMOISE `items`.** A fresh array identity remounts every card and restarts every entrance.\n- **Never let `reloadKey` follow a search box.** It re-reads the room's cast; bound to a query it tears\n  down and rebuilds the WebGL context on every keystroke, behind a console that looks perfect.\n- **The layering contract**: `.lms-chud__stage` receives everything; every chrome layer above it is\n  `pointer-events:none` with live children. A full-bleed transparent `pointer-events:auto` element\n  over the stage kills all interaction and looks identical in a screenshot.\n- **A domain's internals are internal.** Cross-domain imports must be named in that domain's\n  `public.js`, and consumers still import the deep path (a barrel hop would drag the whole barrel into\n  the chunk).\n- **`three` only through a dynamic import**, or it lands on a boot path.\n\n## Prove it\n\n`pnpm check:huds` — every domain has an overview, it is the shared one, no console draws its own\nsurface/HUD/host, the gate is written once, every populator can't reject, and the renderer is only\never dynamically imported. `--self-test` proves its matchers still reject bad input.\n\nThen, per what you touched: `check:nav` (every domain has a reachable door) · `check:routes` ·\n`check:admin` (surfaces render through `AdminSurface`; ratchets inline styles + CSS namespaces) ·\n`check:ui-kit` · `check:devices` · `check:frontend` · `check:theme` · `check:props-coverage`.\n`pnpm diagrams` regenerates `ops/reference/diagrams/` — run it after changing a manifest or a spec.\n\nSee also: `leumas-capabilities` (what already exists, and its `placement.md` lane for where code goes),\n`leumas-guards` (which guard to run), `build-leumas-plugin` (a domain that ships in the app store).\n",
  "source": {
    "path": ".claude/skills/leumas-domains/SKILL.md",
    "blobSha": "",
    "commit": "",
    "committedAt": "",
    "provenance": "no-git",
    "bytes": 36059,
    "hash": "58f7fd607c404998f7e37625da32f3dc5d531ae8"
  },
  "urls": {
    "html": "/p/skills/leumas-domains",
    "json": "/docs/skills/leumas-domains.json",
    "md": "/docs/skills/leumas-domains.md"
  },
  "links": {
    "composes": [],
    "usedBy": [],
    "product": [],
    "howTo": [],
    "skills": []
  }
}
