# @leumas/entrance

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


How a Leumas product **opens** (the splash) and how it **moves** between pages (the transition).

Both used to be hardcoded, differently, in three products. Both are the first thing anyone sees. Both
are taste. So both are **adapters**: metadata a catalog can list, plus a `loader` that fetches the
code only when that one is actually chosen — and a person, a tenant or an appliance operator can pick
any of them.

```
                       ?splash=…            the preview / dev override
                            ↓
   profiles.settings['entrance.splash']     the person — follows them across devices
                            ↓
    config.appearance.entrance.splash       the tenant — a white-label brand default
                            ↓
             PRODUCT_DEFAULTS[product]      what this product ships with
                            ↓
                 the capability floor       what THIS device can actually run
```

`resolve.js` is that table as one pure function. `ops/infra/scripts/smoke-entrance.mjs` asserts every
row of it without a browser.

---

## The three tiers of a splash

| Tier | Where | Weight |
|---|---|---|
| **T0** | inline `<style>` + `#boot` markup in each product's `index.html` | ~2.5KB, 0 JS, paints on the first byte |
| **T1** | `<SplashHost>` — this package | ~5KB |
| **T2** | the chosen adapter's renderer, dynamically imported | lazy; never blocks |

T1's first frame is deliberately **identical** to T0's last one — same class names, same geometry,
same accent — so React taking over is invisible. That matters more than it sounds: `main.jsx` awaits
`/api/tenant` (four retries at 1200ms) *before* the first React render, so on a cold or restarting API
the two tiers are on screen back to back for seconds.

**The boot readout is real.** `window.__leumasBoot` is installed by the inline markup; `bootStep()`
writes to it from `main.jsx` around work it was already doing. The wait narrates itself — *contacting
core · tenant leumas · starting studio* — instead of showing an indeterminate bar. Same duration,
completely different experience, and when a step is genuinely slow you can see which one.

---

## The game-boot layer

Two entrances answer the "make it feel like a game launching" brief from opposite ends, and both are
2D canvas — ~4 KB, no WebGL, so they work on anything:

- **`breach`** — the netrunner boot. Hex rain falling through a lattice, a slow scan, and *rare*
  datamosh (one frame in forty, displacing real pixels sideways). Continuous glitch is noise; the eye
  normalises it inside a second and it stops reading as damage. Hazard yellow, monospace, and the
  wordmark decodes character by character with a cyan/magenta chromatic split.
- **`dropzone`** — the deliberate opposite. Wide vertical sky, a horizon glow, three light shafts
  breathing on separate clocks, motes rising through frame. Warm, open, heavy type.

Both opt into **`chrome: 'hud'`** — the game-loader furniture in `SplashChrome.jsx`: a stage readout,
a progress bar, and a rotating loading tip. It is a **separate layer from the scene** on purpose: the
art is taste, but what the screen *tells* you should not change when someone picks a different
background.

**The progress bar is honest.** There is no byte count to report — the boot is a tenant fetch, a
licence probe and a React mount, none of which expose a fraction. So it tracks the thing that *is*
known: elapsed time against the entrance's declared duration, which is genuinely when the screen
ends. It never sits at 99% waiting and never jumps backwards, and the label beneath it names the real
phase from `bootLog()`. A bar that lies about bytes is worse than no bar.

**Tips** live in `splash/tips.js` and are a registry, not a constant — a product knows tips this
package cannot. `registerTips([...], { scope: 'studio' })` at boot and they join the rotation. A boot
screen holds attention for five seconds with nothing to read; this is where ⌘K gets taught.

### Rotation

`<SplashHost rotate="cycle" pool={[...]}>` gives a different entrance each launch, walking the pool
in order so you eventually see all of them (`"random"` can land on the same one three times running).
It sits **below** an explicit user pick and **above** the product default: choose one in Settings and
rotation stops. The pool is filtered by the capability floor first, so rotation can never hand a
machine an entrance it will immediately degrade out of.

## Adding an entrance

One entry in `src/adapters.js` plus one render module. Nothing else in the system needs to learn about
it: the settings gallery lists it, the resolver can pick it, `check-entrance.mjs` checks it.

```js
registerSplash('aurora-veil', {
  meta: {
    label: 'Aurora Veil',
    description: 'Slow ribbons of light across a cold field.',
    tone: 'calm',                 // 'lab' | 'genesis' | 'calm' — gallery grouping only
    accent: '#45b3ff',
    cost: 'gpu',                  // 'none' | 'cpu' | 'video' | 'gpu' — the capability floor reads this
    requiresWebgl: true,
    durationMs: 5600,
    fallback: 'lab-boot',         // what plays when this device cannot afford it
    entitlement: null,            // the monetization seam — @leumas/auth already understands it
    copy: [{ at: 800, kind: 'verse', text: '…' }],
  },
  loader: () => import('./splash/aurora-veil/render.jsx'),   // MUST be dynamic
});
```

**`loader` must be a dynamic import.** This catalog is reachable from every product's eager bundle —
the resolver reads it to decide what to play, the gallery reads it to draw the picker — so a static
import of a renderer puts that renderer in the first paint of every visitor, including the ones whose
splash is suppressed and who never render a frame of it.

### The heavy catalog is opt-in

`src/heavy.js` holds the four three.js-backed entrances and is **not** reachable from the barrel. A
dynamic import is a chunk boundary but not an exclusion — the bundler still has to *resolve* it — so
registering them in the default catalog made `@react-three/fiber` a hard dependency of every product.
The control plane, which has no 3D anything, failed to build outright. A product that ships three
opts in:

```js
import '@leumas/entrance/heavy';
```

Same split, same reason, as `@leumas/features/nav-views/heavy`.

---

## Transitions

Eight adapters — **`glide` (default)**, `doors`, `blink`, `slide`, `shutter`, `warp`, `fold`, `none` —
and all of the motion is CSS in `entrance.css`. Where the browser supports it, it runs on the **View
Transition** snapshots rather than on any live element, which is the only way to animate a page that
is *leaving*: during a normal React commit the old tree no longer exists.

### The default is deliberately quiet

`glide` is the outgoing page receding 0.8% and fading, and the incoming page coming forward the same
0.8% and settling. No overlay, no accent, no seam — **the content is the transition.**

**The curve is the whole thing.** `cubic-bezier(.32,.72,0,1)` is the long-tailed decelerate Apple
uses for sheet presentation: it covers most of the distance almost immediately, then spends the
remaining 80% of its duration on the last few pixels. That asymmetry is what reads as weight —
something arriving and coming to rest, rather than something being played at you. A symmetric ease
over the same 300ms feels half as good and you cannot say why.

**The numbers are small on purpose.** 0.8% of scale, 6px of travel. Large enough that the eye
registers arrival; small enough that on the two-hundredth navigation of the day it is still
invisible. Anything you can consciously watch is too much. Going *back* arrives from behind
(`scale(0.994)`, −4px) instead of in front — same distances, opposite depth, which is the entire
difference between forward and back without sliding in either direction.

An earlier pass threw a lit blade and a curtain across the viewport. It was certainly visible, and it
was wrong: a transition is not something to admire. The seam now fires for `doors` and nothing else.

Four mechanisms underneath, in the order they matter (see `routing/RouteTransition.jsx`):

1. **The old page is held.** `<Routes>` is driven by a deferred location, so React renders the next
   route in the background while the current one stays on screen. There is never a blank frame. This
   is the single biggest change; everything else is polish.
2. **The chunk is warmed first, with a 120ms bound.** `prefetchRoute` usually has it already from the
   hover; past the bound we commit anyway and the shape skeleton takes over.
3. **The commit is animated by the browser.** `flushSync` inside `startViewTransition` is *required* —
   the API captures the new state when the callback returns, so a deferred update would snapshot the
   old page twice and animate nothing.
4. **Everything degrades.** No View Transition support (Firefox, older Safari), reduced motion, or a
   `flushSync` React refuses → a plain `startTransition`, and the same arrival keyframe runs on the
   incoming subtree. At this scale nobody can tell which path ran.

### The spinner was the transition

`AdminSurface` defaulted to `<Loader center/>` — a rotating ring — and only 7 of the 113 surfaces on
that shell passed a `skeleton`. Every navigation mounts a surface which immediately fetches, so the
ring is what a page change actually *looked like* from the operator's chair, no matter how good the
motion around it was. The default is now a shape (`toolbar`), `check:entrance` ratchets it, and the
sweep covers the swap anyway.

> **Any component rendering route-derived chrome — a breadcrumb, an active nav item, a page title —
> must read `useRouteLocation()` from this package, not `useLocation()` from the router.** The
> router's hook updates the instant you click, which during the hold announces a page that is not on
> screen yet. Outside a provider `useRouteLocation()` returns the router's own location unchanged, so
> it is always safe.

---

## Bringing your own

| Who | How | Code needed |
|---|---|---|
| A person | Settings → Entrance, in Studio, the dashboard profile, or the control plane | none |
| A tenant | `config.appearance.entrance` — the appearance object the form wizard already edits | none |
| An appliance operator | drop a file into `LIBRARY_DIR`; the datacenter searches it **before** the packaged tree | none |
| A product | `registerSplash(id, decl, { origin: 'product' })` at boot — see `leumas-web/src/entrance.register.js` | one file |
| A third party | an app-store plugin contributing `entrance.background`, rendered in its **existing sandbox** | a plugin |

Nothing here executes user-supplied code in the app origin. The registry is a registry, not an eval.

---

## The art

`reel:<key>` and `still:<key>` entries come from `shared/services/datacenter/src/library-assets/backdrops/`,
produced by `ops/tools/deck-assets` and committed (the same 14MB budget, the same guard, the same
manifest as the Operator Deck's own backdrops — every entrance loop doubles as a deck backdrop).

Five are generated. Five more are art-directed and costed in `ops/tools/deck-assets/lib/bible.js`
but not yet produced, and they are deliberately **absent** from `src/splash/backdrops.js`: an entry
there becomes a card in the gallery, and a card whose art 404s is indistinguishable from a bug.

```sh
node ops/tools/deck-assets/index.mjs prompts --json      # prompts, for a media MCP
node ops/tools/deck-assets/index.mjs encode <clip> --key obsidian
node ops/tools/deck-assets/index.mjs verify              # seam PSNR
node ops/tools/deck-assets/index.mjs manifest
# then add the key back to src/splash/backdrops.js
```

---

## Guards

```sh
pnpm check:entrance     # heavy static imports · heavy catalog opt-in · adapter shape ·
                        # backdrops resolve · every product imports the CSS · reduced motion ·
                        # feature detection · no bare page spinner
pnpm smoke:entrance     # the resolver's full precedence table + the capability floor
```


---
Source: shared/packages/entrance/README.md
Canonical: https://docs.leumas.tech/p/packages/entrance
