# @leumas/avatar — the operator identity, as data

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.


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

| Subpath | Imports | Loaded by |
|---|---|---|
| `/slots` · `/rarity` · `/frames` · `/auras` | **nothing** | server + client |
| `/look` | `./slots.js`, nothing else | server + client |
| `/render` | `./look.js` + `./slots.js` + `./rarity.js`, and re-exports all three | server + client |
| `/avatar.css` | — | a bundler, never Node |
| `.` (the barrel) | React, `@leumas/ui`, every `.jsx` here | **client only** |

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

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

| Export | File | What it is |
|---|---|---|
| `AvatarBadge` | `src/AvatarBadge.jsx` | `Avatar` from `@leumas/ui` plus optional decoration. Undecorated it **returns `Avatar` itself**. |
| `OperatorBust` | `src/OperatorBust.jsx` | Container-sized portrait: aura behind, frame in front, optional ring and rim light. |
| `OperatorCard` | `src/OperatorCard.jsx` | Bust + nameplate. Composition only — no fetching, no progression maths. |
| `OperatorFrame` | `src/OperatorFrame.jsx` | The eight frames as inline SVG. `stroke="currentColor"`, `fill="none"`, no colour literal. |
| `LookCustomizer`, `EDITABLE_SLOTS` | `src/LookCustomizer.jsx` | The controlled picker. Every tile draws the **real** cosmetic. |
| `FRAMES`, `AURAS`, `frameOf`, `auraOf`, `auraClass` | `src/frames.js`, `src/auras.js` | The 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.
2. **`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.
3. **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.
4. **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

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


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