diff --git a/apps/web/src/features/applications/tile-color.ts b/apps/web/src/features/applications/tile-color.ts
deleted file mode 100644
index a0a117fcc..000000000
--- a/apps/web/src/features/applications/tile-color.ts
+++ /dev/null
@@ -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];
-}
diff --git a/package.json b/package.json
index 0f807132b..6df3340b4 100644
--- a/package.json
+++ b/package.json
@@ -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",
diff --git a/packages/ui/src/components/icon.test.tsx b/packages/ui/src/components/icon.test.tsx
new file mode 100644
index 000000000..84e092a63
--- /dev/null
+++ b/packages/ui/src/components/icon.test.tsx
@@ -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();
+ 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();
+ expect(container.querySelector('[data-slot="icon"]')?.getAttribute("style")).toContain('"FILL" 1');
+ });
+
+ it("mirrors directional icons in right-to-left layouts", () => {
+ const { container } = render(
+ <>
+
+
+ >,
+ );
+ 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());
+ });
+});
diff --git a/packages/ui/src/components/icon.tsx b/packages/ui/src/components/icon.tsx
new file mode 100644
index 000000000..3f84857a7
--- /dev/null
+++ b/packages/ui/src/components/icon.tsx
@@ -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([
+ "arrow_back",
+ "arrow_forward",
+ "chevron_left",
+ "chevron_right",
+ "redo",
+ "undo",
+]);
+
+type IconProps = Omit, "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 (
+ = 24 ? 24 : 20}`,
+ ...style,
+ }}
+ {...props}
+ >
+ {name}
+
+ );
+}
+
+export { Icon, type IconName, type IconProps };
diff --git a/packages/ui/src/icons/material-symbols-rounded.json b/packages/ui/src/icons/material-symbols-rounded.json
new file mode 100644
index 000000000..75ddabf03
--- /dev/null
+++ b/packages/ui/src/icons/material-symbols-rounded.json
@@ -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"
+ ]
+}
diff --git a/packages/ui/src/icons/material-symbols-rounded.woff2 b/packages/ui/src/icons/material-symbols-rounded.woff2
new file mode 100644
index 000000000..214a66945
Binary files /dev/null and b/packages/ui/src/icons/material-symbols-rounded.woff2 differ
diff --git a/packages/ui/src/icons/names.ts b/packages/ui/src/icons/names.ts
new file mode 100644
index 000000000..f83b5ff46
--- /dev/null
+++ b/packages/ui/src/icons/names.ts
@@ -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];
diff --git a/tooling/icons/material-symbols.ts b/tooling/icons/material-symbols.ts
new file mode 100644
index 000000000..da77ebac8
--- /dev/null
+++ b/tooling/icons/material-symbols.ts
@@ -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).`);
diff --git a/tooling/package.json b/tooling/package.json
index 428d76104..e4701127f 100644
--- a/tooling/package.json
+++ b/tooling/package.json
@@ -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:*"