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:
- A third-party theme package can ship its caption buttons and folder icons without shipping code.
- That geometry is validated before it ever renders — an unknown shape kind, an out-of-scope token reference, or a paste-bomb path string is rejected loudly at
registerTheme, not silently mis-rendered at paint time. - The data renderers emit the exact same DOM the old React renderers did, so existing theme stylesheets keep matching unchanged.
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:foo → var(--foo), rgba:255,255,255,0.4 → rgba(255, 255, 255, 0.4), url:#g → url(#<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:
- viewBox is 4 finite numbers.
- Every shape kind is a known union member (an unknown kind is rejected rather than silently rendering nothing).
- Every
pathd passeslexPathD. - Every
pixelshas positivewidth/height. - Every gradient stop color is a valid hex.
- Every
token:paint names a known token — schema tokens plus the theme's own bindings:
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.