# leumas-app-art

App-store imagery for every Leumas app — store tile, listing logo, nav banner and screenshots — rendered by one deterministic compositor, never drawn by hand.


# App-store art in Leumas

Every listing wants four pictures, and before `ops/tools/app-art` existed it had none: of 62
first-party apps, **0** had a `logoUrl`, **0** had an `imageUrl` and **0** had a screenshot. The 25
domain apps had a nav `image:`, but only 6 pointed at real key art — the other 19 borrowed
Operator-Deck banners, so several apps showed *the same picture as each other*.

Both faults are invisible by design: a missing image falls back to the app's glyph (a deliberate
design, not a broken-image box), and a duplicated one just looks like art direction.

## The one command

```bash
node ops/tools/app-art/index.mjs plan       # coverage, budget, motif spread, shared glyphs
node ops/tools/app-art/index.mjs render     # draw what is missing, rebuild the manifest
pnpm check:app-art                          # present · unique · committed · in budget
```

`plan` · `render` · `manifest` · `verify` · `prompts` · `ingest` need **no server, no auth, no
credits and no generation API**. That is the point: keeping the store's art current must never
depend on something being up.

Two more, for when you want real pictures:

```bash
node ops/tools/app-art/index.mjs shoot                 # photograph every app that has a surface
node ops/tools/app-art/index.mjs prompts --json        # 12 flagship briefs, for a media MCP
node ops/tools/app-art/index.mjs ingest still.png --app media
```

`shoot` needs Studio + the API up and **signs itself in** with the seeded admin — the same
credentials every smoke suite uses. There is no cookie to paste and no new endpoint. It checks each
origin is reachable FIRST, because an unreachable one does not fail, it *photographs*: a 404 and a
connection-error page both capture into a perfectly valid `.webp` that nothing downstream can tell
from a screenshot.

**Leumas OS windows cannot be photographed yet** (`--harness` is wired but the route it needs does
not exist — `@leumas/os` has no deep link and `OsDesktop` takes no `open` prop). Those apps keep
their procedural art.

## How art reaches a listing

```
ops/tools/app-art render
        ↓  writes .webp files
shared/services/datacenter/src/library-assets/appart/<id>-<slot>-<sha8>.webp   → /library/appart/…
        ↓  and rewrites
shared/packages/app-kit/src/art.generated.js        ← GENERATED. Never hand-edit.
        ↓  merged UNDER spec.catalog by
defineLeumasApp()  →  listing().catalog  →  the store card, the detail view, /apps/<slug>
```

**A hand-declared value always wins.** `const catalog = { ...artFor(id), ...(spec.catalog || {}) }`
— the generated art only ever fills a slot nobody claimed.

**Why it must go through the declaration.** `bulkUpsert` recomputes a content hash from each app's
declaration on every boot and rewrites the row when it differs. Art written onto a listing at
runtime survives until the next API restart and then vanishes with nothing in the logs. This is the
single most important fact on this page.

## The slots

| slot | size | lands on | cap |
|---|---|---|---|
| `icon` | 512×512, transparent rounded corners | `catalog.logoUrl` · launcher · OS window icon | 40KB |
| `card` | 1200×750 | `catalog.imageUrl` — the store tile, the detail strip **and** the funnel hero | 120KB |
| `wide` | 1600×400 | nav `image:` · room banners | 120KB |

There is **no 1920×1080 hero slot**, and that is a measurement rather than an oversight:
`listingMedia` (`shared/packages/ui/src/store/media.js`) is the ONE resolver the card, the detail
view and the funnel all go through, and FunnelLayout puts its result in a contained panel beside the
copy — not full-bleed. A hero would be 62 files and ~3.7MB feeding a consumer that does not exist.

## One compositor, three input tiers

Every image is the same six layers in the same order — ink base, accent bloom, **motif field**,
vignette, glass panel, glyph. Only the field, the hue and the glyph change. That is what makes 62
procedurally-different pictures read as one store rather than 62 experiments.

The **field** has two sources:

1. **procedural** — one of eight motifs (`traces` `flow` `lattice` `waveform` `orbit` `strata`
   `aperture` `mesh`), chosen by category, drawn from `sha256(appId)`. Free, and the default.
2. **a commissioned still** — abstract texture, composited under the same layers by `ingest`.

**A screenshot is deliberately NOT one of them.** Compositing a card over its capture was built and
is worse than either half: the panel and the 350px glyph sit on the app's UI like a watermark. It is
also unnecessary — `listingShots` already ranks real screenshots ahead of `imageUrl`, so a captured
app shows its UI and an uncaptured one shows its key art, decided by the one resolver every surface
already uses. `shoot` fills the gallery and touches nothing else.

## Determinism is the feature, not a detail

`sha256(appId)` drives motif, hue offset, angle and density. No `Math.random`, no `Date`, no
filesystem reads. The same app renders **byte-identically** on any machine on any day, so a
re-render is a no-op and nobody has to wonder whether running the tool will churn the repo.

If you change anything in `lib/`, `render --force` rewrites exactly the files your change affected —
and `git status` is the diff of your art direction.

## Rules that are not style preferences

- **Never `.svg` in a served category.** `/library/*` emits bytes with no CSP, so a served SVG is
  executable markup on our own origin. SVG is a compositing intermediate here; what ships is `.webp`.
  `appart` is on the `CATEGORIES` allowlist in `shared/services/datacenter/src/index.js` — a new
  category that is not on it 404s every file with no error anywhere.
- **Content-addressed filenames, always.** `/library/*` is served `immutable, max-age=31536000`. A
  stable name makes a re-render invisible for a year. `render` deletes the superseded file, because
  two files for one slot is an old picture some browsers keep serving.
- **No text in the art.** Every surface already renders the app's name in the DOM beside it, so a
  baked wordmark duplicates it and then disagrees the day someone renames the app — and `<text>`
  needs a font on the rendering machine, which ends determinism. Art behind a nameplate, never a
  nameplate in the art.
- **No SVG filters, and no `<image>`.** Rasterizer support varies and a missing feature fails by
  rendering *nothing*, indistinguishable from a correct render on a dark field. Glow is a second,
  thicker, low-opacity stroke; photographic layers are composited by **sharp**. librsvg does not
  load `<image href="data:…">` and does not say so — 25 cards shipped as 8kB flat gradients with
  every check passing (valid webp, right size, under cap, unique bytes).
- **One asset serves both themes.** Graded dark-neutral; light mode's chrome frames it exactly as it
  frames a screenshot.
- **Raise the encoder's pressure, never the cap.** The 8MB total on `appart/` exists because "one
  more variant" is always locally reasonable and permanently grows every clone of this repo.
- **Never hand-edit `art.generated.js`.** It is rebuilt from the directory listing, which is what
  makes it incapable of naming a file that is not there.

## Three things only measurement caught

- **Motifs need per-motif energy weights.** One field opacity is the obvious design and is visibly
  wrong: `aperture` and `strata` fill area with solid shapes while `mesh` and `orbit` are hairlines,
  so at one opacity the wedge motifs read as a starburst that swamps the glyph. See `MOTIF_ENERGY`.
- **Grade to the MEASURED median, PER SLOT.** An icon is a rounded tile whose glass panel covers the
  whole canvas, so it is legitimately far brighter than a 4:1 banner that is mostly field — measured
  ungraded, icon median 55.8, card 36.3, wide 33.1. One shared target of 36 therefore darkened every
  icon by ~35%, a correction that looked like grading and was destroying the slot's design. Each
  `SLOTS[slot].luma` is its own measured median; grading pulls in outliers and never moves the slot.
  (Result: per-slot spread fell from ~11 to ~7.5 with every median unchanged.)
- **The art files must be `git add`ed, and the guard checks that FIRST.** Every other check reads the
  working tree, so on a machine where the art exists but was never added, all of them pass and a
  clean clone ships nothing. `check-deck-assets.mjs` once went green on 89 untracked files.

## Adding an app

Nothing to do. `render` reads `shared/apps/registry.js`, so a newly declared app is drawn on the
next run — and `pnpm check:app-art`'s coverage ratchet fails the build until it is.

## When a card still shows a glyph

In order:

1. `node ops/tools/app-art/index.mjs plan` — is the slot rendered at all?
2. `pnpm check:app-art` — does the manifest url resolve on disk?
3. Is the app's `spec.catalog` declaring an `imageUrl` by hand? A declared value wins, by design.
4. Did the API restart since the manifest changed? The listing row is rewritten at boot.

## Related

- `ops/tools/app-art/README.md` — the tool's own reference.
- `ops/tools/deck-assets/` — the Operator Deck's media pipeline, whose README is where the grading,
  keying and cap lessons were paid for.
- `shared/packages/ui/src/store/media.js` — `listingShots`/`listingMedia`, the ONE resolver deciding
  which picture an app "has". Change art precedence there, never in a component.


---
Source: .claude/skills/leumas-app-art/SKILL.md
Canonical: https://docs.leumas.tech/p/skills/leumas-app-art
