1// Build-free key "faces" for the MX Creative Keypad: a fluent icon builder that
2// renders to a key-sized SVG document. Three sources — Lucide (stroke icons,
3// typed from `lucide-static`), Material Design Icons (filled icons, typed from
4// `mdi-ts`), and plain text — with chainable `.bg()`/`.fg()`/`.size()`.
5//
6// lucide("Play") // a white play glyph on the default bg
7// mdi("metronome").fg("red") // an MDI metronome, tinted red
8// txt("BPM").bg("#101010") // a centered text label
9//
10// The result's `.svg` is what KeypadUI rasterizes to JPEG. Everything here is
11// pure string building — no build step, no runtime SVG parsing beyond a trim.
12import * as lucideIcons from "lucide-static";
13import type { MdiIcon } from "mdi-ts";
14import { readFileSync } from "node:fs";
15import { createRequire } from "node:module";
16import { dirname, join } from "node:path";
17
18/** The pixel size of a single key face (square) — the SVG viewBox. */
19const KEY_SIZE = 118;
20
21const DEFAULT_BG = "#1b1b1b";
22const DEFAULT_FG = "#ffffff";
23const DEFAULT_ICON_SIZE = 56;
24const DEFAULT_TEXT_SIZE = 34;
25
26// The "unassigned" face: a near-black key with a small grey dot.
27const BLANK_BG = "#141414";
28const BLANK_DOT = "#555";
29
30/** Named palette. Extend freely — any unknown name falls through as a raw color. */
31const colorMap = {
32 black: "#000000",
33 white: "#ffffff",
34 grey: "#8a8a8a",
35 gray: "#8a8a8a",
36 red: "#ff6b6b",
37 orange: "#ff9f43",
38 amber: "#ffbf47",
39 yellow: "#ffd23f",
40 lime: "#b6f36b",
41 green: "#7cfc9b",
42 teal: "#2dd4bf",
43 cyan: "#3ad6e8",
44 blue: "#4aa8ff",
45 indigo: "#6c7bff",
46 violet: "#9b6bff",
47 purple: "#b47cff",
48 magenta: "#ff5ccd",
49 pink: "#ff6bd6",
50} as const;
51
52/** Hex string, a name from the palette, or any other CSS color. */
53export type Color = `#${string}` | keyof typeof colorMap | (string & {});
54
55function resolveColor(color: Color): string {
56 return (colorMap as Record<string, string>)[color] ?? color;
57}
58
59/** Anything with SVG markup for a single key. */
60export interface Svg {
61 readonly svg: string;
62}
63
64/** Lucide icon names (PascalCase), typed from `lucide-static`. */
65export type LucideIconName = keyof typeof lucideIcons;
66
67/** MDI icon names (kebab-case, without the `mdi-` prefix), typed from `mdi-ts`. */
68export type MdiIconName = `${MdiIcon}` extends `mdi-${infer Name}` ? Name : never;
69
70/** One line of a {@link stack}: bare text, or text with its own size/color. */
71export type StackLine =
72 | string
73 | number
74 | { text: string | number; size?: number; color?: Color };
75
76interface StackSpec {
77 text: string;
78 size?: number;
79 color?: Color;
80}
81
82type IconSource =
83 | { readonly kind: "lucide"; readonly name: string }
84 | { readonly kind: "mdi"; readonly name: string }
85 | { readonly kind: "text"; readonly text: string }
86 | { readonly kind: "stack"; readonly lines: readonly StackSpec[] }
87 | { readonly kind: "timesig"; readonly numerator: string; readonly denominator: string }
88 | { readonly kind: "blank" };
89
90/**
91 * An immutable, lazily-rendered key face. `.bg()`/`.fg()`/`.size()` each return
92 * a new `Icon`, so definitions compose without mutating shared instances.
93 */
94export class Icon implements Svg {
95 readonly #source: IconSource;
96 readonly #bg: Color;
97 readonly #fg: Color;
98 readonly #size: number | null;
99 #rendered: string | null = null;
100
101 constructor(
102 source: IconSource,
103 bg: Color = DEFAULT_BG,
104 fg: Color = DEFAULT_FG,
105 size: number | null = null,
106 ) {
107 this.#source = source;
108 this.#bg = bg;
109 this.#fg = fg;
110 this.#size = size;
111 }
112
113 /** A copy with a different background color. */
114 bg(color: Color): Icon {
115 return new Icon(this.#source, color, this.#fg, this.#size);
116 }
117
118 /** A copy with a different foreground (stroke/fill/text) color. */
119 fg(color: Color): Icon {
120 return new Icon(this.#source, this.#bg, color, this.#size);
121 }
122
123 /** A copy with a different glyph size in key pixels (icon or text height). */
124 size(px: number): Icon {
125 return new Icon(this.#source, this.#bg, this.#fg, px);
126 }
127
128 /** The full key-sized SVG document. Rendered once, then cached. */
129 get svg(): string {
130 return this.#rendered ??= wrapSvg(this.#inner(), resolveColor(this.#bg));
131 }
132
133 #inner(): string {
134 const fg = resolveColor(this.#fg);
135 switch (this.#source.kind) {
136 case "lucide":
137 return glyph(lucideInner(this.#source.name), this.#glyphSize(), {
138 fill: "none",
139 stroke: fg,
140 extra: `stroke-width="2" stroke-linecap="round" stroke-linejoin="round"`,
141 });
142 case "mdi":
143 return glyph(mdiInner(this.#source.name), this.#glyphSize(), {
144 fill: fg,
145 stroke: "none",
146 });
147 case "text":
148 return text(this.#source.text, fg, this.#size ?? DEFAULT_TEXT_SIZE);
149 case "stack":
150 return stackInner(this.#source.lines, fg);
151 case "timesig":
152 return timeSignatureInner(
153 this.#source.numerator,
154 this.#source.denominator,
155 fg,
156 );
157 case "blank":
158 return `<circle cx="${KEY_SIZE / 2}" cy="${KEY_SIZE / 2}" r="7" `
159 + `fill="${BLANK_DOT}"/>`;
160 }
161 }
162
163 #glyphSize(): number {
164 return this.#size ?? DEFAULT_ICON_SIZE;
165 }
166}
167
168/** A Lucide (stroke) icon. `key` is the PascalCase name, e.g. `"AlarmClock"`. */
169export function lucide(key: LucideIconName): Icon {
170 return new Icon({ kind: "lucide", name: key });
171}
172
173/** A Material Design (filled) icon. `key` is the kebab name, e.g. `"metronome"`. */
174export function mdi(key: MdiIconName): Icon {
175 return new Icon({ kind: "mdi", name: key });
176}
177
178/** A short, centered text label. */
179export function txt(label: string): Icon {
180 return new Icon({ kind: "text", text: label });
181}
182
183/**
184 * A vertical stack of text lines, e.g. `stack(130, "BPM")` — the first line is
185 * emphasized (larger), the rest are secondary. Pass `{ text, size, color }` to
186 * override a line. Great for live readouts: `() => stack(bpm(), "BPM")`.
187 */
188export function stack(...lines: StackLine[]): Icon {
189 return new Icon({ kind: "stack", lines: lines.map(normalizeStackLine) });
190}
191
192function normalizeStackLine(line: StackLine): StackSpec {
193 if (typeof line === "string" || typeof line === "number") {
194 return { text: String(line) };
195 }
196 return { text: String(line.text), size: line.size, color: line.color };
197}
198
199/**
200 * A musical time signature: serif numerals stacked over a set of staff lines.
201 * `timeSignature("4/4")` or `timeSignature(6, 8)`.
202 */
203export function timeSignature(signature: string): Icon;
204export function timeSignature(
205 numerator: number | string,
206 denominator: number | string,
207): Icon;
208export function timeSignature(
209 a: number | string,
210 b?: number | string,
211): Icon {
212 let numerator: string;
213 let denominator: string;
214 if (b === undefined) {
215 const [top, bottom] = String(a).split("/");
216 numerator = (top ?? "4").trim();
217 denominator = (bottom ?? "4").trim();
218 } else {
219 numerator = String(a);
220 denominator = String(b);
221 }
222 return new Icon({ kind: "timesig", numerator, denominator });
223}
224
225/** The "unassigned" face: a near-black key with a small grey dot. */
226export const blank: Icon = new Icon({ kind: "blank" }, BLANK_BG);
227
228// ---------------------------------------------------------------------------
229// SVG building — icons live in a 24x24 viewBox; scale + center them in the key.
230// ---------------------------------------------------------------------------
231
232/** Wrap inner markup in a full key-sized document with a solid background. */
233function wrapSvg(inner: string, bg: string): string {
234 return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${KEY_SIZE} ${KEY_SIZE}">`
235 + `<rect width="${KEY_SIZE}" height="${KEY_SIZE}" fill="${bg}"/>${inner}</svg>`;
236}
237
238interface GlyphStyle {
239 fill: string;
240 stroke: string;
241 extra?: string;
242}
243
244/** Place a 24x24 glyph, scaled to `size` and centered, with the given paint. */
245function glyph(inner: string, size: number, style: GlyphStyle): string {
246 const offset = (KEY_SIZE - size) / 2;
247 const scale = size / 24;
248 const extra = style.extra ? ` ${style.extra}` : "";
249 return `<g transform="translate(${offset} ${offset}) scale(${scale})" `
250 + `fill="${style.fill}" stroke="${style.stroke}"${extra}>${inner}</g>`;
251}
252
253const SANS_FONT = "Helvetica, Arial, sans-serif";
254// Lead with a lining-figures serif. Georgia (and most "text" serifs) use
255// old-style figures where digits sit at different heights — 0/1/2 are short,
256// 3/4/5/7/9 descend, 6/8 ascend — so no single vertical offset can center
257// every numerator/denominator pair. Times uses lining figures: all digits
258// share one baseline and cap-height, which is also how music engraving sets
259// time signatures.
260const SERIF_FONT = "'Times New Roman', Times, Georgia, serif";
261
262interface TextStyle {
263 family?: string;
264 weight?: number;
265}
266
267/** A single centered `<text>` at (x, y), sized in key pixels. */
268function textAt(
269 x: number,
270 y: number,
271 label: string,
272 fg: string,
273 size: number,
274 style: TextStyle = {},
275): string {
276 const family = style.family ?? SANS_FONT;
277 const weight = style.weight ?? 600;
278 return `<text x="${x}" y="${y}" fill="${fg}" font-family="${family}" `
279 + `font-size="${size}" font-weight="${weight}" text-anchor="middle" `
280 + `dominant-baseline="central">${escapeXml(label)}</text>`;
281}
282
283function text(label: string, fg: string, size: number): string {
284 return textAt(KEY_SIZE / 2, KEY_SIZE / 2, label, fg, size);
285}
286
287const STACK_PRIMARY = 42;
288const STACK_SECONDARY = 22;
289const STACK_GAP = 6;
290
291/** Lay text lines out vertically, centered as a group. */
292function stackInner(lines: readonly StackSpec[], fg: string): string {
293 const sizes = lines.map((line, index) => line.size ?? (index === 0 ? STACK_PRIMARY : STACK_SECONDARY));
294 const height = sizes.reduce((sum, size) => sum + size, 0)
295 + STACK_GAP * Math.max(0, lines.length - 1);
296 let top = (KEY_SIZE - height) / 2;
297
298 return lines
299 .map((line, index) => {
300 const size = sizes[index];
301 const centerY = top + size / 2;
302 top += size + STACK_GAP;
303 const color = line.color ? resolveColor(line.color) : fg;
304 return textAt(KEY_SIZE / 2, centerY, line.text, color, size);
305 })
306 .join("");
307}
308
309// Four horizontal staff lines with the serif numerals stacked across them —
310// numerator in the upper half, denominator in the lower, like real sheet music.
311const STAFF_LINES = 5;
312const STAFF_GAP = 16;
313const STAFF_INSET = 16;
314const TIMESIG_GLYPH = 40;
315// Vertical distance from the middle staff line to each numeral's center. The
316// numerator sits this far above the center, the denominator the same distance
317// below, so the pair is balanced regardless of which digits are shown.
318const TIMESIG_STACK_OFFSET = 16;
319
320function timeSignatureInner(
321 numerator: string,
322 denominator: string,
323 fg: string,
324): string {
325 const span = STAFF_GAP * (STAFF_LINES - 1);
326 const top = (KEY_SIZE - span) / 2;
327 let staff = "";
328 for (let i = 0; i < STAFF_LINES; i += 1) {
329 const y = top + i * STAFF_GAP;
330 staff += `<line x1="${STAFF_INSET}" y1="${y}" x2="${KEY_SIZE - STAFF_INSET}" `
331 + `y2="${y}" stroke="${fg}" stroke-width="1.5" opacity="0.4"/>`;
332 }
333 const style: TextStyle = { family: SERIF_FONT, weight: 700 };
334 const numeral = TIMESIG_GLYPH;
335 const middle = KEY_SIZE / 2;
336 const glyphs = textAt(KEY_SIZE / 2, middle - TIMESIG_STACK_OFFSET, numerator, fg, numeral, style)
337 + textAt(KEY_SIZE / 2, middle + TIMESIG_STACK_OFFSET, denominator, fg, numeral, style);
338 return staff + glyphs;
339}
340
341function escapeXml(value: string): string {
342 return value.replace(/[<>&"']/g, (char) =>
343 char === "<"
344 ? "&lt;"
345 : char === ">"
346 ? "&gt;"
347 : char === "&"
348 ? "&amp;"
349 : char === "\""
350 ? "&quot;"
351 : "&apos;");
352}
353
354/** Strip the outer `<svg>` wrapper (and any leading comment) from icon markup. */
355function extractInner(markup: string): string {
356 return markup
357 .replace(/^[\s\S]*?<svg[^>]*>/, "")
358 .replace(/<\/svg>[\s\S]*$/, "")
359 .trim();
360}
361
362const require = createRequire(import.meta.url);
363const MDI_DIR = join(dirname(require.resolve("@mdi/svg/package.json")), "svg");
364
365const lucideCache = new Map<string, string>();
366const mdiCache = new Map<string, string>();
367
368function lucideInner(name: string): string {
369 let inner = lucideCache.get(name);
370 if (inner === undefined) {
371 const raw = (lucideIcons as Record<string, unknown>)[name];
372 if (typeof raw !== "string") {
373 throw new Error(`Unknown Lucide icon: "${name}"`);
374 }
375 inner = extractInner(raw);
376 lucideCache.set(name, inner);
377 }
378 return inner;
379}
380
381function mdiInner(name: string): string {
382 let inner = mdiCache.get(name);
383 if (inner === undefined) {
384 let raw: string;
385 try {
386 raw = readFileSync(join(MDI_DIR, `${name}.svg`), "utf8");
387 } catch {
388 throw new Error(`Unknown MDI icon: "${name}"`);
389 }
390 inner = extractInner(raw);
391 mdiCache.set(name, inner);
392 }
393 return inner;
394}