mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-10-04 10:43:46 +10:00
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:
@@ -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
@@ -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",
|
||||
|
||||
@@ -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());
|
||||
});
|
||||
});
|
||||
@@ -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.
@@ -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];
|
||||
@@ -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).`);
|
||||
@@ -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:*"
|
||||
|
||||
Reference in New Issue
Block a user