ADRs
ADR 0041 — User-added custom icons
  • 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).

ColumnTypeNotes
idtext PKbic-<8-char nano>
project_idtext NOT NULL, FK → project.id ON DELETE CASCADE
nametext NOT NULLdesigner-supplied slug, [a-z][a-z0-9-]{0,40} (e.g. acme-logo, pulse-mark)
varianttext NOT NULLsolid | line — same enum as Foundation icons
svgtext NOT NULLsanitized SVG markup (see §3)
rtltext NOT NULL DEFAULT 'keep'flip | keep — required at upload per ADR 0040
viewboxtext NOT NULL DEFAULT '0 0 24 24'from the sanitized SVG, falls back to default
licensetext NOT NULLowned | ofl | commercial | embedded | other (same enum as brand_font)
license_reftext NOT NULL≥12 chars
user_idtext NULL, FK → user.id ON DELETE SET NULLauthor
created_attimestamptz 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:

  1. Name — slug (validated against the regex above). Auto-suggested from the filename, stripped of Icon / Logo suffixes.
  2. Variant — picker. Defaults to line for stroke-only paths, solid when the SVG has fill declarations on its top-level paths. Designer can override.
  3. 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").
  4. 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 any on* event attributes.
  • Strip <foreignObject> (lets HTML in, defeats sanitization).
  • Strip <style> blocks (inline style attributes preserved).
  • Strip xlink:href referencing anything other than #fragment (no external font loads, no data: URLs in v1).
  • Strip non-SVG namespaces (e.g. <g xmlns:custom="...">).
  • Resolve viewBox from the root <svg> element. If missing, fall back to '0 0 24 24' and surface a warning to the designer.
  • Enforce currentColor for fill / stroke where 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 as brand_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 currentColor semantics 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.