feat: optimize Material Symbols

This commit is contained in:
Michael Zetterberg
2025-04-06 05:41:34 +02:00
parent 1239f0c662
commit f31b374370
13 changed files with 343 additions and 22 deletions

View File

@@ -18,3 +18,29 @@ To get started, clone this repository and run `yarn install` in the root directo
### Running `scandic-web` locally
To run the `scandic-web` app locally see its [README](./apps/scandic-web/README.md).
## Material Symbols
We download the font file from Google Fonts service and host it ourselves.
### Configuration
We use the following configuration:
- FILL axis: 0..1
- wght axis: 400
- GRAD axis: 0
- opsz axis: 24
More info at https://developers.google.com/fonts/docs/material_symbols#optimize_the_icon_font
### Optimization
We optimize the font size by only including the icons we use in the repository.
Read more at: https://developers.google.com/fonts/docs/material_symbols#use_in_web
### Modifying icons
1. Update the list of icons to include in `scripts/material-symbols-update.mjs`.
2. Run `yarn run icons:update` in monorepo root.

View File

@@ -31,6 +31,10 @@ This will also spin up [Redis Insight ](https://redis.io/insight/) so that you c
- Click **'Add Redis database'**
- Provide Connection URL `redis://redis:6379`
### Icons / Material Symbols
Read the README.md in the monorepo root.
## Learn More
To learn more about Next.js, take a look at the following resources:

View File

@@ -2,7 +2,6 @@ import "@scandic-hotels/design-system/fonts.css"
import "@/app/globals.css"
import "@/public/_static/css/design-system-new-deprecated.css"
import "@scandic-hotels/design-system/style.css"
import "react-material-symbols/rounded"
import { ReactQueryDevtools } from "@tanstack/react-query-devtools"
import Script from "next/script"

View File

@@ -2,7 +2,6 @@ import "@scandic-hotels/design-system/fonts.css"
import "@/app/globals.css"
import "@/public/_static/css/design-system-current-deprecated.css"
import "@scandic-hotels/design-system/style.css"
import "react-material-symbols/rounded"
import Script from "next/script"

View File

@@ -2,7 +2,6 @@ import "@scandic-hotels/design-system/fonts.css"
import "@/app/globals.css"
import "@/public/_static/css/design-system-new-deprecated.css"
import "@scandic-hotels/design-system/style.css"
import "react-material-symbols/rounded"
import { ReactQueryDevtools } from "@tanstack/react-query-devtools"
import Script from "next/script"

View File

@@ -2,7 +2,6 @@ import "@scandic-hotels/design-system/fonts.css"
import "@/app/globals.css"
import "@/public/_static/css/design-system-new-deprecated.css"
import "@scandic-hotels/design-system/style.css"
import "react-material-symbols/rounded"
import Script from "next/script"

View File

@@ -8,7 +8,8 @@
"dev:web": "turbo run dev --filter=@scandic-hotels/scandic-web --output-logs new-only",
"dev:ds": "turbo run dev --filter=@scandic-hotels/design-system --output-logs new-only",
"test": "turbo run test",
"postinstall": "husky"
"postinstall": "husky",
"icons:update": "node scripts/material-symbols-update.mjs"
},
"workspaces": [
"apps/*",

View File

@@ -148,6 +148,6 @@ Each component should have at least one Storybook story. A default story that sh
Stories that involve other non-related components are compositions. These should be placed in the `Compositions/` folder where the composition has the best chance of discoverability, typically inside the component folder of the outermost component of the composition. Exporting the same composition in multiple places can be good for discoverability.
### Icons
### Icons / Material Symbols
Still a work in progress.
Read the README.md in the monorepo root.

View File

@@ -8,29 +8,17 @@ import { iconVariants } from '../variants'
import type { VariantProps } from 'class-variance-authority'
export interface MaterialIconProps
extends Omit<MaterialSymbolProps, 'color' | 'fill'>,
extends Pick<MaterialSymbolProps, 'size' | 'icon' | 'className' | 'style'>,
VariantProps<typeof iconVariants> {
isFilled?: boolean
}
export type MaterialIconSetIconProps = Omit<MaterialIconProps, 'icon'>
export function MaterialIcon({
icon,
color,
size = 24,
isFilled = false,
className,
isFilled = false,
...props
}: MaterialIconProps) {
const classNames = iconVariants({ className, color })
return (
<MaterialSymbol
icon={icon}
size={size}
className={classNames}
fill={isFilled}
{...props}
/>
)
return <MaterialSymbol className={classNames} {...props} fill={isFilled} />
}

View File

@@ -263,3 +263,32 @@
src: url(/_static/fonts/canela-deck/CanelaDeck-ThinItalic.otf)
format('opentype');
}
@font-face {
font-family: 'Material Symbols Rounded';
font-style: normal;
font-weight: 400;
font-display: block;
src: url(/_static/fonts/material-symbols/rounded-eaec15a9.woff2)
format('woff2');
}
.material-symbols {
font-family: 'Material Symbols Rounded';
font-weight: normal;
font-style: normal;
font-size: inherit;
line-height: 1;
letter-spacing: normal;
text-transform: none;
display: inline-block;
white-space: nowrap;
word-wrap: normal;
direction: ltr;
user-select: none;
font-feature-settings: 'liga';
-webkit-font-feature-settings: 'liga';
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
-moz-osx-font-smoothing: grayscale;
}

View File

@@ -0,0 +1,277 @@
// @ts-check
import crypto from 'node:crypto';
import { mkdir, readFile, rm, writeFile } from 'node:fs/promises';
import { createWriteStream } from 'node:fs';
import { resolve, join } from 'node:path';
import { Readable } from 'node:stream';
import { pipeline } from 'node:stream/promises';
import stringify from 'json-stable-stringify-without-jsonify';
// Defines where the font lives
const DESIGN_SYSTEM_FONT_DIR = `./packages/design-system/public/_static/fonts/material-symbols`;
const WEB_FONT_DIR = `./apps/scandic-web/public/_static/fonts/material-symbols`;
// Defines the settings for the font
const FONT_BASE_URL = `https://fonts.googleapis.com/css2?family=Material+Symbols+Rounded:opsz,wght,FILL,GRAD@24,400,0..1,0`;
// Defines the subset of icons for the font
const icons = [
'accessibility',
'accessible',
'add',
'add_circle',
'air',
'air_purifier_gen',
'apparel',
'arrow_back',
'arrow_forward',
'arrow_right',
'arrow_upward',
'asterisk',
'bakery_dining',
'beach_access',
'bed',
'business_center',
'calendar_add_on',
'calendar_month',
'calendar_today',
'call',
'call_quality',
'camera',
'cancel',
'chair',
'check',
'check_box',
'check_circle',
'checkroom',
'chevron_left',
'chevron_right',
'close',
'coffee',
'compare_arrows',
'concierge',
'connected_tv',
'content_copy',
'contract',
'countertops',
'credit_card',
'credit_card_heart',
'deck',
'delete',
'desk',
'device_thermostat',
'diamond',
'dining',
'directions',
'downhill_skiing',
'download',
'dresser',
'edit',
'edit_square',
'electric_bike',
'electric_car',
'elevator',
'emoji_transportation',
'error',
'error_circle_rounded',
'exercise',
'family_restroom',
'fastfood',
'favorite',
'fax',
'featured_seasonal_and_gifts',
'filter',
'filter_alt',
'floor_lamp',
'garage',
'globe',
'groups',
'health_and_beauty',
'heat',
'hiking',
'home',
'hot_tub',
'imagesmode',
'info',
'iron',
'kayaking',
'kettle',
'keyboard_arrow_down',
'keyboard_arrow_up',
'laundry',
'link',
'liquor',
'local_bar',
'local_cafe',
'local_convenience_store',
'local_laundry_service',
'local_parking',
'location_city',
'location_on',
'lock',
'luggage',
'mail',
'map',
'meeting_room',
'mode_fan',
'museum',
'nature',
'nightlife',
'open_in_new',
'pan_zoom',
'pedal_bike',
'person',
'pets',
'phone',
'pool',
'refresh',
'remove',
'restaurant',
'room_service',
'sauna',
'search',
'sell',
'shopping_bag',
'skateboarding',
'smoke_free',
'smoking_rooms',
'spa',
'sports_esports',
'sports_golf',
'sports_tennis',
'star',
'straighten',
'styler',
'swipe',
'sync_saved_locally',
'theater_comedy',
'train',
'travel',
'tv_remote',
'upload',
'visibility',
'visibility_off',
'warning',
'water_full',
'wifi',
];
function createHash(value) {
const stringified = stringify(value);
const hash = crypto.createHash('sha256');
hash.update(stringified);
return hash.digest('hex');
}
const hash = createHash(icons).substring(0, 8);
async function fetchIconUrl(url) {
const response = await fetch(url, {
headers: {
Accept:
'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7',
'User-Agent':
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36',
},
});
if (!response.ok) {
console.error(`Unable to fetch woff2 for ${url}`);
process.exit(1);
}
const text = await response.text();
const isWoff2 = /format\('woff2'\)/.test(text);
if (!isWoff2) {
console.error(`Unable to identify woff2 font in response`);
process.exit(1);
}
const srcUrl = text.match(/src: url\(([^)]+)\)/);
if (srcUrl && srcUrl[1]) {
return srcUrl[1];
}
return null;
}
async function download(url, destFolder) {
const dest = resolve(join(destFolder, `/rounded-${hash}.woff2`));
try {
const response = await fetch(url);
if (!response.ok) {
console.error(`Unable to fetch ${url}`);
process.exit(1);
}
if (!response.body) {
console.error(`Bad response from ${url}`);
process.exit(1);
}
const fileStream = createWriteStream(dest);
// @ts-expect-error: type mismatch
const readableNodeStream = Readable.fromWeb(response.body);
await pipeline(readableNodeStream, fileStream);
} catch (error) {
console.error(`Error downloading file from ${url}:`, error);
process.exit(1);
}
}
async function cleanFontDirs(folderPath) {
await rm(DESIGN_SYSTEM_FONT_DIR, { recursive: true, force: true });
await mkdir(DESIGN_SYSTEM_FONT_DIR, { recursive: true });
await rm(WEB_FONT_DIR, { recursive: true, force: true });
await mkdir(WEB_FONT_DIR, { recursive: true });
}
async function updateFontCSS() {
const file = './packages/design-system/lib/fonts.css';
const css = await readFile(file, {
encoding: 'utf-8',
});
await writeFile(
file,
css.replace(
/url\(\/_static\/fonts\/material-symbols\/rounded[^)]+\)/,
`url(/_static/fonts/material-symbols/rounded-${hash}.woff2)`
),
{
encoding: 'utf-8',
}
);
}
async function main() {
const fontUrl = `${FONT_BASE_URL}&icon_names=${icons.join(',')}&display=block`;
const iconUrl = await fetchIconUrl(fontUrl);
if (iconUrl) {
await cleanFontDirs();
await download(iconUrl, DESIGN_SYSTEM_FONT_DIR);
await download(iconUrl, WEB_FONT_DIR);
await updateFontCSS();
console.log('Successfully updated icons!');
process.exit(0);
} else {
console.error(
`Unable to find the icon font src URL in CSS response from Google Fonts at ${fontUrl}`
);
}
}
main();