| 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. |
| 12 | import * as lucideIcons from "lucide-static"; |
| 13 | import type { MdiIcon } from "mdi-ts"; |
| 14 | import { readFileSync } from "node:fs"; |
| 15 | import { createRequire } from "node:module"; |
| 16 | import { dirname, join } from "node:path"; |
| 17 | |
| 18 | /** The pixel size of a single key face (square) — the SVG viewBox. */ |
| 19 | const KEY_SIZE = 118; |
| 20 | |
| 21 | const DEFAULT_BG = "#1b1b1b"; |
| 22 | const DEFAULT_FG = "#ffffff"; |
| 23 | const DEFAULT_ICON_SIZE = 56; |
| 24 | const DEFAULT_TEXT_SIZE = 34; |
| 25 | |
| 26 | // The "unassigned" face: a near-black key with a small grey dot. |
| 27 | const BLANK_BG = "#141414"; |
| 28 | const BLANK_DOT = "#555"; |
| 29 | |
| 30 | /** Named palette. Extend freely — any unknown name falls through as a raw color. */ |
| 31 | const 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. */ |
| 53 | export type Color = `#${string}` | keyof typeof colorMap | (string & {}); |
| 54 | |
| 55 | function resolveColor(color: Color): string { |
| 56 | return (colorMap as Record<string, string>)[color] ?? color; |
| 57 | } |
| 58 | |
| 59 | /** Anything with SVG markup for a single key. */ |
| 60 | export interface Svg { |
| 61 | readonly svg: string; |
| 62 | } |
| 63 | |
| 64 | /** Lucide icon names (PascalCase), typed from `lucide-static`. */ |
| 65 | export type LucideIconName = keyof typeof lucideIcons; |
| 66 | |
| 67 | /** MDI icon names (kebab-case, without the `mdi-` prefix), typed from `mdi-ts`. */ |
| 68 | export 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. */ |
| 71 | export type StackLine = |
| 72 | | string |
| 73 | | number |
| 74 | | { text: string | number; size?: number; color?: Color }; |
| 75 | |
| 76 | interface StackSpec { |
| 77 | text: string; |
| 78 | size?: number; |
| 79 | color?: Color; |
| 80 | } |
| 81 | |
| 82 | type 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 | */ |
| 94 | export 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"`. */ |
| 169 | export 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"`. */ |
| 174 | export function mdi(key: MdiIconName): Icon { |
| 175 | return new Icon({ kind: "mdi", name: key }); |
| 176 | } |
| 177 | |
| 178 | /** A short, centered text label. */ |
| 179 | export 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 | */ |
| 188 | export function stack(...lines: StackLine[]): Icon { |
| 189 | return new Icon({ kind: "stack", lines: lines.map(normalizeStackLine) }); |
| 190 | } |
| 191 | |
| 192 | function 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 | */ |
| 203 | export function timeSignature(signature: string): Icon; |
| 204 | export function timeSignature( |
| 205 | numerator: number | string, |
| 206 | denominator: number | string, |
| 207 | ): Icon; |
| 208 | export 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. */ |
| 226 | export 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. */ |
| 233 | function 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 | |
| 238 | interface 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. */ |
| 245 | function 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 | |
| 253 | const 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. |
| 260 | const SERIF_FONT = "'Times New Roman', Times, Georgia, serif"; |
| 261 | |
| 262 | interface TextStyle { |
| 263 | family?: string; |
| 264 | weight?: number; |
| 265 | } |
| 266 | |
| 267 | /** A single centered `<text>` at (x, y), sized in key pixels. */ |
| 268 | function 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 | |
| 283 | function text(label: string, fg: string, size: number): string { |
| 284 | return textAt(KEY_SIZE / 2, KEY_SIZE / 2, label, fg, size); |
| 285 | } |
| 286 | |
| 287 | const STACK_PRIMARY = 42; |
| 288 | const STACK_SECONDARY = 22; |
| 289 | const STACK_GAP = 6; |
| 290 | |
| 291 | /** Lay text lines out vertically, centered as a group. */ |
| 292 | function 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. |
| 311 | const STAFF_LINES = 5; |
| 312 | const STAFF_GAP = 16; |
| 313 | const STAFF_INSET = 16; |
| 314 | const 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. |
| 318 | const TIMESIG_STACK_OFFSET = 16; |
| 319 | |
| 320 | function 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 | |
| 341 | function 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. */ |
| 355 | function extractInner(markup: string): string { |
| 356 | return markup |
| 357 | .replace(/^[\s\S]*?<svg[^>]*>/, "") |
| 358 | .replace(/<\/svg>[\s\S]*$/, "") |
| 359 | .trim(); |
| 360 | } |
| 361 | |
| 362 | const require = createRequire(import.meta.url); |
| 363 | const MDI_DIR = join(dirname(require.resolve("@mdi/svg/package.json")), "svg"); |
| 364 | |
| 365 | const lucideCache = new Map<string, string>(); |
| 366 | const mdiCache = new Map<string, string>(); |
| 367 | |
| 368 | function 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 | |
| 381 | function 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 | } |