@rblabs-dev/axiom 0.0.0-stage → 1.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Rodolfo Bocanegra
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,156 @@
1
- # Temporary Holding Version
1
+ # @rblabs-dev/axiom
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Axiom is the RB Labs design system for Angular and Angular Material 3: the theme, the `--rb-*`
4
+ tokens, self-hosted fonts and icons, kit classes, content components, the app and public shells,
5
+ and a `/design-system` showcase page. Every RB Labs Angular frontend uses it.
6
+
7
+ The specification, with every token, component and rule, is
8
+ [docs/design-system.md](https://github.com/rodolfoBocanegra/rblabs-axiom/blob/main/docs/design-system.md).
9
+ This README covers installing the package and wiring it into an app.
10
+
11
+ ## Requirements
12
+
13
+ - Angular 21 or 22, with the matching `@angular/material` and `@angular/cdk`. The package also uses
14
+ `@angular/forms`, `@angular/router` and `@angular/platform-browser`, which every Angular CLI app
15
+ has.
16
+ - SCSS global styles: `"inlineStyleLanguage": "scss"` in `angular.json` and a `src/styles.scss`.
17
+ - Browsers with `light-dark()` and `color-mix()`: Chrome and Edge 123, Firefox 120, Safari 17.5 or
18
+ later.
19
+
20
+ ## Install
21
+
22
+ ```bash
23
+ npm install @rblabs-dev/axiom @angular/material@<major> @angular/cdk@<major>
24
+ ```
25
+
26
+ Inter Variable comes with the package (`@fontsource-variable/inter`). Nothing loads from a CDN.
27
+
28
+ ## Set up an app
29
+
30
+ Run these steps from the folder that holds the app's `angular.json`.
31
+
32
+ 1. **npm scripts.** The package installs the `axiom` command. Add these scripts, and append the
33
+ checks to the app's own `lint` and `build`:
34
+
35
+ ```json
36
+ "lint": "<existing lint> && npm run check:style-literals",
37
+ "build": "ng build && npm run verify:production-assets",
38
+ "check:style-literals": "axiom check-style-literals",
39
+ "verify:production-assets": "axiom verify-production-assets",
40
+ "build:icon-font": "axiom build-icon-font",
41
+ "build:brand-assets": "axiom build-brand-assets"
42
+ ```
43
+
44
+ Create `style-literals.allowlist.json` containing `[]` next to `package.json`.
45
+
46
+ 2. **Icon font.** The kit's icons are included automatically. List the icons the app's own
47
+ templates use in `scripts/material-symbols-icons.txt`, one per line, then run:
48
+
49
+ ```bash
50
+ npm run build:icon-font
51
+ ```
52
+
53
+ It writes `public/fonts/material-symbols-outlined-<release>-400-subset.<hash>.woff2` and its
54
+ `NOTICE.txt`. `angular.json` must serve `public/` (`{ "glob": "**/*", "input": "public" }`, the
55
+ Angular CLI default); no other asset entry is needed.
56
+
57
+ 3. **Global styles.** Call the entry mixin once, at the top of `src/styles.scss`, with the subset's
58
+ file name (later runs of `build:icon-font` update it):
59
+
60
+ ```scss
61
+ @use '@rblabs-dev/axiom' as rb;
62
+
63
+ @include rb.design-system($icon-font-url: '/fonts/<subset file name>');
64
+ ```
65
+
66
+ Remove any other Material theme setup (`mat.core()`, `mat.define-theme()`, prebuilt themes).
67
+ `$scope: '.rb-scope'` applies the kit only inside that class, for an app that still has global
68
+ styles of its own. The module also forwards `rb.below()`, `rb.above()` and `$breakpoints`.
69
+
70
+ 4. **Providers.** In `app.config.ts`:
71
+
72
+ ```ts
73
+ import { provideRbDesignSystem } from '@rblabs-dev/axiom';
74
+
75
+ export const appConfig: ApplicationConfig = {
76
+ providers: [provideRouter(routes), provideRbDesignSystem()],
77
+ };
78
+ ```
79
+
80
+ It applies the stored theme before the first render and makes Material Symbols the default
81
+ `<mat-icon>` font. `provideRbDesignSystem({ forcedTheme: 'dark' })` keeps an app dark-only
82
+ while it migrates, and `legacyStorageKeys` moves a theme choice stored under an older key.
83
+
84
+ 5. **Showcase route.** Route `/design-system` to the showcase, lazily, so it stays out of the
85
+ initial bundle:
86
+
87
+ ```ts
88
+ {
89
+ path: 'design-system',
90
+ title: 'Design system',
91
+ loadComponent: () =>
92
+ import('@rblabs-dev/axiom/showcase').then((m) => m.DesignSystemShowcaseComponent),
93
+ }
94
+ ```
95
+
96
+ 6. **Favicon and document head.** Write the app's favicon from the brand template, then reference
97
+ it in `index.html` with the theme colors:
98
+
99
+ ```bash
100
+ npm run build:brand-assets -- --app <admin|auth|rover|photogrammetry>
101
+ ```
102
+
103
+ ```html
104
+ <link rel="icon" type="image/svg+xml" href="favicon.svg" />
105
+ <meta name="theme-color" content="#f4f7fb" media="(prefers-color-scheme: light)" />
106
+ <meta name="theme-color" content="#08111d" media="(prefers-color-scheme: dark)" />
107
+ ```
108
+
109
+ 7. **Build.** `npm run build` builds the app and runs `axiom verify-production-assets`.
110
+
111
+ Then use the components and kit classes in templates:
112
+
113
+ ```ts
114
+ import { RbPageHeaderComponent, RbPanelComponent } from '@rblabs-dev/axiom';
115
+ ```
116
+
117
+ ```html
118
+ <rb-page-header eyebrow="Registry" title="Services" />
119
+ <rb-panel title="Details">…</rb-panel>
120
+ ```
121
+
122
+ Section 18 of the specification lists the full adoption, including shells, views and the evidence
123
+ each pull request needs.
124
+
125
+ ## What the package contains
126
+
127
+ | Import or path | Contents |
128
+ | -------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- |
129
+ | `@rblabs-dev/axiom` | Content components, shells, brand components, `ThemeService`, `provideRbDesignSystem()`, `RB_BREAKPOINTS` and `RB_DESIGN_SYSTEM_VERSION` |
130
+ | `@rblabs-dev/axiom/showcase` | `DesignSystemShowcaseComponent`, the `/design-system` page |
131
+ | `@use '@rblabs-dev/axiom'` (SCSS) | `rb.design-system()`, `rb.below()`, `rb.above()`, `$breakpoints` and `$version`; emits no CSS until the mixin is called |
132
+ | `axiom` (command) | The four commands below |
133
+ | `brand/`, `material-symbols-icons.txt` | Brand templates and the kit's icon inventory, read by the commands |
134
+
135
+ ## The `axiom` command
136
+
137
+ Each command reads the app's source root, `public/` folder and build output from `angular.json`.
138
+ With several applications in one workspace, pass `--project <name>`. `npx axiom help` lists them.
139
+
140
+ | Command | What it does |
141
+ | --------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- |
142
+ | `axiom build-icon-font [--output <dir>]` | Downloads the Material Symbols subset for the kit's and the app's icons, replaces the previous one in `public/fonts/` and updates its name in `src/**/*.scss` |
143
+ | `axiom build-brand-assets --app <name> [--out-dir <dir>]` | Writes `public/favicon.svg`. With `--og-image <out.svg> --description <text> --domain <host>`, also writes the social preview SVG |
144
+ | `axiom verify-production-assets [--scan <dir>]...` | After a production build, fails on Google Fonts references, a missing favicon or icon font, or an icon used in `src/` that no inventory lists |
145
+ | `axiom check-style-literals` | Fails on hex, `rgb()` or `hsl()` color literals in `src/**/*.scss` that `style-literals.allowlist.json` does not list |
146
+
147
+ ## Versions
148
+
149
+ The package follows Semantic Versioning, and the package version is the kit version that the
150
+ showcase and `RB_DESIGN_SYSTEM_VERSION` report. Release notes are on the
151
+ [GitHub releases](https://github.com/rodolfoBocanegra/rblabs-axiom/releases) page.
152
+
153
+ ## License
154
+
155
+ MIT. The icon font that `axiom build-icon-font` downloads is Material Symbols (Apache License 2.0),
156
+ and Inter Variable is licensed under the SIL Open Font License 1.1.
@@ -0,0 +1,21 @@
1
+ <!--
2
+ Per-app favicon template (docs/design-system.md, section 12.3). Do not copy by hand: run
3
+ npm run build:brand-assets -- --app <admin|auth|rover|photogrammetry>
4
+ which fills {{appName}} and {{badgeColor}} and writes public/favicon.svg.
5
+ -->
6
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-labelledby="title">
7
+ <title id="title">RB Labs {{appName}}</title>
8
+ <defs>
9
+ <linearGradient id="brand" x1="8" y1="8" x2="56" y2="56" gradientUnits="userSpaceOnUse">
10
+ <stop stop-color="#67a2ff" />
11
+ <stop offset="1" stop-color="#2f78ed" />
12
+ </linearGradient>
13
+ </defs>
14
+ <rect width="64" height="64" rx="16" fill="#08111d" />
15
+ <rect x="5" y="5" width="54" height="54" rx="14" fill="url(#brand)" />
16
+ <g fill="none" stroke="#f6f9fd" stroke-linecap="round" stroke-linejoin="round" stroke-width="3.5">
17
+ <path d="M17 47V17h7.3c4.9 0 7.8 2.7 7.8 6.9s-2.9 6.8-7.8 6.8H17M24.3 30.7 34.5 47" />
18
+ <path d="M40 17v30M40 17h6.8c4.4 0 7.1 2.6 7.1 6.2s-2.7 5.9-7.1 5.9H40M40 29.1h7.5c4.7 0 7.7 3.2 7.7 7.1s-3 7.8-7.7 7.8H40" />
19
+ </g>
20
+ <circle cx="52" cy="52" r="9" fill="{{badgeColor}}" stroke="#08111d" stroke-width="4" />
21
+ </svg>
@@ -0,0 +1,37 @@
1
+ <!--
2
+ Social preview template (docs/design-system.md, section 12.4): 1200x630, always in the dark
3
+ palette, 64px safe margin. Do not fill by hand: run
4
+ npm run build:brand-assets -- --app <app> --og-image <out.svg> --description "<text>" --domain <host>
5
+ and export the result to public/og-image.png as the specification describes.
6
+ -->
7
+ <svg xmlns="http://www.w3.org/2000/svg" width="1200" height="630" viewBox="0 0 1200 630">
8
+ <style>
9
+ @font-face {
10
+ font-family: 'Inter Variable';
11
+ font-weight: 100 900;
12
+ src: url('{{interFontUrl}}') format('woff2');
13
+ }
14
+ text {
15
+ font-family: 'Inter Variable', Inter, 'Segoe UI', system-ui, sans-serif;
16
+ }
17
+ </style>
18
+ <defs>
19
+ <linearGradient id="brand" x1="8" y1="8" x2="56" y2="56" gradientUnits="userSpaceOnUse">
20
+ <stop stop-color="#67a2ff" />
21
+ <stop offset="1" stop-color="#2f78ed" />
22
+ </linearGradient>
23
+ </defs>
24
+ <rect width="1200" height="630" fill="#08111d" />
25
+ <svg x="64" y="64" width="160" height="160" viewBox="0 0 64 64">
26
+ <rect width="64" height="64" rx="16" fill="#08111d" />
27
+ <rect x="5" y="5" width="54" height="54" rx="14" fill="url(#brand)" />
28
+ <g fill="none" stroke="#f6f9fd" stroke-linecap="round" stroke-linejoin="round" stroke-width="3.5">
29
+ <path d="M17 47V17h7.3c4.9 0 7.8 2.7 7.8 6.9s-2.9 6.8-7.8 6.8H17M24.3 30.7 34.5 47" />
30
+ <path d="M40 17v30M40 17h6.8c4.4 0 7.1 2.6 7.1 6.2s-2.7 5.9-7.1 5.9H40M40 29.1h7.5c4.7 0 7.7 3.2 7.7 7.1s-3 7.8-7.7 7.8H40" />
31
+ </g>
32
+ </svg>
33
+ <text x="64" y="306" fill="#a3b4c9" font-size="40" font-weight="600">RB Labs</text>
34
+ <text x="64" y="400" fill="#f6f9fd" font-size="88" font-weight="700" letter-spacing="-1.76">{{appName}}</text>
35
+ <text x="64" y="462" fill="#dce7f5" font-size="32" font-weight="400">{{description}}</text>
36
+ <text x="64" y="566" fill="#8b9db4" font-size="28" font-weight="500">{{domain}}</text>
37
+ </svg>
@@ -0,0 +1,16 @@
1
+ <!-- The RB monogram (docs/design-system.md, section 12.1). Colors are the fixed brand tokens. -->
2
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-labelledby="title">
3
+ <title id="title">RB Labs</title>
4
+ <defs>
5
+ <linearGradient id="brand" x1="8" y1="8" x2="56" y2="56" gradientUnits="userSpaceOnUse">
6
+ <stop stop-color="#67a2ff" />
7
+ <stop offset="1" stop-color="#2f78ed" />
8
+ </linearGradient>
9
+ </defs>
10
+ <rect width="64" height="64" rx="16" fill="#08111d" />
11
+ <rect x="5" y="5" width="54" height="54" rx="14" fill="url(#brand)" />
12
+ <g fill="none" stroke="#f6f9fd" stroke-linecap="round" stroke-linejoin="round" stroke-width="3.5">
13
+ <path d="M17 47V17h7.3c4.9 0 7.8 2.7 7.8 6.9s-2.9 6.8-7.8 6.8H17M24.3 30.7 34.5 47" />
14
+ <path d="M40 17v30M40 17h6.8c4.4 0 7.1 2.6 7.1 6.2s-2.7 5.9-7.1 5.9H40M40 29.1h7.5c4.7 0 7.7 3.2 7.7 7.1s-3 7.8-7.7 7.8H40" />
15
+ </g>
16
+ </svg>
package/cli/axiom.mjs ADDED
@@ -0,0 +1,56 @@
1
+ #!/usr/bin/env node
2
+ // The `axiom` command apps run from their npm scripts (docs/design-system.md, section 14). Run it
3
+ // from the folder that holds the app's angular.json; see `axiom help`.
4
+
5
+ const commands = {
6
+ 'build-icon-font': {
7
+ load: () => import('./build-icon-font.mjs'),
8
+ usage: '[--project <name>] [--output <dir>]',
9
+ summary: "download the Material Symbols subset into the app's public/fonts/",
10
+ },
11
+ 'build-brand-assets': {
12
+ load: () => import('./build-brand-assets.mjs'),
13
+ usage:
14
+ '--app <admin|auth|rover|photogrammetry> [--project <name>] [--out-dir <dir>]\n' +
15
+ ' [--og-image <out.svg> --description <text> --domain <host>]',
16
+ summary: "write the app's favicon.svg (and the og-image SVG) from the brand templates",
17
+ },
18
+ 'verify-production-assets': {
19
+ load: () => import('./verify-production-assets.mjs'),
20
+ usage: '[--project <name>] [--scan <dir>]...',
21
+ summary: 'check the production build: favicon, icon font and icon inventory',
22
+ },
23
+ 'check-style-literals': {
24
+ load: () => import('./check-style-literals.mjs'),
25
+ usage: '[--project <name>]',
26
+ summary: "fail on color literals in the app's own stylesheets",
27
+ },
28
+ };
29
+
30
+ function help() {
31
+ const lines = ['Usage: axiom <command> [options]', '', 'Commands:'];
32
+ for (const [name, command] of Object.entries(commands)) {
33
+ lines.push(` ${name.padEnd(26)}${command.summary}`, ` axiom ${name} ${command.usage}`);
34
+ }
35
+ lines.push(
36
+ '',
37
+ '--project picks the application in ./angular.json; it can be left out when there is only one.',
38
+ );
39
+ return lines.join('\n');
40
+ }
41
+
42
+ const [name, ...argv] = process.argv.slice(2);
43
+ if (!name || name === 'help' || name === '--help') {
44
+ console.log(help());
45
+ } else if (!Object.hasOwn(commands, name)) {
46
+ console.error(`Unknown command "${name}".\n\n${help()}`);
47
+ process.exitCode = 1;
48
+ } else {
49
+ try {
50
+ const { run } = await commands[name].load();
51
+ await run(argv);
52
+ } catch (error) {
53
+ console.error(error instanceof Error ? error.message : error);
54
+ process.exitCode = 1;
55
+ }
56
+ }
@@ -0,0 +1,115 @@
1
+ // Produces an app's brand assets from the kit templates in this package's brand/ folder
2
+ // (docs/design-system.md, sections 12.3 and 12.4).
3
+ //
4
+ // axiom build-brand-assets --app <admin|auth|rover|photogrammetry> [--project <name>]
5
+ // [--out-dir <dir>]
6
+ // writes favicon.svg to the app's public/ folder (or <dir>): the monogram with the app's badge
7
+ // and the title "RB Labs <App>"
8
+ //
9
+ // ... --og-image <out.svg> --description "<at most 60 characters>" --domain <host>
10
+ // also writes the filled social preview template to <out.svg>, for export to the app's
11
+ // public/og-image.png (1200x630). The SVG loads Inter from node_modules, so export it on the
12
+ // machine that generated it.
13
+ //
14
+ // The output is per-app and lives in the app; commit its public/favicon.svg and og-image.png.
15
+
16
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs';
17
+ import { createRequire } from 'node:module';
18
+ import path from 'node:path';
19
+ import { pathToFileURL } from 'node:url';
20
+
21
+ import { brandDir, parseOptions, relativeToCwd, resolveProject } from './project.mjs';
22
+
23
+ const require = createRequire(import.meta.url);
24
+
25
+ // Section 12.3. Badge colors exist only in favicons; they are not UI tokens.
26
+ export const RB_APPS = {
27
+ admin: { name: 'Admin', badgeColor: '#7dd3fc' },
28
+ auth: { name: 'Auth', badgeColor: '#6fdcaa' },
29
+ rover: { name: 'Rover', badgeColor: '#c4b5fd' },
30
+ photogrammetry: { name: 'Photogrammetry', badgeColor: '#f2a65a' },
31
+ };
32
+
33
+ const maxDescriptionLength = 60;
34
+
35
+ function escapeXml(text) {
36
+ return text
37
+ .replace(/&/gu, '&amp;')
38
+ .replace(/</gu, '&lt;')
39
+ .replace(/>/gu, '&gt;')
40
+ .replace(/"/gu, '&quot;');
41
+ }
42
+
43
+ // Fills {{name}} placeholders and drops the template's leading comments (the kit header and the
44
+ // usage note), which do not belong in the generated per-app file.
45
+ export function fillTemplate(template, values) {
46
+ const body = template.replace(/^(?:\s*<!--[\s\S]*?-->)+\s*/u, '');
47
+ const filled = body.replace(/\{\{(\w+)\}\}/gu, (placeholder, name) => {
48
+ if (!(name in values)) throw new Error(`No value for ${placeholder}.`);
49
+ return escapeXml(values[name]);
50
+ });
51
+ return filled.endsWith('\n') ? filled : `${filled}\n`;
52
+ }
53
+
54
+ function readTemplate(name) {
55
+ return readFileSync(path.join(brandDir, name), 'utf8');
56
+ }
57
+
58
+ // The variable Inter file the og-image template embeds, from this package's dependency.
59
+ function interFontPath() {
60
+ try {
61
+ return require.resolve('@fontsource-variable/inter/files/inter-latin-wght-normal.woff2');
62
+ } catch {
63
+ throw new Error('Cannot find @fontsource-variable/inter; run npm install first.');
64
+ }
65
+ }
66
+
67
+ export function run(argv) {
68
+ const options = parseOptions(argv);
69
+ const app = RB_APPS[options.app];
70
+ if (!app) {
71
+ throw new Error(`--app must be one of: ${Object.keys(RB_APPS).join(', ')}.`);
72
+ }
73
+
74
+ // Validate everything before writing anything.
75
+ const { description, domain } = options;
76
+ const outDir = options['out-dir']
77
+ ? path.resolve(options['out-dir'])
78
+ : resolveProject(options.project).publicDir;
79
+ if (!existsSync(outDir)) throw new Error(`${relativeToCwd(outDir)} does not exist.`);
80
+ let interFont;
81
+ if (options['og-image']) {
82
+ if (!description || !domain) {
83
+ throw new Error('--og-image needs --description and --domain.');
84
+ }
85
+ if (description.length > maxDescriptionLength) {
86
+ throw new Error(
87
+ `--description has ${description.length} characters; the limit is ${maxDescriptionLength}.`,
88
+ );
89
+ }
90
+ interFont = interFontPath();
91
+ }
92
+
93
+ const faviconPath = path.join(outDir, 'favicon.svg');
94
+ writeFileSync(
95
+ faviconPath,
96
+ fillTemplate(readTemplate('favicon.template.svg'), {
97
+ appName: app.name,
98
+ badgeColor: app.badgeColor,
99
+ }),
100
+ );
101
+ console.log(`Wrote ${relativeToCwd(faviconPath)} for RB Labs ${app.name}.`);
102
+
103
+ if (!options['og-image']) return;
104
+ const ogPath = path.resolve(options['og-image']);
105
+ writeFileSync(
106
+ ogPath,
107
+ fillTemplate(readTemplate('og-image.template.svg'), {
108
+ appName: app.name,
109
+ description,
110
+ domain,
111
+ interFontUrl: pathToFileURL(interFont).href,
112
+ }),
113
+ );
114
+ console.log(`Wrote ${relativeToCwd(ogPath)}; export it to public/og-image.png at 1200x630.`);
115
+ }
@@ -0,0 +1,134 @@
1
+ // Builds an app's Material Symbols Outlined subset (docs/design-system.md, section 10).
2
+ //
3
+ // axiom build-icon-font [--project <name>] [--output <dir>] (npm run build:icon-font)
4
+ //
5
+ // Reads the kit's ligature inventory (material-symbols-icons.txt in this package: every icon the
6
+ // kit's components and showcase use) and the app's own, scripts/material-symbols-icons.txt in the
7
+ // project root, when it exists. Downloads a WOFF2 subset with exactly those icons (static instance:
8
+ // FILL 0, wght 400, GRAD 0, opsz 24) and writes it to the app's public/fonts/ as
9
+ // material-symbols-outlined-<release>-400-subset.<hash>.woff2, with a NOTICE.txt. The content hash
10
+ // is part of the name because servers cache font files as immutable: adding an icon must produce a
11
+ // new URL. The previous subset is deleted and every reference to its name in the app's .scss files
12
+ // is updated.
13
+ //
14
+ // This is a developer-time step; the app never requests Google Fonts at runtime. `--output` writes
15
+ // the font to another directory and leaves public/ and src/ untouched (useful for a trial run).
16
+
17
+ import { createHash } from 'node:crypto';
18
+ import { mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs';
19
+ import path from 'node:path';
20
+
21
+ import { parseOptions, readIconInventories, relativeToCwd, resolveProject } from './project.mjs';
22
+
23
+ const fontPattern = /^material-symbols-outlined-.+\.woff2$/u;
24
+ const maxBytes = 16_000;
25
+ // Google Fonts serves WOFF2 only to browsers it recognizes.
26
+ const userAgent =
27
+ 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36';
28
+
29
+ async function fetchSubset(names) {
30
+ // Google Fonts requires `icon_names` in alphabetical order.
31
+ const iconNames = [...names].sort().join(',');
32
+ const cssUrl =
33
+ 'https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@24,400,0,0' +
34
+ `&icon_names=${iconNames}&display=block`;
35
+ const cssResponse = await fetch(cssUrl, { headers: { 'User-Agent': userAgent } });
36
+ if (!cssResponse.ok)
37
+ throw new Error(`Google Fonts CSS request failed: HTTP ${cssResponse.status}`);
38
+ const css = await cssResponse.text();
39
+
40
+ const fontUrl = /src:\s*url\(([^)]+)\)\s*format\('woff2'\)/u.exec(css)?.[1];
41
+ if (!fontUrl) throw new Error('No WOFF2 source in the Google Fonts response.');
42
+ const release = /[?&]v=(v\d+)/u.exec(fontUrl)?.[1] ?? /\/(v\d+)\//u.exec(fontUrl)?.[1];
43
+ if (!release) throw new Error(`Cannot read the font release from ${fontUrl}`);
44
+
45
+ const fontResponse = await fetch(fontUrl, { headers: { 'User-Agent': userAgent } });
46
+ if (!fontResponse.ok) throw new Error(`Font download failed: HTTP ${fontResponse.status}`);
47
+ return { release, bytes: Buffer.from(await fontResponse.arrayBuffer()) };
48
+ }
49
+
50
+ export function contentHash(bytes) {
51
+ return createHash('sha256').update(bytes).digest('hex').slice(0, 8);
52
+ }
53
+
54
+ function listFiles(directory, extension) {
55
+ const files = [];
56
+ for (const entry of readdirSync(directory, { withFileTypes: true })) {
57
+ const entryPath = path.join(directory, entry.name);
58
+ if (entry.isDirectory()) files.push(...listFiles(entryPath, extension));
59
+ else if (entry.name.endsWith(extension)) files.push(entryPath);
60
+ }
61
+ return files;
62
+ }
63
+
64
+ function replaceReferences(sourceDir, previousNames, nextName) {
65
+ const updated = [];
66
+ for (const file of listFiles(sourceDir, '.scss')) {
67
+ const text = readFileSync(file, 'utf8');
68
+ let next = text;
69
+ for (const previous of previousNames) next = next.split(previous).join(nextName);
70
+ if (next !== text) {
71
+ writeFileSync(file, next);
72
+ updated.push(relativeToCwd(file));
73
+ }
74
+ }
75
+ return updated;
76
+ }
77
+
78
+ function writeNotice(directory, release, inventories) {
79
+ writeFileSync(
80
+ path.join(directory, 'NOTICE.txt'),
81
+ [
82
+ 'Material Symbols Outlined subset',
83
+ '',
84
+ 'Source: https://fonts.google.com/icons',
85
+ `Font release: ${release}`,
86
+ 'Subset configuration: weight 400, optical size 24, fill 0, grade 0',
87
+ `Icons: ${inventories.names.length}, the kit's inventory plus the app's ` +
88
+ '(scripts/material-symbols-icons.txt)',
89
+ 'Built by `axiom build-icon-font` (@rblabs-dev/axiom, the RB Labs design system).',
90
+ '',
91
+ 'Material Symbols are licensed under the Apache License, Version 2.0.',
92
+ 'https://www.apache.org/licenses/LICENSE-2.0',
93
+ '',
94
+ ].join('\n'),
95
+ );
96
+ }
97
+
98
+ export async function run(argv) {
99
+ const options = parseOptions(argv);
100
+ const project = resolveProject(options.project);
101
+ const outputDir = options.output ? path.resolve(options.output) : null;
102
+ const inventories = readIconInventories(project);
103
+ const { release, bytes } = await fetchSubset(inventories.names);
104
+ if (bytes.length > maxBytes) {
105
+ throw new Error(
106
+ `Subset is ${bytes.length} bytes; the limit is ${maxBytes}. Remove unused icons.`,
107
+ );
108
+ }
109
+
110
+ const fileName = `material-symbols-outlined-${release}-400-subset.${contentHash(bytes)}.woff2`;
111
+ const targetDir = outputDir ?? path.join(project.publicDir, 'fonts');
112
+ mkdirSync(targetDir, { recursive: true });
113
+
114
+ const previous = outputDir
115
+ ? []
116
+ : readdirSync(targetDir).filter((name) => fontPattern.test(name) && name !== fileName);
117
+ writeFileSync(path.join(targetDir, fileName), bytes);
118
+ writeNotice(targetDir, release, inventories);
119
+
120
+ for (const name of previous) rmSync(path.join(targetDir, name));
121
+ const updated = outputDir ? [] : replaceReferences(project.sourceDir, previous, fileName);
122
+
123
+ console.log(
124
+ `Wrote ${relativeToCwd(path.join(targetDir, fileName))} (${inventories.names.length} icons, ` +
125
+ `${statSync(path.join(targetDir, fileName)).size} bytes).`,
126
+ );
127
+ if (previous.length > 0) console.log(`Removed ${previous.join(', ')}.`);
128
+ if (updated.length > 0) console.log(`Updated references in ${updated.join(', ')}.`);
129
+ if (!outputDir && previous.length > 0 && updated.length === 0) {
130
+ console.warn(
131
+ `No stylesheet referenced the previous subset. Point $icon-font-url at /fonts/${fileName}.`,
132
+ );
133
+ }
134
+ }
@@ -0,0 +1,94 @@
1
+ // Literal-color guard for an app's own stylesheets (docs/design-system.md, sections 13 and 17.6).
2
+ // The kit's stylesheets are checked in the Axiom workspace.
3
+ //
4
+ // axiom check-style-literals [--project <name>] (npm run check:style-literals)
5
+ //
6
+ // Fails, naming each file and line, when a stylesheet under the app's source root (src/) uses a
7
+ // hex, rgb()/rgba() or hsl()/hsla() color literal instead of --rb-* or --mat-sys-* tokens.
8
+ //
9
+ // A documented exception (section 13) goes in style-literals.allowlist.json in the project root,
10
+ // as { "file": "src/…/x.scss", "literal": "#05080f", "reason": "docs/design-system.md 13.x …" },
11
+ // with `file` relative to the project root. An entry that no longer matches anything fails the
12
+ // check too, so the list cannot go stale.
13
+
14
+ import { existsSync, readFileSync, readdirSync } from 'node:fs';
15
+ import path from 'node:path';
16
+
17
+ import { parseOptions, relativeToCwd, resolveProject } from './project.mjs';
18
+
19
+ const literalPattern = /(?<![\w&-])#[0-9a-f]{3,8}(?![\w-])|\b(?:rgba?|hsla?)\([^)]*\)?/giu;
20
+
21
+ // Stylesheets under `folder`, as paths relative to `rootDir` with forward slashes.
22
+ function listStylesheets(rootDir, folder) {
23
+ const files = [];
24
+ for (const entry of readdirSync(path.join(rootDir, folder), { withFileTypes: true })) {
25
+ const relative = folder ? `${folder}/${entry.name}` : entry.name;
26
+ if (entry.isDirectory()) files.push(...listStylesheets(rootDir, relative));
27
+ else if (entry.isFile() && entry.name.endsWith('.scss')) files.push(relative);
28
+ }
29
+ return files;
30
+ }
31
+
32
+ // Comments may quote literals, for example to explain what a token replaced. They are blanked
33
+ // rather than removed so line numbers stay accurate.
34
+ function stripComments(text) {
35
+ return text
36
+ .replace(/\/\*[\s\S]*?\*\//gu, (comment) => comment.replace(/[^\n]/gu, ' '))
37
+ .replace(/(^|[^:])\/\/.*$/gmu, '$1');
38
+ }
39
+
40
+ function readAllowlist(allowlistPath) {
41
+ if (!existsSync(allowlistPath)) return [];
42
+ const entries = JSON.parse(readFileSync(allowlistPath, 'utf8'));
43
+ if (!Array.isArray(entries)) throw new Error('style-literals.allowlist.json must be an array.');
44
+ for (const entry of entries) {
45
+ if (!entry.file || !entry.literal || !entry.reason?.includes('docs/design-system.md')) {
46
+ throw new Error(
47
+ `style-literals.allowlist.json: ${JSON.stringify(entry)} needs "file", "literal" and a ` +
48
+ '"reason" that cites the docs/design-system.md section allowing it.',
49
+ );
50
+ }
51
+ }
52
+ return entries;
53
+ }
54
+
55
+ export function run(argv) {
56
+ const project = resolveProject(parseOptions(argv).project);
57
+ const allowlist = readAllowlist(project.allowlistPath);
58
+ const scannedFolder = path.relative(project.rootDir, project.sourceDir).split(path.sep).join('/');
59
+ const used = new Set();
60
+ const problems = [];
61
+
62
+ for (const file of listStylesheets(project.rootDir, scannedFolder)) {
63
+ const lines = stripComments(readFileSync(path.join(project.rootDir, file), 'utf8')).split('\n');
64
+
65
+ lines.forEach((line, index) => {
66
+ for (const [literal] of line.matchAll(literalPattern)) {
67
+ const allowed = allowlist.findIndex(
68
+ (entry) =>
69
+ entry.file === file && literal.toLowerCase().startsWith(entry.literal.toLowerCase()),
70
+ );
71
+ if (allowed >= 0) used.add(allowed);
72
+ else
73
+ problems.push(`${file}:${index + 1}: color literal \`${literal}\`; use a --rb-* token`);
74
+ }
75
+ });
76
+ }
77
+
78
+ allowlist.forEach((entry, index) => {
79
+ if (!used.has(index))
80
+ problems.push(`style-literals.allowlist.json: ${entry.file} no longer uses ${entry.literal}`);
81
+ });
82
+
83
+ if (problems.length > 0) {
84
+ throw new Error(
85
+ `Style literal check failed:\n${problems.map((p) => ` - ${p}`).join('\n')}\n` +
86
+ 'Read colors from --rb-* or --mat-sys-* tokens. A new exception needs a section in ' +
87
+ 'docs/design-system.md (13.4) and an entry in style-literals.allowlist.json.',
88
+ );
89
+ }
90
+ console.log(
91
+ `Style literal check passed: no color literals in ${relativeToCwd(project.sourceDir)}` +
92
+ (allowlist.length > 0 ? ` (${allowlist.length} documented exceptions).` : '.'),
93
+ );
94
+ }