Docs
/

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

@leumas/entrance

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

TierWhereWeight
T0inline <style> + #boot markup in each product's index.html~2.5KB, 0 JS, paints on the first byte
T1<SplashHost> — this package~5KB
T2the chosen adapter's renderer, dynamically importedlazy; 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.

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:

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.

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

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

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

WhoHowCode needed
A personSettings → Entrance, in Studio, the dashboard profile, or the control planenone
A tenantconfig.appearance.entrance — the appearance object the form wizard already editsnone
An appliance operatordrop a file into LIBRARY_DIR; the datacenter searches it before the packaged treenone
A productregisterSplash(id, decl, { origin: 'product' }) at boot — see leumas-web/src/entrance.register.jsone file
A third partyan app-store plugin contributing entrance.background, rendered in its existing sandboxa 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.

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

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

What it exports

59 exports: 14 components, 6 hooks, 39 helpers. Import from @leumas/entrance.

BACKDROP_BASEBACKDROPSCopyLayerCOSTSEntranceCardKINDSNavBeamNavBeamPRODUCT_DEFAULTSRouteTransitionProviderRouteTransitionProviderSPLASH_KEYSplashHostTRANSITION_KEYuseRouteLocationuseRouteLocationuseRouteTransitionStateuseRouteTransitionStateuseScrambleuseScrollMemoryaccentForadapterProblembootDonebootLogbootPhasebootStepcanAffordclearNavIntentfilesForgetSplashgetSplashgetTransitiongetTransitiongetTransitionDescriptorhasWebgllistAdapterslistSplasheslistSplasheslistTipslistTransitionslistTransitionsnormalizeAdapterpickRotationpickTipprefersReducedMotionreadCapabilitiesreadOverrideregisterSplashregisterSplashregisterSplashesregisterTipsregisterTransitionregisterTransitionresolveSplashresolveTransitionsignalNavIntentsteppedRecentlysteppingDirectiontakeNavIntent
Source shared/packages/entrance/README.md (no-git)markdownjson
Generated from the Leumas repository. Every page cites the file it came from.leumas.techllms.txt