Docs
/

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

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:

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

slotsizelands oncap
icon512×512, transparent rounded cornerscatalog.logoUrl · launcher · OS window icon40KB
card1200×750catalog.imageUrl — the store tile, the detail strip and the funnel hero120KB
wide1600×400nav image: · room banners120KB

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.

  1. 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 added, 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.
  • 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.jslistingShots/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 (no-git)markdownjson
Generated from the Leumas repository. Every page cites the file it came from.leumas.techllms.txt