Docs
/
the operator identity, as data

@leumas/avatar

The operator identity: the renderer-agnostic look contract + slot registry (React-free, shared verbatim by server and client) and the 2.5D operator-card renderers.

@leumas/avatar — the operator identity, as data

What a Leumas user looks like, expressed as a look blob that the server validates and persists and the client renders — from one definition, not two. The package ships in two halves to make that literally true: React-free contract modules (./look, ./slots, ./rarity, ./render, ./frames, ./auras) that Node imports byte-identically, and a React barrel a browser only ever loads. Zero runtime dependencies; react, react-dom and @leumas/ui are optional peers (peerDependenciesMeta), which is what lets a server package depend on this one and install none of them.

Part of the Operator Deck program — see ops/todos/dashboard-deck-roadmap.md (Contracts C1, C2, C9).

The exports map

SubpathImportsLoaded by
/slots · /rarity · /frames · /aurasnothingserver + client
/look./slots.js, nothing elseserver + client
/render./look.js + ./slots.js + ./rarity.js, and re-exports all threeserver + client
/avatar.cssa bundler, never Node
. (the barrel)React, @leumas/ui, every .jsx hereclient only

Every consumer in the repo today, and which half it takes:

import { normalizeLook, serializeLook, LOOK_MAX_BYTES } from '@leumas/avatar/look';  // engines/progression/src/look.js
import { isRenderedSlot } from '@leumas/avatar/slots';                               // engines/progression/src/context.js
import { composeLayers } from '@leumas/avatar/render';                               // adapters/domain/avatar/index.js
import { RARITY_KEYS } from '@leumas/avatar/rarity';                                 // progression/test/catalog.test.js
import { OperatorCard, LookCustomizer, diffLook } from '@leumas/avatar';             // …/dashboard/operator/OperatorStudio.jsx
import { OperatorCard } from '@leumas/avatar';                                       // …/dashboard/deck/DeckOperator.jsx
import '@leumas/avatar/avatar.css';                                                  // both of the above

Never delete an entry from that map, and never widen an existing one to reach a .jsx file. Node hard-fails an unlisted subpath with ERR_PACKAGE_PATH_NOT_EXPORTED the moment an exports map exists, so dropping ./render takes the whole avatar adapter down: its index.js import throws, shared/engines/middleware/src/adapters/registry.js records the failure, and node ops/infra/scripts/check-adapters.mjs then fails three ways at once — a non-empty registry.errors, avatar missing from its EXPECT list, and the identicon probe with nothing to call. Pointing a React-free subpath at JSX is the same failure in slow motion: Node cannot parse .jsx, so the server dies at import rather than at use.

The React surface

All of it behind the barrel, except the last row: those catalogs are plain data and are also exported React-free at ./frames and ./auras.

ExportFileWhat it is
AvatarBadgesrc/AvatarBadge.jsxAvatar from @leumas/ui plus optional decoration. Undecorated it returns Avatar itself.
OperatorBustsrc/OperatorBust.jsxContainer-sized portrait: aura behind, frame in front, optional ring and rim light.
OperatorCardsrc/OperatorCard.jsxBust + nameplate. Composition only — no fetching, no progression maths.
OperatorFramesrc/OperatorFrame.jsxThe eight frames as inline SVG. stroke="currentColor", fill="none", no colour literal.
LookCustomizer, EDITABLE_SLOTSsrc/LookCustomizer.jsxThe controlled picker. Every tile draws the real cosmetic.
FRAMES, AURAS, frameOf, auraOf, auraClasssrc/frames.js, src/auras.jsThe catalogs, as plain data.

Three of those are delegation, not duplication, which is the only reason they cannot drift. AvatarBadge returns the untouched Avatar (a copy kept in step by a test drifts the first time someone edits the kit; test/badge.test.js guards the delegation instead), and LookCustomizer draws the real cosmetic in every tile, so no preview asset needs keeping in sync. The catalogs sit outside their components because the server reads them and Node cannot parse .jsx: engines/progression/test/catalog.test.js cross-checks the seeded cosmetic_definitions rows against FRAME_KEYS/AURA_KEYS, so a row can never name a frame the renderer does not know — which would ship as an equippable cosmetic that visibly does nothing.

src/avatar.css owns the entire lms-op__* namespace (Contract C10) and every custom property the renderers read. Classes are never invented in JS: auraClass() returns a class string so the colours and keyframes stay in the stylesheet where the token guard can see them.

The look, and where its contract lives

A slot value is namespaced. The grammar is Contract C1 and the parser is parseSlotValue() in src/look.js — do not re-derive either.

  • cat:<cosmeticKey> — a cosmetic_definitions row.
  • gen:<tool>/<seed> — a deterministic generator from the avatar adapter; allowlist GEN_TOOLS.
  • asset:<ownerId>/<assetId> — public datacenter storage.

Which slots accept which namespace is SLOTS[].accepts in src/slots.js; acceptsNamespace() fails closed, and a bare string with no : coerces to cat:. normalizeLook() is pure and idempotent and drops anything invalid rather than throwing; parseLook() never throws at all, so a corrupt or hand-edited row renders a default operator instead of blanking a page. Ownership is not checked here — the server recomputes it on every equip, and client lock state is presentation only.

The six 3D slots (body, head, outfit, prop, pose, mount) are already declared, stored and never painted; validateLook warns rather than rejecting. That is what makes the deferred 3D renderer a pure addition. There is no v: 2.

Four rules you cannot bend

  1. The React-free subpaths import nothing. No React, no DOM, no CSS, no @leumas/ui: ./slots,

./rarity, ./frames and ./auras import literally nothing and ./look imports only ./slots.js. That is what lets engines/progression and the avatar adapter share one definition with the browser instead of two that agree until they don't.

  1. gravatarUrl is never a valid generator. It derives a public image from a hash of the user's

email, so a rendered avatar becomes an identity oracle — you could confirm whether an address holds an account. It is absent from GEN_TOOLS and from the avatar router's own STYLES = ['boringAvatar', 'identicon', 'blockies'] in shared/packages/auth/src/routers/avatar.router.js, and pnpm smoke:operator posts gen:gravatarUrl/x at the write path to prove it is refused.

  1. Tint is a security control, not tidiness. The avatar adapter interpolates palette colours and

options.fontFamily straight into SVG attributes with no escaping — escapeXml guards text content only (initials, and renderCard's name/rank/level), never an attribute value — so an arbitrary tint string is an SVG-attribute injection primitive. A tint is a TINT_TOKENS member or /^#[0-9a-fA-F]{6}$/, and normalizeLook drops everything else.

  1. A cosmetic on disk is .webp, never .svg and never .avif. cosmeticUrl() resolves

cat:<key> to <base>/cosmetics/<key>.webp — the extension is hardcoded — and the library route serves those bytes with no CSP, which makes a same-origin SVG stored XSS. check-deck-assets.mjs bans .svg outright under cosmetics/ and backdrops/, and holds cosmetics/ to .webp alone: .avif stays legal for a backdrop poster, but an .avif cosmetic would seed green and then 404 at render. Anything that genuinely needs vectors is code here — the eight frames are JSX paths, the auras are CSS rules, and both are seeded with an assetRef of svg:<key> rather than a file for that guard to resolve. That SVG is emitted inline, never as a data: URI: custom properties do not cross into a data-URI document, so currentColor would resolve to nothing there and the frame would silently vanish.

Verify

pnpm --filter @leumas/avatar test         # node --import ./test/register-jsx.mjs --test test/*.test.js — 41 tests
node ops/infra/scripts/check-adapters.mjs # the exports map, via the avatar adapter (no root-script alias)
pnpm check:deck                           # bundle purity + the asset/format rules + the built-entry budget
pnpm check:tokens                         # every lms-* class written here must be declared in the corpus
pnpm smoke:operator                       # the look write path end to end, against a running API

The --import flag is not decoration: test/register-jsx.mjs registers the loader from @leumas/test-jsx (the package's only devDependency), which transforms .jsx with sucrase (already the transform @leumas/features and @leumas/config-registry use) and stubs .css to an empty module. That loader used to be a byte-identical copy in each of the three packages that need it — @leumas/ui, @leumas/features and this one — and is now one workspace package. Without it the contract tests could not import the actual AvatarBadge and the actual @leumas/ui Avatar — and a test that re-declares the markup pins itself rather than the component.

check-deck-bundle.mjs resolves @leumas/ through each package's exports map, so it walks in here from the deck: a three or @react-three/ import anywhere in src/ fails it. Both it and check-design-tokens.mjs are repo-wide — read the path in a failure before assuming it is yours. check:deck's third script, check-deck-budget.mjs, greps the BUILT entry chunk for lms-op__card among its markers, so it only has anything to say after a production build — with no dist/ it prints what is missing and exits 0.

Caveats

  • AvatarBadge, OperatorBust and OperatorFrame have no consumers outside this package yet.

It was built to replace bare Avatar call sites across the dashboard, social and admin over time; that migration has not started. test/badge.test.js pins the identity contract in the meantime.

  • src/three/OperatorAvatar3D.jsx does not exist. check-deck-bundle.mjs already exempts that

exact path as the one file permitted to reach three, behind await import() and a quality gate. The exemption is pre-declared for the deferred 3D phase; nothing in src/ reaches three today.

  • shared/engines/adapters/domain/avatar is not this package. It is a pre-existing deterministic

identicon/blockies/initials SVG generator that predates the Operator Deck, it is what the gen: namespace resolves through, and it imports @leumas/avatar/render rather than being it. Any sentence about "avatar" has to say which one.

What it exports

125 exports: 58 components, 0 hooks, 67 helpers. Import from @leumas/avatar.

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