# leumas-brand-marks

The creature each product and domain wears — briefing a mascot, turning it into a favicon, and the four surfaces it reaches. Read before touching any favicon, icon link or domain icon.


# Creature marks

Every Leumas surface that answers "who am I" used to answer it the same way. Measured before
`ops/tools/brand-marks` existed:

- `products/leumas-web/public/favicon.svg` and `products/leumas-studio/public/favicon.svg` were the
  **same drawing** — one azure→violet "L", with the gradient stops swapped.
- `products/leumas-admin/index.html` linked `/logo.svg` into a product with **no `public/`
  directory at all**. That link had 404'd for as long as it existed.
- **No domain anywhere carried a mark, a colour or an emoji** — only a glyph NAME, so two domains
  reading `icon: 'coins'` were indistinguishable by construction.

All three are invisible by design: a dead icon link renders the browser's blank-page glyph with no
console error and no failed build, and a duplicated one just looks like a house style.

## The one command

```bash
pnpm marks:plan                                    # which of the 15 have art, which do not
pnpm marks:prompts --json                          # the ip-as-logo briefs, six per mark
pnpm marks — ingest <file> --mark domain:ai       # accept the candidate you picked
pnpm marks:encode                                  # masters → favicons + manifest
pnpm check:brand-marks                             # tracked · present · unique · links resolve · in budget
```

`plan` · `prompts` · `ingest` · `encode` · `verify` need **no server, no auth, no credits and no
generation API**. Only `prompts --json` is meant to be fed to one. A tool that needed credits to
tell you what art you already have would be useless on the day you need it most.

## The fifteen

`shared/apps/marks.js` is the register — hand-authored, zero imports, read during Studio's first
paint. Never quote a count from here; run `pnpm marks:plan`.

| namespace | what it names | examples |
|---|---|---|
| `brand:` | the house mark, and the fallback for everything unmapped | `brand:leumas` — phoenix |
| `product:` | a directory under `products/` | `product:web` spider · `product:studio` octopus · `product:admin` lion |
| `domain:` | a row in `shared/apps/domains.js` or Studio's `nav.manifest.js` | `domain:hosting` turtle · `domain:ai` owl |
| `manifest:` | a nav manifest ROOT — not a domain | `manifest:studio` hammer |
| `surface:` | a route with no domain of its own | `surface:dashboard` globe (`/admin`) |

**The namespace has to be honest about what a row is.** `manifest:studio` exists because there is no
`/admin/d/studio` route and no `studio` row in `DOMAINS` — `id: 'studio'` in `nav.manifest.js` is
the STUDIO_NAV root. Calling it `domain:` would have made `markFor('studio')` answer with it through
`MARKS_BY_DOMAIN`, which is a lie that renders perfectly.

**Two owls are deliberate.** `domain:ai` is forward-facing in violet, `product:docs` is side-perched
in emerald. Same species reads as siblings; they never collide in a 32×32 tab.

**Studio wears two creatures on purpose.** The octopus is the browser tab — eight arms on eight
domains, what Studio is from outside. The hammer is the in-app nav glyph — the tool you are holding,
what Studio is from inside.

## How a mark is made

Adapted from [`s1dashu/ip-as-logo-skill`](https://github.com/s1dashu/ip-as-logo-skill) (MIT): flat,
cute, personified square mascots built to survive 32×32. That constraint **is** the favicon
constraint, which is why it fits.

```
shared/apps/marks.js          creature + accent, declared
        ↓  lib/brief.js       six briefs — 3 directions × 2 lower corners, A1/A2/B1/B2/C1/C2
   an image model             seedream_v5_pro via the Higgsfield MCP, 1:1, 1k
        ↓  YOU pick one       the source skill is explicit: a stochastic draw, no auto-ranking
   ingest → .inbox/           re-encoded to a 512² webp, measured
        ↓  encode
shared/services/datacenter/src/library-assets/logos/<slug>-mark-<sha8>.webp  → /library/logos/…
   products/<p>/public/       favicon-32.png · favicon-192.png · apple-touch-icon.png · favicon.ico
        ↓  and rewrites
shared/apps/marks.generated.js   ← GENERATED. Never hand-edit.
```

### The model

`seedream_v5_pro`, chosen by measurement rather than preference. It was probed head to head against
`recraft_v4_1` (`model_type: utility_vector`) on the same phoenix brief: Recraft returns real SVG and
lets you pin the palette exactly, which sounds decisive — but it drew thin strokes and hollow eye
outlines that turn to mud at 32px, against a rule the brief states as "one solid silhouette with no
outline". It also costs 2.5 credits against Seedream's 1.5. Re-probe before switching; do not switch
on the argument that vector output must be better.

### The rules that are not style preferences

Every one is in `HOUSE` in `ops/tools/brand-marks/lib/brief.js`, byte-identical in all fifteen
prompts. That invariance is what makes fifteen independently-drawn pictures read as one set.

- **Never say "logo", "icon", "brand mark", "favicon" or "emblem" in a generation prompt.** Those
  words pull hard toward flat corporate geometry — a swoosh, a monogram, a shield — and away from the
  personified character this whole system is. `assertNoBannedWords` refuses to emit a prompt
  containing one, because a single accidental "logo" produces a picture that looks fine and ships.
- **Readable at 32×32 is the arbiter.** Anything invisible there is omitted rather than shrunk.
- **Both members of every pair are drawn.** A model asked for simplicity drops one, and a one-winged
  bird is the most obviously-wrong output this pipeline can produce.
- **Exactly three colours**, and they come from `ACCENT_PRESETS` in `@leumas/theme` — the accent's
  `value` and `strong` plus a `#12141c` ground. Not "a limited palette": three, named, per mark.
- **Emerging from an assigned lower corner at 85–95% fill.** A centred mascot at 32×32 is a small
  blob ringed by dead background.
- **No text in the art**, ever. Every surface renders the thing's name in the DOM beside it.

## Rules for the files

- **Never `.svg` in a served category.** `/library/*` emits bytes with no CSP, so a served SVG is
  executable markup on our own origin. `logos` is already on the `CATEGORIES` allowlist in
  `shared/services/datacenter/src/index.js` — a category that is *not* on it 404s every file with no
  error anywhere. Product-local `public/` is a different path; those are PNG and ICO.
- **Content-addressed filenames, always.** `/library/*` is served `immutable, max-age=31536000`. A
  stable name would make a re-render invisible for a year. `encode` deletes the superseded file.
- **`favicon.ico` is hand-built, with no dependency.** `sharp` cannot encode ICO; `lib/ico.js` writes
  the `ICONDIR` envelope around three PNG payloads. A browser asks for `/favicon.ico` whether or not
  any page links it.
- **One opaque asset serves every surface.** A transparent mark looks better in exactly one place (a
  light-themed tab strip) and worse in the taskbar, the nav, and a dark tab.
- **Never hand-edit `marks.generated.js`.** It is rebuilt from the directory listing, which is what
  makes it incapable of naming a file that is not there.

## Where a mark reaches — and the one resolver

`markIcon(id, fallbackGlyph)` in `shared/apps/mark-icon.js` is the **only** thing that decides which
picture something wears. It returns the creature's url when one has been drawn and the caller's
existing glyph name until then, so wiring a mark in is a **no-op until the picture exists**.

**Never paste a mark url into a file.** The urls are content-addressed, so a pasted one goes stale on
the next re-render, silently, in whichever file nobody remembered.

`renderIcon` (`shared/packages/ui/src/icons/resolve.jsx`) already accepts a root-relative image URL
as well as a glyph name — which is why three of the four surfaces below needed no new resolver:

| surface | wired at |
|---|---|
| browser tab, per product | the `<link rel="icon">` set in each `products/<p>/index.html` |
| browser tab, per **domain** | `useFavicon` at `DomainRoutes.jsx`'s `activeDomain` — Studio is one document that is forty places |
| the door's entrance | `DefaultFallback` in `DomainRoutes.jsx`, over the console skeleton |
| nav, doors, store, OS launcher | `icon:` in `nav.manifest.js` and `defineDomainApp` |

`useFavicon` in `@leumas/features` takes a **URL, not a mark id** — resolving one needs
`@leumas/apps`, and features deliberately keeps no edge to it. [critical] It never sets an empty `href`:
assigning `''` makes the browser request the *page URL* as an icon, which returns HTML, fails to
decode, and leaves the tab blank with nothing in the console.

[critical] The door's placeholder has its own stylesheet, `routes/doorload.css`, imported by
`DomainRoutes.jsx`. `domainhud.css` is inside the door's **lazy chunk**, so styling the fallback from
there means it renders unstyled for its entire life and the rules land just in time to be discarded.

## The guard

`pnpm check:brand-marks` — six checks, each closing one silent regression.

1. **Tracked-ness, from git, FIRST, unconditionally.** Every other check reads the working tree, so
   on the machine that generated the art they all pass and a clean clone ships nothing.
   `check-deck-assets.mjs` once went green on 89 files that had never been added.
2. Coverage — a mark declared and never generated.
3. Uniqueness **by content hash** — the exact failure the two identical "L" favicons were.
4. The manifest and the disk agree.
5. **Every `<link rel="icon">` href resolves.** The check that would have caught Admin on day one.
6. Per-file and total caps, and nothing but `.webp` under `logos/`.

Check 5 reports a link as **pending** rather than dead when its product's mark has no master yet —
`encode` writes every favicon from that master, so check 2 has already failed on it by name, and
reporting one fact as two failures makes the second noise. Pending links are still counted out loud.

## Adding a mark

Add a row to `shared/apps/marks.js`, then `pnpm marks:prompts --mark <id>`, generate, ingest, encode.
Wire it with `markIcon('<id>', '<the glyph it currently uses>')` — never a pasted url. The guard's
coverage check fails the build until the art exists, which is the point.

## Related

- `ops/tools/brand-marks/README.md` — the tool's own reference.
- `leumas-app-art` — the *other* art pipeline. app-art enumerates `shared/apps/registry.js` and draws
  deterministically from `sha256(appId)`: free, never needs a human, and never calls a model. This
  one enumerates `marks.js`, cannot render anything on its own, and has a person in the middle
  picking one of six. Two different jobs; they share `sharp` and the naming rule and nothing else.
- `shared/packages/ui/src/icons/resolve.jsx` — `renderIcon`, the resolver that makes a url and a
  glyph name interchangeable everywhere an `icon:` is read.


---
Source: .claude/skills/leumas-brand-marks/SKILL.md
Canonical: https://docs.leumas.tech/p/skills/leumas-brand-marks
