Docs
/

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:

questionhome
what pages exist, and where do they livenav.manifest.js
what sections a page has<domain>/model/tabs.js
what THINGS this domain ownsshared/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 feedsHow
the route tableAdminRoutes derives one <Route> per sub-item; a sub-item with tabs mounts on a splat (path/*) so it can own nested <Routes>
the asidepublished 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 breadcrumbsStudio › <domain> › <page> › <segment>, every crumb carrying its siblings
⌘KnavToCommands emits the domain, every page and every tab — tabs are routes, so the palette can jump straight into one
the deck / front doorthe 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 diagramspnpm diagrams reads this file + each overview.jsops/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.

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.

  1. 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.

  1. 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.

  1. Labels are plain language about the domain's jobWhat 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.

  1. Every row is in a family. No ungrouped stragglers above or below the headings.
  2. 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.

  1. A nested row inherits its parent's familytabsToNavItems 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'.

ImportWhat it is
DomainOverviewHudTHE 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.
DomainActionBarTHE 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) / primaryActForWHAT 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_PARAMthe 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.
DomainRailTHE 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 bothcheck: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.
DomainViewHostwraps 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 / getDomainRoomthe 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-viewside-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/roomside-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/backdropDomainWorldBackdrop itself, for a surface drawing its own stage. Import it lazily.
@leumas/domain-kit/domain-hud.csslms-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.

idTypeThe question it answersLoadingNumbers
a1Collection"what have I got?" — many rows, narrowing, a create actioncards/table/rowsshell kpis
a2Gallery"what have I made?" — rows with a previewgalleryshell kpis
a3Catalog"what does this instance know how to do?" — a read-only registrycardsnone
bWorkbench"pick one and change it without leaving"splitshell kpis
cReport"what happened / what state is this in?" — no createcards/tablea StatTile grid is legal here
dRecord editorreached by a route param; saves; has dirty stateformnone
eCanvasthe body is the subject — a graph, a globe, an iframebespokeshell kpis only
fForm"do this one thing", one submitformnone
gGuideprose + CodeBlock, no fetch at allnonenone

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.

<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 / a2kpis (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 emptyGallery 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.

  • dcrumbs · 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.

  • efill and bleed, one child, controls in actions or floating over the stage.
  • fskeleton="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:

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.

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.

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 isits acts
a page with sub-tabsInside (drills) · Open
a leaf pageOpen
a sub-tab↑ its page · Open
the way back↑ where it goes
lockednone — 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 isdeclare inreaches the API via
a store app (media, wardrobe, …)DOMAIN_ENTITIESdefineDomainAppdefineLeumasAppFIRST_PARTY_APPS
a core section (commerce, lmx, …)CORE_ROOMScompiled there, spread into app.js's specs:
a HAND-WRITTEN app (graphs, prospecting)its own index.jsdomainAppsExcept 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.

  1. 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.

  1. [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.

  1. 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.

  1. 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:

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 (no-git)markdownjson
Generated from the Leumas repository. Every page cites the file it came from.leumas.techllms.txt