Glyphs & icons

Sheru themes are pure data. A theme ships no React, no functions, no DOM — just tokens, a scoped stylesheet, chrome config, and declarative artwork. The artwork is expressed in a JSON glyph format (GlyphDrawing) that is validated at register time and rendered to SVG. This page documents that format: the drawing model, the shape union, the paint grammar, the two consumers (window controls and file icons), the validation guards, and the per-instance gradient renderer.

All five bundled themes (aqua, win98, winxp, win7, xfce) render their caption clusters and file icons from this data.

For the surrounding theme contract (tokens, parts, registerTheme/applyTheme, contract versioning), see authoring a theme.

Why declarative

A theme is a JSON-serializable SheruTheme object — it imports nothing from react and names no ComponentType. That is the whole point: a theme that carries no executable JavaScript is sandbox-by-construction. Window-control and file-icon artwork is geometry, so it can be expressed as data too. Encoding it as GlyphDrawing JSON means:

The geometry fields are the only place a theme touches "shape". Everything that is paint (gel sheens, bevels, hover reveals) still lives in the theme's scoped stylesheet and is keyed off the same [data-part] hooks.

The drawing model: GlyphDrawing

One complete drawing is a viewBox, optional gradient defs, and an ordered list of shapes painted back-to-front:

export interface GlyphDrawing {
  viewBox: [number, number, number, number];
  shapeRendering?: "auto" | "crispEdges" | "geometricPrecision";
  defs?: GradientDef[];
  shapes: GlyphShape[];
}

shapeRendering: "crispEdges" is what keeps Win98's pixel artwork sharp at any scale. defs are gradient paint servers local to this drawing — their ids are rewritten per render instance (see the renderer) so two folder glyphs on screen can't collide on the same gradient id.

The shape union: GlyphShape

Each shape is one tagged object; the renderer maps it to exactly one SVG element. The union covers every primitive the five era themes need:

export type GlyphShape =
  | PathShape
  | RectShape
  | CircleShape
  | LineShape
  | PixelsShape
  | TextShape;

path

An SVG <path>. The d string is validated by the path lexer at register time (see validation):

export interface PathShape {
  kind: "path";
  d: string;
  fill?: GlyphPaint;
  stroke?: GlyphPaint;
  strokeWidth?: number;
  strokeLinecap?: GlyphLineCap;
  strokeLinejoin?: GlyphLineJoin;
  fillRule?: GlyphFillRule;
}

rect, circle, line

Straightforward primitives. rect adds rx (corner radius); line requires a stroke. xfce's minimize control is a single line; Win98's minimize bar is a rect:

{ kind: "rect", x: 1, y: 5, width: 6, height: 2, fill: "currentColor" }

pixels

A pixel grid: each origin is the [x, y] of one width×height rect. This serializes Win98's pixel-perfect close × directly so crispEdges pixel artwork survives the round-trip into data:

export interface PixelsShape {
  kind: "pixels";
  origins: [number, number][];
  width: number;
  height: number;
  fill?: GlyphPaint;
}

Win98's close glyph — the classic 8×7 two-pixel-wide ×, as [x, y] origins of 2×1 rects:

close: {
  viewBox: [0, 0, 8, 7],
  shapeRendering: "crispEdges",
  shapes: [
    {
      kind: "pixels",
      width: 2,
      height: 1,
      fill: "currentColor",
      origins: [
        [0, 0], [6, 0],
        [1, 1], [5, 1],
        [2, 2], [4, 2],
        [3, 3],
        [2, 4], [4, 4],
        [1, 5], [5, 5],
        [0, 6], [6, 6],
      ],
    },
  ],
},

text

An era extension badge. source selects the runtime-fed extension string (upper- or lower-cased), truncated to maxChars. fontFamily is a raw CSS font stack — not --font-ui/--font-mono — because each era's badge font differs from the UI font and the glyph carries its own stack verbatim:

export interface TextShape {
  kind: "text";
  x: number;
  y: number;
  source: "ext-upper" | "ext-lower";
  maxChars?: number;
  fontFamily: string;
  fontSize: number;
  fontWeight?: number | "normal" | "bold";
  fill?: GlyphPaint;
  textAnchor?: "start" | "middle" | "end";
}

Win98's file glyph stamps the uppercased extension in the era Tahoma stack:

{
  kind: "text",
  x: 16,
  y: 26.5,
  source: "ext-upper",
  maxChars: 4,
  textAnchor: "middle",
  fontFamily: "Tahoma, 'MS Sans Serif', sans-serif",
  fontSize: 6,
  fontWeight: "bold",
  fill: "#000080",
}

Paint values: GlyphPaint

Every fill/stroke is one GlyphPaint string. The forms deliberately cover real era artwork:

export type GlyphPaint =
  | "currentColor"
  | "none"
  | `#${string}`
  | `rgba:${number},${number},${number},${number}`
  | `token:${string}`
  | `url:#${string}`;
Form Meaning
currentColor / none Inherit text color / no paint.
#rgb, #rgba, #rrggbb, #rrggbbaa Literal hex.
rgba:r,g,b,a Fractional-alpha rgba — r,g,b in 0–255, a in 0–1. Required: aqua/win7/winxp/xfce sheens were authored as rgba(255,255,255,0.x), kept lossless here.
token:name Resolves to var(--name); validated against the theme's token set at register time.
url:#id A gradient defined in this drawing's defs; the id is rewritten per instance.

resolvePaint is the shared resolver both the validator and renderer use, so they can't drift on what a paint means: token:foovar(--foo), rgba:255,255,255,0.4rgba(255, 255, 255, 0.4), url:#gurl(#<prefix>-g).

Window controls: ControlGlyphData

The caption cluster as data, carried on ThemeChrome.controlGlyphs:

export interface ControlGlyphData {
  glyphs: Partial<Record<WindowControlAction, GlyphDrawing>>;
  spanChar?: Partial<Record<WindowControlAction, string>>;
  labels?: Partial<Record<WindowControlAction, string>>;
}

WindowControlAction is "close" | "minimize" | "zoom". A theme may supply a glyphs drawing, an always-rendered spanChar (a <span aria-hidden> character), both, or neither per action.

Cluster order and side are NOT here. They live once on ThemeChrome.controlsOrder / controlsSide — the single source the default renderer and the layout already read — so geometry and order can never disagree:

chrome: {
  controlsSide: "left",
  controlsOrder: ["close", "minimize", "zoom"],
  controlGlyphs: aquaControlGlyphs,
  windowRadius: 8,
},

The two bundled extremes show the spectrum. Win98 ships full pixel/path glyph geometry (the pixels close above plus a rect minimize and an evenodd path zoom). Aqua ships no glyph drawings at all — the gel buttons whose ×//+ appear only on hover are pure CSS paint, and the data layer carries only the always-rendered span chars:

const aquaControlGlyphs: ControlGlyphData = {
  glyphs: {},
  spanChar: { close: "×", minimize: "−", zoom: "+" },
  labels: { close: "Close", minimize: "Minimize", zoom: "Zoom" },
};

<DataWindowControls> renders the cluster. For each action in order it emits a button whose child is the glyph SVG and/or the span — the exact same DOM as the default renderer, which is why theme stylesheets keep matching:

<button
  key={action}
  {...part(PARTS.control)}
  type="button"
  data-action={action}
  data-focused={focused ? "true" : "false"}
  aria-label={label}
  onClick={handlers[action]}
>
  {drawing && <GlyphDrawingSvg drawing={drawing} />}
  {span !== undefined && <span aria-hidden="true">{span}</span>}
</button>

File icons: FileIconData

Folder/document artwork as data, carried on SheruTheme.iconGlyphs:

export interface FileIconData {
  folder: GlyphDrawing;
  file: GlyphDrawing;
  byExt?: Record<string, GlyphDrawing>;
}

<DataFileIcon> picks folder or file by which; for files, a per-extension override in byExt (keyed by lowercased extension) wins when present, and the ext is forwarded so a text badge can stamp the label. When a theme omits iconGlyphs, the views fall back to a set of neutral stroke glyphs.

Aqua's folder shows both the gradient def and the lossless rgba: sheen:

folder: {
  viewBox: [0, 0, 32, 32],
  defs: [
    {
      id: "sheru-aqua-folder",
      kind: "linear",
      x1: 0, y1: 0, x2: 0, y2: 1,
      stops: [
        { offset: 0, color: "#b5d6f6" },
        { offset: 0.5, color: "#6ba4e8" },
        { offset: 0.52, color: "#5b96e0" },
        { offset: 1, color: "#7db1ee" },
      ],
    },
  ],
  shapes: [
    {
      kind: "path",
      d: "M3.5 9.5a2 2 0 0 1 2-2h6.6l2.6 3h12.8a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2h-22a2 2 0 0 1-2-2z",
      fill: "url:#sheru-aqua-folder",
      stroke: "#3a6db1",
      strokeWidth: 1,
    },
    // Aqua sheen across the upper half.
    {
      kind: "path",
      d: "M4.5 10.5h23v5.5a40 40 0 0 1-23 0z",
      fill: "rgba:255,255,255,0.4",
    },
  ],
},

Validation

Glyph data is untrusted runtime data — it round-trips through JSON before validation, and third-party themes are the data source it guards against. Two guards run at register time, both throwing the loud Theme "<id>" violates … style so a bad theme is rejected before it enters the registry (and so a marketplace loader can disable the entry instead of crashing).

Path safety: lexPathD

Every path d string passes a character-class lexer — bounded length and a restricted character set. It is a lexer, not a full SVG path parser: its job is to guarantee the string is inert geometry that carries no script and no nested markup (it rejects <, url(, ;):

export const MAX_PATH_D_LENGTH = 2048;
const PATH_D_ALLOWED = /^[MmLlHhVvCcSsQqTtAaZz0-9 ,.eE+\-\t\r\n]+$/;

export function lexPathD(d: string): PathLexResult {
  if (typeof d !== "string") return { ok: false, reason: "path d must be a string" };
  if (d.length === 0) return { ok: false, reason: "path d is empty" };
  if (d.length > MAX_PATH_D_LENGTH)
    return { ok: false, reason: `path d exceeds ${MAX_PATH_D_LENGTH} chars` };
  if (!PATH_D_ALLOWED.test(d))
    return { ok: false, reason: "path d contains disallowed characters" };
  return { ok: true };
}

Whole-theme check: validateGlyphData

validateGlyphData(theme) walks every drawing the theme ships — each control glyph, the folder, the file, and every byExt override — and checks:

if (parsed.token !== undefined && !knownTokens.has(`--${parsed.token}`)) {
  problems.push(`${where}: paint references unknown token "--${parsed.token}"`);
}
if (parsed.urlId !== undefined && !localDefIds.has(parsed.urlId)) {
  problems.push(`${where}: paint references unknown gradient "#${parsed.urlId}"`);
}

So a url:#id paint must resolve to a def local to the same drawing — a glyph cannot reach into another drawing's gradients, and a token: paint cannot name a token that doesn't exist. validateGlyphData runs inside registerTheme right after the token-contract drift guard; failures throw before the theme is registered.

registerTheme is memoized per id + object identity, so the per-render call from <ThemeProvider> doesn't re-lex paths every render — a replaced object (different identity) re-validates.

The renderer: GlyphDrawingSvg

<GlyphDrawingSvg> is pure presentation — it maps each shape to one SVG element, in order, back-to-front, and routes every paint through the shared resolvePaint. Two details matter:

Per-instance gradient ids. Gradient ids are rewritten through a useId()-derived prefix so two icons sharing an id (e.g. two folder glyphs on screen) can't collide — both the <linearGradient id> and every url:#id reference in the drawing are rewritten consistently:

const rawId = useId();
const prefix = rawId.replace(/[^a-zA-Z0-9_-]/g, "");
const mapId = (id: string) => `${prefix}-${id}`;

Intrinsic size fallback. When the caller gives no width/height, the SVG falls back to the viewBox's intrinsic dimensions. An SVG with a viewBox but no width/height has no intrinsic box and collapses — which is exactly the regression #87 fixed: after the #85 migration the control glyphs were sizeless viewBox-only SVGs and vanished entirely. Falling back to the viewBox dimensions reproduces each theme's old width={vb.w} height={vb.h} control SVG:

<svg
  width={width ?? w}
  height={height ?? h}
  viewBox={`${minX} ${minY} ${w} ${h}`}
  shapeRendering={drawing.shapeRendering}
  aria-hidden="true"
  style={style}
>

#87 follow-up: the terminal black bar

The same #85 migration shook loose a second regression that #87 fixed — unrelated to glyph rendering but worth noting alongside it. aqua/winxp/win7/xfce had painted the terminal panel and its resize handle var(--terminal-bg) (black), so the transparent resize handle read as a black bar above the themed "Terminal" header. The four were changed to var(--surface) (win98 already did this): the resize grip and header are window chrome, and only the host paints the terminal background. See the terminal for the host/theme split.

Rendering is fully data-driven

A theme's caption clusters and file icons render purely from this glyph data — no per-theme code is involved. Because the stylesheet does all paint and the geometry comes entirely from the GlyphDrawing JSON, the same data always produces the same render.