feat(ui): add a self-hosted Material Symbols icon subset

Icon renders Material Symbols Rounded at weight 300 from a subset font
that holds only the glyphs listed in packages/ui/src/icons/names.ts.
pnpm icons:build checks every name against Google's codepoints and
regenerates the font; a test keeps the manifest and the list in sync.
Icons are aria-hidden and untranslated, and directional ones mirror in
right-to-left layouts.
This commit is contained in:
Amruth Pillai
2026-09-28 16:29:17 +02:00
parent def4f72169
commit 4485825dfe
9 changed files with 356 additions and 21 deletions
@@ -1,19 +0,0 @@
// Deterministic accent color for a company's initials tile, shared by the board card and the
// table row so the same company reads the same everywhere.
const TILE_COLORS = [
"bg-rose-500",
"bg-orange-500",
"bg-amber-500",
"bg-emerald-500",
"bg-teal-500",
"bg-sky-500",
"bg-indigo-500",
"bg-violet-500",
"bg-fuchsia-500",
];
export function tileColor(seed: string) {
let hash = 0;
for (let i = 0; i < seed.length; i++) hash = (hash * 31 + seed.charCodeAt(i)) | 0;
return TILE_COLORS[Math.abs(hash) % TILE_COLORS.length];
}
+2 -1
View File
@@ -41,7 +41,8 @@
"test:e2e": "playwright test",
"test:e2e:ui": "playwright test --ui",
"test:agent": "turbo run test:agent --concurrency=4",
"pdf:translations": "pnpm --filter @reactive-resume/tooling pdf:translations"
"pdf:translations": "pnpm --filter @reactive-resume/tooling pdf:translations",
"icons:build": "pnpm --filter @reactive-resume/tooling icons:build"
},
"devDependencies": {
"@biomejs/biome": "^2.5.14",
+38
View File
@@ -0,0 +1,38 @@
import { render } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import manifest from "../icons/material-symbols-rounded.json";
import { iconNames } from "../icons/names";
import { Icon } from "./icon";
describe("Icon", () => {
it("is hidden from assistive tech and translation, so ligature text never becomes a name", () => {
const { container } = render(<Icon name="search" />);
const icon = container.querySelector('[data-slot="icon"]');
expect(icon).toHaveAttribute("aria-hidden", "true");
expect(icon).toHaveAttribute("translate", "no");
expect(icon).toHaveTextContent("search");
});
it("uses the filled glyph only when asked", () => {
const { container } = render(<Icon name="description" filled />);
expect(container.querySelector('[data-slot="icon"]')?.getAttribute("style")).toContain('"FILL" 1');
});
it("mirrors directional icons in right-to-left layouts", () => {
const { container } = render(
<>
<Icon name="arrow_back" />
<Icon name="search" />
</>,
);
const [back, search] = container.querySelectorAll('[data-slot="icon"]');
expect(back).toHaveClass("rtl:-scale-x-100");
expect(search).not.toHaveClass("rtl:-scale-x-100");
});
});
describe("Material Symbols subset", () => {
it("was built from the current icon list (run `pnpm icons:build` after editing it)", () => {
expect(manifest.names).toEqual([...iconNames].sort());
});
});
+47
View File
@@ -0,0 +1,47 @@
import type * as React from "react";
import type { IconName } from "../icons/names";
import { cn } from "@reactive-resume/utils/style";
// Icons that point somewhere are mirrored in right-to-left layouts.
const DIRECTIONAL_ICONS = new Set<IconName>([
"arrow_back",
"arrow_forward",
"chevron_left",
"chevron_right",
"redo",
"undo",
]);
type IconProps = Omit<React.ComponentProps<"span">, "children"> & {
name: IconName;
/** The filled glyph. The design system reserves it for the selected navigation item. */
filled?: boolean | undefined;
/** Glyph size in CSS pixels: 20 on desktop, 24 on touch. */
size?: number | undefined;
};
/**
* A Material Symbols Rounded glyph (weight 300). Icons are decorative: the control they sit in
* carries the accessible name, so the ligature text is hidden from assistive tech and translation.
*/
function Icon({ name, filled = false, size = 20, className, style, ...props }: IconProps) {
return (
<span
aria-hidden="true"
translate="no"
data-slot="icon"
data-icon={name}
className={cn("material-symbol", DIRECTIONAL_ICONS.has(name) && "rtl:-scale-x-100", className)}
style={{
fontSize: size,
fontVariationSettings: `"FILL" ${filled ? 1 : 0}, "wght" 300, "GRAD" 0, "opsz" ${size >= 24 ? 24 : 20}`,
...style,
}}
{...props}
>
{name}
</span>
);
}
export { Icon, type IconName, type IconProps };
@@ -0,0 +1,105 @@
{
"names": [
"account_circle",
"add",
"add_a_photo",
"arrow_back",
"arrow_downward",
"arrow_forward",
"arrow_upward",
"auto_awesome",
"bolt",
"bookmark",
"calendar_month",
"call",
"check",
"check_circle",
"chevron_left",
"chevron_right",
"circle",
"close",
"cloud_done",
"cloud_off",
"content_copy",
"content_cut",
"contrast",
"dark_mode",
"delete",
"delete_forever",
"description",
"desktop_windows",
"download",
"drag_indicator",
"edit",
"edit_square",
"error",
"event",
"expand_less",
"expand_more",
"fact_check",
"format_bold",
"format_clear",
"format_italic",
"format_list_bulleted",
"format_list_numbered",
"format_size",
"grid_view",
"history",
"hub",
"import_export",
"info",
"insights",
"ios_share",
"key",
"language",
"left_panel_close",
"light_mode",
"link",
"link_off",
"location_on",
"lock",
"lock_open",
"logout",
"mail",
"menu",
"more_horiz",
"more_vert",
"motion_mode",
"note_add",
"open_in_new",
"palette",
"passkey",
"picture_as_pdf",
"print",
"qr_code_2",
"redo",
"remove",
"restart_alt",
"restore_from_trash",
"scan_delete",
"schedule",
"school",
"search",
"search_off",
"settings",
"short_text",
"sticky_note_2",
"stop_circle",
"sync",
"sync_problem",
"text_fields",
"title",
"translate",
"tune",
"undo",
"upload_file",
"vertical_align_center",
"vertical_split",
"view_list",
"visibility",
"visibility_off",
"warning",
"wifi_off",
"work"
]
}
Binary file not shown.
+111
View File
@@ -0,0 +1,111 @@
/**
* Every Material Symbols Rounded icon the app uses, sorted. The font in `material-symbols-rounded.woff2`
* is a subset that contains only these glyphs, so an icon missing from this list renders as its name.
*
* After changing the list, run `pnpm icons:build` to regenerate the font and its manifest.
*/
export const iconNames = [
"account_circle",
"add",
"add_a_photo",
"arrow_back",
"arrow_downward",
"arrow_forward",
"arrow_upward",
"auto_awesome",
"bolt",
"bookmark",
"calendar_month",
"call",
"check",
"check_circle",
"chevron_left",
"chevron_right",
"circle",
"close",
"cloud_done",
"cloud_off",
"content_copy",
"content_cut",
"contrast",
"dark_mode",
"delete",
"delete_forever",
"description",
"desktop_windows",
"download",
"drag_indicator",
"edit",
"edit_square",
"error",
"event",
"expand_less",
"expand_more",
"fact_check",
"format_bold",
"format_clear",
"format_italic",
"format_list_bulleted",
"format_list_numbered",
"format_size",
"grid_view",
"history",
"hub",
"import_export",
"info",
"insights",
"ios_share",
"key",
"language",
"left_panel_close",
"light_mode",
"link",
"link_off",
"location_on",
"lock",
"lock_open",
"logout",
"mail",
"menu",
"more_horiz",
"more_vert",
"motion_mode",
"note_add",
"open_in_new",
"palette",
"passkey",
"picture_as_pdf",
"print",
"qr_code_2",
"redo",
"remove",
"restart_alt",
"restore_from_trash",
"scan_delete",
"schedule",
"school",
"search",
"search_off",
"settings",
"short_text",
"sticky_note_2",
"stop_circle",
"sync",
"sync_problem",
"text_fields",
"title",
"translate",
"tune",
"undo",
"upload_file",
"vertical_align_center",
"vertical_split",
"view_list",
"visibility",
"visibility_off",
"warning",
"wifi_off",
"work",
] as const;
export type IconName = (typeof iconNames)[number];
+51
View File
@@ -0,0 +1,51 @@
/**
* Builds the self-hosted Material Symbols Rounded subset used by `@reactive-resume/ui`'s `Icon`.
*
* Google Fonts silently ignores unknown names in `icon_names`, so names are checked against the
* font's published codepoints first. Axes match the design system: weight 300, grade 0, optical
* size 20–24 and FILL 0–1 (filled icons mark the selected navigation item).
*/
import { readFile, writeFile } from "node:fs/promises";
const CODEPOINTS_URL =
"https://raw.githubusercontent.com/google/material-design-icons/master/variablefont/MaterialSymbolsRounded%5BFILL,GRAD,opsz,wght%5D.codepoints";
const FAMILY = "Material+Symbols+Rounded:opsz,wght,FILL,GRAD@20..24,300,0..1,0";
// A current Chrome user agent makes Google Fonts answer with WOFF2.
const USER_AGENT =
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0 Safari/537.36";
const namesFile = new URL("../../packages/ui/src/icons/names.ts", import.meta.url);
const fontFile = new URL("../../packages/ui/src/icons/material-symbols-rounded.woff2", import.meta.url);
const manifestFile = new URL("../../packages/ui/src/icons/material-symbols-rounded.json", import.meta.url);
const fetchOk = async (url: string, init?: RequestInit) => {
const response = await fetch(url, init);
if (!response.ok) throw new Error(`${response.status} ${response.statusText} for ${url}`);
return response;
};
// Tooling can't depend on the browser-only UI package, so the list is read from its source: one
// quoted name per line inside the `iconNames` array.
const names = [...(await readFile(namesFile, "utf8")).matchAll(/^\t"([a-z0-9_]+)",$/gm)].map(
(match) => match[1] as string,
);
if (names.length === 0) throw new Error("No icon names found in packages/ui/src/icons/names.ts");
names.sort();
const known = new Set(
(await (await fetchOk(CODEPOINTS_URL)).text())
.split("\n")
.map((line) => line.split(" ")[0])
.filter(Boolean),
);
const unknown = names.filter((name) => !known.has(name));
if (unknown.length > 0) throw new Error(`Not Material Symbols icons: ${unknown.join(", ")}`);
const cssUrl = `https://fonts.googleapis.com/css2?family=${FAMILY}&icon_names=${names.join(",")}&display=block`;
const css = await (await fetchOk(cssUrl, { headers: { "User-Agent": USER_AGENT } })).text();
const woff2Url = css.match(/src:\s*url\(([^)]+)\)\s*format\('woff2'\)/)?.[1];
if (!woff2Url) throw new Error(`No WOFF2 source in the Google Fonts response:\n${css}`);
const font = new Uint8Array(await (await fetchOk(woff2Url)).arrayBuffer());
await writeFile(fontFile, font);
await writeFile(manifestFile, `${JSON.stringify({ names }, null, "\t")}\n`);
console.log(`Wrote ${names.length} icons (${(font.byteLength / 1024).toFixed(1)} KB).`);
+2 -1
View File
@@ -9,7 +9,8 @@
"test": "vitest run --passWithNoTests",
"test:ci": "vitest run --coverage --reporter=default --reporter=github-actions --reporter=json --reporter=junit --outputFile.json=reports/vitest-results.json --outputFile.junit=reports/vitest-junit.xml --passWithNoTests",
"typecheck": "tsgo --noEmit",
"pdf:translations": "tsx locales/generate.ts"
"pdf:translations": "tsx locales/generate.ts",
"icons:build": "tsx icons/material-symbols.ts"
},
"dependencies": {
"@reactive-resume/schema": "workspace:*"