{
  "schema": "leumas.docs.page/1",
  "id": "pkg:@leumas/entrance",
  "slug": "packages/entrance",
  "kind": "capabilities",
  "bucket": "package",
  "title": "@leumas/entrance",
  "name": "@leumas/entrance",
  "eyebrow": null,
  "chip": null,
  "summary": "The Entrance system — how a Leumas product OPENS and how it MOVES. A pluggable registry of splash adapters (the boot sequence) and transition adapters (the between-page motion), resolved per user...",
  "keywords": [
    "entrance",
    "splash",
    "transition",
    "motion",
    "plays",
    "entrance api",
    "leumas entrance",
    "how to use entrance"
  ],
  "audience": "both",
  "funnel": {
    "product": null,
    "cta": null
  },
  "body": "# `@leumas/entrance`\n\nHow a Leumas product **opens** (the splash) and how it **moves** between pages (the transition).\n\nBoth used to be hardcoded, differently, in three products. Both are the first thing anyone sees. Both\nare taste. So both are **adapters**: metadata a catalog can list, plus a `loader` that fetches the\ncode only when that one is actually chosen — and a person, a tenant or an appliance operator can pick\nany of them.\n\n```\n                       ?splash=…            the preview / dev override\n                            ↓\n   profiles.settings['entrance.splash']     the person — follows them across devices\n                            ↓\n    config.appearance.entrance.splash       the tenant — a white-label brand default\n                            ↓\n             PRODUCT_DEFAULTS[product]      what this product ships with\n                            ↓\n                 the capability floor       what THIS device can actually run\n```\n\n`resolve.js` is that table as one pure function. `ops/infra/scripts/smoke-entrance.mjs` asserts every\nrow of it without a browser.\n\n---\n\n## The three tiers of a splash\n\n| Tier | Where | Weight |\n|---|---|---|\n| **T0** | inline `<style>` + `#boot` markup in each product's `index.html` | ~2.5KB, 0 JS, paints on the first byte |\n| **T1** | `<SplashHost>` — this package | ~5KB |\n| **T2** | the chosen adapter's renderer, dynamically imported | lazy; never blocks |\n\nT1's first frame is deliberately **identical** to T0's last one — same class names, same geometry,\nsame accent — so React taking over is invisible. That matters more than it sounds: `main.jsx` awaits\n`/api/tenant` (four retries at 1200ms) *before* the first React render, so on a cold or restarting API\nthe two tiers are on screen back to back for seconds.\n\n**The boot readout is real.** `window.__leumasBoot` is installed by the inline markup; `bootStep()`\nwrites to it from `main.jsx` around work it was already doing. The wait narrates itself — *contacting\ncore · tenant leumas · starting studio* — instead of showing an indeterminate bar. Same duration,\ncompletely different experience, and when a step is genuinely slow you can see which one.\n\n---\n\n## The game-boot layer\n\nTwo entrances answer the \"make it feel like a game launching\" brief from opposite ends, and both are\n2D canvas — ~4 KB, no WebGL, so they work on anything:\n\n- **`breach`** — the netrunner boot. Hex rain falling through a lattice, a slow scan, and *rare*\n  datamosh (one frame in forty, displacing real pixels sideways). Continuous glitch is noise; the eye\n  normalises it inside a second and it stops reading as damage. Hazard yellow, monospace, and the\n  wordmark decodes character by character with a cyan/magenta chromatic split.\n- **`dropzone`** — the deliberate opposite. Wide vertical sky, a horizon glow, three light shafts\n  breathing on separate clocks, motes rising through frame. Warm, open, heavy type.\n\nBoth opt into **`chrome: 'hud'`** — the game-loader furniture in `SplashChrome.jsx`: a stage readout,\na progress bar, and a rotating loading tip. It is a **separate layer from the scene** on purpose: the\nart is taste, but what the screen *tells* you should not change when someone picks a different\nbackground.\n\n**The progress bar is honest.** There is no byte count to report — the boot is a tenant fetch, a\nlicence probe and a React mount, none of which expose a fraction. So it tracks the thing that *is*\nknown: elapsed time against the entrance's declared duration, which is genuinely when the screen\nends. It never sits at 99% waiting and never jumps backwards, and the label beneath it names the real\nphase from `bootLog()`. A bar that lies about bytes is worse than no bar.\n\n**Tips** live in `splash/tips.js` and are a registry, not a constant — a product knows tips this\npackage cannot. `registerTips([...], { scope: 'studio' })` at boot and they join the rotation. A boot\nscreen holds attention for five seconds with nothing to read; this is where ⌘K gets taught.\n\n### Rotation\n\n`<SplashHost rotate=\"cycle\" pool={[...]}>` gives a different entrance each launch, walking the pool\nin order so you eventually see all of them (`\"random\"` can land on the same one three times running).\nIt sits **below** an explicit user pick and **above** the product default: choose one in Settings and\nrotation stops. The pool is filtered by the capability floor first, so rotation can never hand a\nmachine an entrance it will immediately degrade out of.\n\n## Adding an entrance\n\nOne entry in `src/adapters.js` plus one render module. Nothing else in the system needs to learn about\nit: the settings gallery lists it, the resolver can pick it, `check-entrance.mjs` checks it.\n\n```js\nregisterSplash('aurora-veil', {\n  meta: {\n    label: 'Aurora Veil',\n    description: 'Slow ribbons of light across a cold field.',\n    tone: 'calm',                 // 'lab' | 'genesis' | 'calm' — gallery grouping only\n    accent: '#45b3ff',\n    cost: 'gpu',                  // 'none' | 'cpu' | 'video' | 'gpu' — the capability floor reads this\n    requiresWebgl: true,\n    durationMs: 5600,\n    fallback: 'lab-boot',         // what plays when this device cannot afford it\n    entitlement: null,            // the monetization seam — @leumas/auth already understands it\n    copy: [{ at: 800, kind: 'verse', text: '…' }],\n  },\n  loader: () => import('./splash/aurora-veil/render.jsx'),   // MUST be dynamic\n});\n```\n\n**`loader` must be a dynamic import.** This catalog is reachable from every product's eager bundle —\nthe resolver reads it to decide what to play, the gallery reads it to draw the picker — so a static\nimport of a renderer puts that renderer in the first paint of every visitor, including the ones whose\nsplash is suppressed and who never render a frame of it.\n\n### The heavy catalog is opt-in\n\n`src/heavy.js` holds the four three.js-backed entrances and is **not** reachable from the barrel. A\ndynamic import is a chunk boundary but not an exclusion — the bundler still has to *resolve* it — so\nregistering them in the default catalog made `@react-three/fiber` a hard dependency of every product.\nThe control plane, which has no 3D anything, failed to build outright. A product that ships three\nopts in:\n\n```js\nimport '@leumas/entrance/heavy';\n```\n\nSame split, same reason, as `@leumas/features/nav-views/heavy`.\n\n---\n\n## Transitions\n\nEight adapters — **`glide` (default)**, `doors`, `blink`, `slide`, `shutter`, `warp`, `fold`, `none` —\nand all of the motion is CSS in `entrance.css`. Where the browser supports it, it runs on the **View\nTransition** snapshots rather than on any live element, which is the only way to animate a page that\nis *leaving*: during a normal React commit the old tree no longer exists.\n\n### The default is deliberately quiet\n\n`glide` is the outgoing page receding 0.8% and fading, and the incoming page coming forward the same\n0.8% and settling. No overlay, no accent, no seam — **the content is the transition.**\n\n**The curve is the whole thing.** `cubic-bezier(.32,.72,0,1)` is the long-tailed decelerate Apple\nuses for sheet presentation: it covers most of the distance almost immediately, then spends the\nremaining 80% of its duration on the last few pixels. That asymmetry is what reads as weight —\nsomething arriving and coming to rest, rather than something being played at you. A symmetric ease\nover the same 300ms feels half as good and you cannot say why.\n\n**The numbers are small on purpose.** 0.8% of scale, 6px of travel. Large enough that the eye\nregisters arrival; small enough that on the two-hundredth navigation of the day it is still\ninvisible. Anything you can consciously watch is too much. Going *back* arrives from behind\n(`scale(0.994)`, −4px) instead of in front — same distances, opposite depth, which is the entire\ndifference between forward and back without sliding in either direction.\n\nAn earlier pass threw a lit blade and a curtain across the viewport. It was certainly visible, and it\nwas wrong: a transition is not something to admire. The seam now fires for `doors` and nothing else.\n\nFour mechanisms underneath, in the order they matter (see `routing/RouteTransition.jsx`):\n\n1. **The old page is held.** `<Routes>` is driven by a deferred location, so React renders the next\n   route in the background while the current one stays on screen. There is never a blank frame. This\n   is the single biggest change; everything else is polish.\n2. **The chunk is warmed first, with a 120ms bound.** `prefetchRoute` usually has it already from the\n   hover; past the bound we commit anyway and the shape skeleton takes over.\n3. **The commit is animated by the browser.** `flushSync` inside `startViewTransition` is *required* —\n   the API captures the new state when the callback returns, so a deferred update would snapshot the\n   old page twice and animate nothing.\n4. **Everything degrades.** No View Transition support (Firefox, older Safari), reduced motion, or a\n   `flushSync` React refuses → a plain `startTransition`, and the same arrival keyframe runs on the\n   incoming subtree. At this scale nobody can tell which path ran.\n\n### The spinner was the transition\n\n`AdminSurface` defaulted to `<Loader center/>` — a rotating ring — and only 7 of the 113 surfaces on\nthat shell passed a `skeleton`. Every navigation mounts a surface which immediately fetches, so the\nring is what a page change actually *looked like* from the operator's chair, no matter how good the\nmotion around it was. The default is now a shape (`toolbar`), `check:entrance` ratchets it, and the\nsweep covers the swap anyway.\n\n> **Any component rendering route-derived chrome — a breadcrumb, an active nav item, a page title —\n> must read `useRouteLocation()` from this package, not `useLocation()` from the router.** The\n> router's hook updates the instant you click, which during the hold announces a page that is not on\n> screen yet. Outside a provider `useRouteLocation()` returns the router's own location unchanged, so\n> it is always safe.\n\n---\n\n## Bringing your own\n\n| Who | How | Code needed |\n|---|---|---|\n| A person | Settings → Entrance, in Studio, the dashboard profile, or the control plane | none |\n| A tenant | `config.appearance.entrance` — the appearance object the form wizard already edits | none |\n| An appliance operator | drop a file into `LIBRARY_DIR`; the datacenter searches it **before** the packaged tree | none |\n| A product | `registerSplash(id, decl, { origin: 'product' })` at boot — see `leumas-web/src/entrance.register.js` | one file |\n| A third party | an app-store plugin contributing `entrance.background`, rendered in its **existing sandbox** | a plugin |\n\nNothing here executes user-supplied code in the app origin. The registry is a registry, not an eval.\n\n---\n\n## The art\n\n`reel:<key>` and `still:<key>` entries come from `shared/services/datacenter/src/library-assets/backdrops/`,\nproduced by `ops/tools/deck-assets` and committed (the same 14MB budget, the same guard, the same\nmanifest as the Operator Deck's own backdrops — every entrance loop doubles as a deck backdrop).\n\nFive are generated. Five more are art-directed and costed in `ops/tools/deck-assets/lib/bible.js`\nbut not yet produced, and they are deliberately **absent** from `src/splash/backdrops.js`: an entry\nthere becomes a card in the gallery, and a card whose art 404s is indistinguishable from a bug.\n\n```sh\nnode ops/tools/deck-assets/index.mjs prompts --json      # prompts, for a media MCP\nnode ops/tools/deck-assets/index.mjs encode <clip> --key obsidian\nnode ops/tools/deck-assets/index.mjs verify              # seam PSNR\nnode ops/tools/deck-assets/index.mjs manifest\n# then add the key back to src/splash/backdrops.js\n```\n\n---\n\n## Guards\n\n```sh\npnpm check:entrance     # heavy static imports · heavy catalog opt-in · adapter shape ·\n                        # backdrops resolve · every product imports the CSS · reduced motion ·\n                        # feature detection · no bare page spinner\npnpm smoke:entrance     # the resolver's full precedence table + the capability floor\n```\n",
  "source": {
    "path": "shared/packages/entrance/README.md",
    "blobSha": "",
    "commit": "",
    "committedAt": "",
    "provenance": "no-git",
    "bytes": 12296,
    "hash": "f19e343355c7045cfd1fd2d8e9baaf950a70116f"
  },
  "urls": {
    "html": "/p/packages/entrance",
    "json": "/docs/packages/entrance.json",
    "md": "/docs/packages/entrance.md"
  },
  "links": {
    "composes": [
      "pkg:@leumas/theme",
      "pkg:@leumas/ui"
    ],
    "usedBy": [
      "pkg:@leumas/admin",
      "pkg:@leumas/features",
      "pkg:@leumas/site-map",
      "pkg:@leumas/studio",
      "pkg:@leumas/web"
    ],
    "product": [
      "pkg:@leumas/admin",
      "pkg:@leumas/studio",
      "pkg:@leumas/web"
    ],
    "howTo": [],
    "skills": []
  },
  "exports": {
    "total": 59,
    "component": 14,
    "hook": 6,
    "helper": 39,
    "names": [
      {
        "n": "BACKDROP_BASE",
        "k": "component"
      },
      {
        "n": "BACKDROPS",
        "k": "component"
      },
      {
        "n": "CopyLayer",
        "k": "component"
      },
      {
        "n": "COSTS",
        "k": "component"
      },
      {
        "n": "EntranceCard",
        "k": "component"
      },
      {
        "n": "KINDS",
        "k": "component"
      },
      {
        "n": "NavBeam",
        "k": "component"
      },
      {
        "n": "NavBeam",
        "k": "component"
      },
      {
        "n": "PRODUCT_DEFAULTS",
        "k": "component"
      },
      {
        "n": "RouteTransitionProvider",
        "k": "component"
      },
      {
        "n": "RouteTransitionProvider",
        "k": "component"
      },
      {
        "n": "SPLASH_KEY",
        "k": "component"
      },
      {
        "n": "SplashHost",
        "k": "component"
      },
      {
        "n": "TRANSITION_KEY",
        "k": "component"
      },
      {
        "n": "useRouteLocation",
        "k": "hook"
      },
      {
        "n": "useRouteLocation",
        "k": "hook"
      },
      {
        "n": "useRouteTransitionState",
        "k": "hook"
      },
      {
        "n": "useRouteTransitionState",
        "k": "hook"
      },
      {
        "n": "useScramble",
        "k": "hook"
      },
      {
        "n": "useScrollMemory",
        "k": "hook"
      },
      {
        "n": "accentFor",
        "k": "helper"
      },
      {
        "n": "adapterProblem",
        "k": "helper"
      },
      {
        "n": "bootDone",
        "k": "helper"
      },
      {
        "n": "bootLog",
        "k": "helper"
      },
      {
        "n": "bootPhase",
        "k": "helper"
      },
      {
        "n": "bootStep",
        "k": "helper"
      },
      {
        "n": "canAfford",
        "k": "helper"
      },
      {
        "n": "clearNavIntent",
        "k": "helper"
      },
      {
        "n": "filesFor",
        "k": "helper"
      },
      {
        "n": "getSplash",
        "k": "helper"
      },
      {
        "n": "getSplash",
        "k": "helper"
      },
      {
        "n": "getTransition",
        "k": "helper"
      },
      {
        "n": "getTransition",
        "k": "helper"
      },
      {
        "n": "getTransitionDescriptor",
        "k": "helper"
      },
      {
        "n": "hasWebgl",
        "k": "helper"
      },
      {
        "n": "listAdapters",
        "k": "helper"
      },
      {
        "n": "listSplashes",
        "k": "helper"
      },
      {
        "n": "listSplashes",
        "k": "helper"
      },
      {
        "n": "listTips",
        "k": "helper"
      },
      {
        "n": "listTransitions",
        "k": "helper"
      },
      {
        "n": "listTransitions",
        "k": "helper"
      },
      {
        "n": "normalizeAdapter",
        "k": "helper"
      },
      {
        "n": "pickRotation",
        "k": "helper"
      },
      {
        "n": "pickTip",
        "k": "helper"
      },
      {
        "n": "prefersReducedMotion",
        "k": "helper"
      },
      {
        "n": "readCapabilities",
        "k": "helper"
      },
      {
        "n": "readOverride",
        "k": "helper"
      },
      {
        "n": "registerSplash",
        "k": "helper"
      },
      {
        "n": "registerSplash",
        "k": "helper"
      },
      {
        "n": "registerSplashes",
        "k": "helper"
      },
      {
        "n": "registerTips",
        "k": "helper"
      },
      {
        "n": "registerTransition",
        "k": "helper"
      },
      {
        "n": "registerTransition",
        "k": "helper"
      },
      {
        "n": "resolveSplash",
        "k": "helper"
      },
      {
        "n": "resolveTransition",
        "k": "helper"
      },
      {
        "n": "signalNavIntent",
        "k": "helper"
      },
      {
        "n": "steppedRecently",
        "k": "helper"
      },
      {
        "n": "steppingDirection",
        "k": "helper"
      },
      {
        "n": "takeNavIntent",
        "k": "helper"
      }
    ]
  }
}
