{
  "schema": "leumas.docs.page/1",
  "id": "skill:leumas-app-art",
  "slug": "skills/leumas-app-art",
  "kind": "tools",
  "bucket": "skill",
  "title": "leumas-app-art",
  "name": "Leumas App Art",
  "eyebrow": null,
  "chip": null,
  "summary": "App-store imagery for every Leumas app — store tile, listing logo, nav banner and screenshots — rendered by one deterministic compositor, never drawn by hand.",
  "keywords": [
    "leumas-app-art",
    "compositor",
    "screenshots",
    "app-store",
    "tile",
    "banner",
    "determinism feature detail",
    "logo"
  ],
  "audience": "both",
  "funnel": {
    "product": null,
    "cta": null
  },
  "body": "\n# App-store art in Leumas\n\nEvery listing wants four pictures, and before `ops/tools/app-art` existed it had none: of 62\nfirst-party apps, **0** had a `logoUrl`, **0** had an `imageUrl` and **0** had a screenshot. The 25\ndomain apps had a nav `image:`, but only 6 pointed at real key art — the other 19 borrowed\nOperator-Deck banners, so several apps showed *the same picture as each other*.\n\nBoth faults are invisible by design: a missing image falls back to the app's glyph (a deliberate\ndesign, not a broken-image box), and a duplicated one just looks like art direction.\n\n## The one command\n\n```bash\nnode ops/tools/app-art/index.mjs plan       # coverage, budget, motif spread, shared glyphs\nnode ops/tools/app-art/index.mjs render     # draw what is missing, rebuild the manifest\npnpm check:app-art                          # present · unique · committed · in budget\n```\n\n`plan` · `render` · `manifest` · `verify` · `prompts` · `ingest` need **no server, no auth, no\ncredits and no generation API**. That is the point: keeping the store's art current must never\ndepend on something being up.\n\nTwo more, for when you want real pictures:\n\n```bash\nnode ops/tools/app-art/index.mjs shoot                 # photograph every app that has a surface\nnode ops/tools/app-art/index.mjs prompts --json        # 12 flagship briefs, for a media MCP\nnode ops/tools/app-art/index.mjs ingest still.png --app media\n```\n\n`shoot` needs Studio + the API up and **signs itself in** with the seeded admin — the same\ncredentials every smoke suite uses. There is no cookie to paste and no new endpoint. It checks each\norigin is reachable FIRST, because an unreachable one does not fail, it *photographs*: a 404 and a\nconnection-error page both capture into a perfectly valid `.webp` that nothing downstream can tell\nfrom a screenshot.\n\n**Leumas OS windows cannot be photographed yet** (`--harness` is wired but the route it needs does\nnot exist — `@leumas/os` has no deep link and `OsDesktop` takes no `open` prop). Those apps keep\ntheir procedural art.\n\n## How art reaches a listing\n\n```\nops/tools/app-art render\n        ↓  writes .webp files\nshared/services/datacenter/src/library-assets/appart/<id>-<slot>-<sha8>.webp   → /library/appart/…\n        ↓  and rewrites\nshared/packages/app-kit/src/art.generated.js        ← GENERATED. Never hand-edit.\n        ↓  merged UNDER spec.catalog by\ndefineLeumasApp()  →  listing().catalog  →  the store card, the detail view, /apps/<slug>\n```\n\n**A hand-declared value always wins.** `const catalog = { ...artFor(id), ...(spec.catalog || {}) }`\n— the generated art only ever fills a slot nobody claimed.\n\n**Why it must go through the declaration.** `bulkUpsert` recomputes a content hash from each app's\ndeclaration on every boot and rewrites the row when it differs. Art written onto a listing at\nruntime survives until the next API restart and then vanishes with nothing in the logs. This is the\nsingle most important fact on this page.\n\n## The slots\n\n| slot | size | lands on | cap |\n|---|---|---|---|\n| `icon` | 512×512, transparent rounded corners | `catalog.logoUrl` · launcher · OS window icon | 40KB |\n| `card` | 1200×750 | `catalog.imageUrl` — the store tile, the detail strip **and** the funnel hero | 120KB |\n| `wide` | 1600×400 | nav `image:` · room banners | 120KB |\n\nThere is **no 1920×1080 hero slot**, and that is a measurement rather than an oversight:\n`listingMedia` (`shared/packages/ui/src/store/media.js`) is the ONE resolver the card, the detail\nview and the funnel all go through, and FunnelLayout puts its result in a contained panel beside the\ncopy — not full-bleed. A hero would be 62 files and ~3.7MB feeding a consumer that does not exist.\n\n## One compositor, three input tiers\n\nEvery image is the same six layers in the same order — ink base, accent bloom, **motif field**,\nvignette, glass panel, glyph. Only the field, the hue and the glyph change. That is what makes 62\nprocedurally-different pictures read as one store rather than 62 experiments.\n\nThe **field** has two sources:\n\n1. **procedural** — one of eight motifs (`traces` `flow` `lattice` `waveform` `orbit` `strata`\n   `aperture` `mesh`), chosen by category, drawn from `sha256(appId)`. Free, and the default.\n2. **a commissioned still** — abstract texture, composited under the same layers by `ingest`.\n\n**A screenshot is deliberately NOT one of them.** Compositing a card over its capture was built and\nis worse than either half: the panel and the 350px glyph sit on the app's UI like a watermark. It is\nalso unnecessary — `listingShots` already ranks real screenshots ahead of `imageUrl`, so a captured\napp shows its UI and an uncaptured one shows its key art, decided by the one resolver every surface\nalready uses. `shoot` fills the gallery and touches nothing else.\n\n## Determinism is the feature, not a detail\n\n`sha256(appId)` drives motif, hue offset, angle and density. No `Math.random`, no `Date`, no\nfilesystem reads. The same app renders **byte-identically** on any machine on any day, so a\nre-render is a no-op and nobody has to wonder whether running the tool will churn the repo.\n\nIf you change anything in `lib/`, `render --force` rewrites exactly the files your change affected —\nand `git status` is the diff of your art direction.\n\n## Rules that are not style preferences\n\n- **Never `.svg` in a served category.** `/library/*` emits bytes with no CSP, so a served SVG is\n  executable markup on our own origin. SVG is a compositing intermediate here; what ships is `.webp`.\n  `appart` is on the `CATEGORIES` allowlist in `shared/services/datacenter/src/index.js` — a new\n  category that is not on it 404s every file with no error anywhere.\n- **Content-addressed filenames, always.** `/library/*` is served `immutable, max-age=31536000`. A\n  stable name makes a re-render invisible for a year. `render` deletes the superseded file, because\n  two files for one slot is an old picture some browsers keep serving.\n- **No text in the art.** Every surface already renders the app's name in the DOM beside it, so a\n  baked wordmark duplicates it and then disagrees the day someone renames the app — and `<text>`\n  needs a font on the rendering machine, which ends determinism. Art behind a nameplate, never a\n  nameplate in the art.\n- **No SVG filters, and no `<image>`.** Rasterizer support varies and a missing feature fails by\n  rendering *nothing*, indistinguishable from a correct render on a dark field. Glow is a second,\n  thicker, low-opacity stroke; photographic layers are composited by **sharp**. librsvg does not\n  load `<image href=\"data:…\">` and does not say so — 25 cards shipped as 8kB flat gradients with\n  every check passing (valid webp, right size, under cap, unique bytes).\n- **One asset serves both themes.** Graded dark-neutral; light mode's chrome frames it exactly as it\n  frames a screenshot.\n- **Raise the encoder's pressure, never the cap.** The 8MB total on `appart/` exists because \"one\n  more variant\" is always locally reasonable and permanently grows every clone of this repo.\n- **Never hand-edit `art.generated.js`.** It is rebuilt from the directory listing, which is what\n  makes it incapable of naming a file that is not there.\n\n## Three things only measurement caught\n\n- **Motifs need per-motif energy weights.** One field opacity is the obvious design and is visibly\n  wrong: `aperture` and `strata` fill area with solid shapes while `mesh` and `orbit` are hairlines,\n  so at one opacity the wedge motifs read as a starburst that swamps the glyph. See `MOTIF_ENERGY`.\n- **Grade to the MEASURED median, PER SLOT.** An icon is a rounded tile whose glass panel covers the\n  whole canvas, so it is legitimately far brighter than a 4:1 banner that is mostly field — measured\n  ungraded, icon median 55.8, card 36.3, wide 33.1. One shared target of 36 therefore darkened every\n  icon by ~35%, a correction that looked like grading and was destroying the slot's design. Each\n  `SLOTS[slot].luma` is its own measured median; grading pulls in outliers and never moves the slot.\n  (Result: per-slot spread fell from ~11 to ~7.5 with every median unchanged.)\n- **The art files must be `git add`ed, and the guard checks that FIRST.** Every other check reads the\n  working tree, so on a machine where the art exists but was never added, all of them pass and a\n  clean clone ships nothing. `check-deck-assets.mjs` once went green on 89 untracked files.\n\n## Adding an app\n\nNothing to do. `render` reads `shared/apps/registry.js`, so a newly declared app is drawn on the\nnext run — and `pnpm check:app-art`'s coverage ratchet fails the build until it is.\n\n## When a card still shows a glyph\n\nIn order:\n\n1. `node ops/tools/app-art/index.mjs plan` — is the slot rendered at all?\n2. `pnpm check:app-art` — does the manifest url resolve on disk?\n3. Is the app's `spec.catalog` declaring an `imageUrl` by hand? A declared value wins, by design.\n4. Did the API restart since the manifest changed? The listing row is rewritten at boot.\n\n## Related\n\n- `ops/tools/app-art/README.md` — the tool's own reference.\n- `ops/tools/deck-assets/` — the Operator Deck's media pipeline, whose README is where the grading,\n  keying and cap lessons were paid for.\n- `shared/packages/ui/src/store/media.js` — `listingShots`/`listingMedia`, the ONE resolver deciding\n  which picture an app \"has\". Change art precedence there, never in a component.\n",
  "source": {
    "path": ".claude/skills/leumas-app-art/SKILL.md",
    "blobSha": "",
    "commit": "",
    "committedAt": "",
    "provenance": "no-git",
    "bytes": 9647,
    "hash": "931dc8805b0df907ceecd6524aea8991bbe6db3c"
  },
  "urls": {
    "html": "/p/skills/leumas-app-art",
    "json": "/docs/skills/leumas-app-art.json",
    "md": "/docs/skills/leumas-app-art.md"
  },
  "links": {
    "composes": [],
    "usedBy": [],
    "product": [],
    "howTo": [],
    "skills": []
  }
}
