- Date: 2026-06-06
- Status: Accepted
- Phase / Feature: Foundation v1 · B2 (icons) custom-upload ritual
- Closes: ADR 0036 Q3 — "User-added custom icon mechanism"
- Builds on: ADR 0036 §4 (icons), ADR 0038 (brand-font upload + license pattern), ADR 0040 (RTL flag per icon), T7.4 (per-icon + sprite delivery)
Context
A team brings logos, brand marks, vertical-specific glyphs (medical / financial / industrial), and custom UI affordances. The 7 v1 essentials + future 100 design pass will never cover this surface — the project needs a way to ingest its own icons and ship them through the Foundation pipeline.
ADR 0036 §4 promised "icons custom-designed, with Solid/Line variants, RTL-aware" — for the Foundation set. User-added icons must fit the same shape so a designer mixing Foundation icons with brand icons in one canvas doesn't see two render paths.
This ADR settles ingestion ritual, storage, sanitization, RTL flag capture at upload, and license attestation — parallel to ADR 0038 BYOF but for SVG instead of font binary.
Decision
§1 — Storage shape
New table brand_icon. Same single-project pattern as brand_font (ADR 0038 §2).
| Column | Type | Notes |
|---|---|---|
id | text PK | bic-<8-char nano> |
project_id | text NOT NULL, FK → project.id ON DELETE CASCADE | |
name | text NOT NULL | designer-supplied slug, [a-z][a-z0-9-]{0,40} (e.g. acme-logo, pulse-mark) |
variant | text NOT NULL | solid | line — same enum as Foundation icons |
svg | text NOT NULL | sanitized SVG markup (see §3) |
rtl | text NOT NULL DEFAULT 'keep' | flip | keep — required at upload per ADR 0040 |
viewbox | text NOT NULL DEFAULT '0 0 24 24' | from the sanitized SVG, falls back to default |
license | text NOT NULL | owned | ofl | commercial | embedded | other (same enum as brand_font) |
license_ref | text NOT NULL | ≥12 chars |
user_id | text NULL, FK → user.id ON DELETE SET NULL | author |
created_at | timestamptz NOT NULL DEFAULT now() |
Unique index (project_id, name, variant) — designer can ship acme-logo in both solid and line flavors, but cannot collide on either.
§2 — Ingestion ritual
Drop SVG file → designer fills:
- Name — slug (validated against the regex above). Auto-suggested from the filename, stripped of
Icon/Logosuffixes. - Variant — picker. Defaults to
linefor stroke-only paths,solidwhen the SVG hasfilldeclarations on its top-level paths. Designer can override. - RTL behavior — required radio choice: "Mirror in RTL (directional)" vs. "Keep as-is (logo / content)". No silent default. Listed second in the form so the designer makes the call before clicking Submit (ADR 0040 §"Open questions").
- License attestation — same five-option radio + ≥12 char reference as ADR 0038 §2 BYOF.
POST /api/v1/projects/:projectId/brand/icons.
§3 — Server-side sanitization
Trust client never. The uploaded SVG is sanitized before insert:
- Strip
<script>tags and anyon*event attributes. - Strip
<foreignObject>(lets HTML in, defeats sanitization). - Strip
<style>blocks (inlinestyleattributes preserved). - Strip
xlink:hrefreferencing anything other than#fragment(no external font loads, nodata:URLs in v1). - Strip non-SVG namespaces (e.g.
<g xmlns:custom="...">). - Resolve
viewBoxfrom the root<svg>element. If missing, fall back to'0 0 24 24'and surface a warning to the designer. - Enforce
currentColorforfill/strokewhere the SVG ships hard-coded#000/#fff— the designer is notified and chooses to convert or keep hard-coded.
The sanitization helper lives in apps/api/src/icon-sanitize.ts. The same helper runs in a unit test against a fixture set to lock down the rules.
§4 — Render parity with Foundation icons
A <BrandIcon> React component (Phase B+ when editor surface lands) reads the stored SVG and pipes it through the same IconBase from _shared.tsx:
<BrandIcon name="acme-logo" variant="solid" rtl={icon.rtl} viewBox={icon.viewbox}>
{dangerouslySetInnerHTML: { __html: icon.svg }}
</BrandIcon>This route does NOT exist in v1 — render parity needs the editor surface and the per-icon API to be wired. What ships with this ADR is the schema + sanitization + endpoints. The render React component lands together with the editor.
§5 — Sprite assembly
Foundation's sprite (ADR 0040 §4) ships at build time. Brand icons cannot live in that static artifact — they're per-project, user-supplied. Two delivery options:
- Inline render (v1 default): the editor renders
<BrandIcon>inline. No sprite. Trivial; no extra round-trips. - Per-project sprite (future): aggregate the project's brand icons into a runtime sprite served from
/api/v1/projects/:projectId/brand/icons-sprite.svg. Useful when the ARNO canvas pulls thousands of icon instances. Deferred until the canvas has measured the volume.
§6 — Endpoints
POST /api/v1/projects/:projectId/brand/icons— body{ name, variant, svg, rtl, license, licenseRef }. Sanitizes, inserts, returns{ id, name, variant, viewbox }.GET /api/v1/projects/:projectId/brand/icons— list (svg included; small enough at v1 scale, no separate fetch).DELETE /api/v1/projects/:projectId/brand/icons/:iconId— hard delete. Same rationale asbrand_font(ADR 0038 §2): license audit ownership lives in file presence.
Anti-patterns explicit
- Do not accept an unsanitized SVG. The sanitization gate is the security boundary; never store raw markup.
- Do not make RTL a default. The form forces the designer to pick.
- Do not support multi-color palette overrides inside the icon body at v1. Stick to
currentColorsemantics so the icon inherits text color. Brand-import bindings (ADR 0038) are how a designer recolors at scale; embedding palette overrides inside SVG fights that. - Do not soft-delete brand icons. Same audit rationale as
brand_font.
Open questions / parking
- Multi-color brand icons. A logo with three brand colors needs a way to express "this stays brand-primary, that stays brand-secondary." Defer — for v1 designers single-color brand logos OR ship the multi-color via
<svg>literal in a Foundation-side React component (not via this upload path). - Sprite delivery (§5) when icon counts grow.
- Cross-project brand kits (parked in ADR 0038) — same parking for the icon side.