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 ( + + ); +} + +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:*"