Merge remote-tracking branch 'origin/master' into claude/design-handoff-prompt-d0aee4

This commit is contained in:
4gray committed 2026-10-14 03:53:06 +02:00
commit 31899d7b58
54 files changed
+4075 -1631

No files matched your search

+214
View File
@@ -0,0 +1,214 @@
import { execFileSync } from 'node:child_process';
import { readFile } from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { isScanned, stripComments } from './check-theme-references.mjs';
/**
* `apps/web/src/m3-theme.scss` themes only the Material components the app
* imports, through its `$material-components` list; `all-component-themes`
* also emitted about 24 KB of tokens for components nothing uses. This guard
* keeps the list in step with the imports in both directions: a component a
* new `@angular/material/<entry>` import brings in must be themed, or it
* renders with no colours at all, and a component whose last import went
* must leave the list.
*/
/** Angular renderer sources that load the theme (the remote control has its own styles). */
const SCANNED_PATHSPECS = ['apps/web/*.ts', 'libs/*.ts'];
export const THEME_FILE = 'apps/web/src/m3-theme.scss';
/**
* Runtime imports of a Material entry point: a static import that is not
* type-only (a `type` specifier list leaves nothing in the bundle) and a
* dynamic `import()` such as the one LazyMatDialog makes. A type-only
* import renders no component and so needs no theme.
*/
const STATIC_IMPORT =
/(?<![\w$.])import\s+(?!type\s)[^;]*?from\s+['"]@angular\/material\/([a-z-]+)['"]/g;
const DYNAMIC_IMPORT =
/\bimport\(\s*['"]@angular\/material\/([a-z-]+)['"]\s*\)/g;
/**
* The theme mixins (`mat.<name>-theme`, `mat.<name>-color`) each Material
* entry point needs. An entry point that is not listed has no themed
* component.
*/
export const THEMES_BY_ENTRY_POINT = new Map([
['autocomplete', ['autocomplete']],
['badge', ['badge']],
['bottom-sheet', ['bottom-sheet']],
['button', ['button', 'icon-button', 'fab']],
['button-toggle', ['button-toggle']],
['card', ['card']],
['checkbox', ['checkbox']],
['chips', ['chips']],
['core', ['core']],
['datepicker', ['datepicker']],
['dialog', ['dialog']],
['divider', ['divider']],
['expansion', ['expansion']],
['form-field', ['form-field']],
['grid-list', ['grid-list']],
['icon', ['icon']],
['input', ['input']],
['list', ['list']],
['menu', ['menu']],
['paginator', ['paginator']],
['progress-bar', ['progress-bar']],
['progress-spinner', ['progress-spinner']],
['radio', ['radio']],
['select', ['select']],
['sidenav', ['sidenav']],
['slide-toggle', ['slide-toggle']],
['slider', ['slider']],
['snack-bar', ['snack-bar']],
['sort', ['sort']],
['stepper', ['stepper']],
['table', ['table']],
['tabs', ['tabs']],
['timepicker', ['timepicker']],
['toolbar', ['toolbar']],
['tooltip', ['tooltip']],
['tree', ['tree']],
]);
/**
* Themed whatever is imported: `core` carries the ripple, option, optgroup
* and pseudo-checkbox tokens that select, menu and list render with.
*/
export const ALWAYS_THEMED = ['core'];
/**
* Material entry points the runtime sources import at runtime, statically or
* dynamically, with one importer each.
*/
export function usedEntryPoints(sources) {
const importers = new Map();
for (const { file, source } of sources) {
const code = stripComments(source);
for (const pattern of [STATIC_IMPORT, DYNAMIC_IMPORT]) {
for (const match of code.matchAll(pattern)) {
if (!importers.has(match[1])) importers.set(match[1], file);
}
}
}
return importers;
}
/** The theme names the imported entry points need, each with a reason. */
export function requiredThemes(importers) {
const required = new Map(
ALWAYS_THEMED.map((name) => [name, 'always themed'])
);
for (const [entryPoint, file] of importers) {
for (const name of THEMES_BY_ENTRY_POINT.get(entryPoint) ?? []) {
if (!required.has(name)) {
required.set(
name,
`@angular/material/${entryPoint} in ${file}`
);
}
}
}
return required;
}
/**
* The components the theme file themes: the `$material-components` list
* plus any `mat.<name>-theme` included directly. `all` means
* `all-component-themes` is still included, which themes everything.
*/
export function themedComponents(scss) {
const themed = new Set();
const list = scss.match(/\$material-components\s*:\s*\(([^)]*)\)/);
if (list) {
for (const item of list[1].split(',')) {
const name = item.replace(/\/\/.*$/gm, '').trim();
if (name) themed.add(name);
}
}
for (const match of scss.matchAll(/@include\s+mat\.([a-z-]+)-theme\(/g)) {
themed.add(match[1]);
}
return {
themed,
all: /@include\s+mat\.all-component-themes\(/.test(scss),
};
}
/**
* Themes that imports need but the file lacks, and themes the file carries
* that no import needs.
*/
export function compareThemeScope(required, { themed, all }) {
const unthemed = all
? []
: [...required].filter(([name]) => !themed.has(name));
const unused = [...themed].filter((name) => !required.has(name));
return { unthemed, unused };
}
/** Tracked renderer sources the guard reads, at any depth. */
export function listScannedFiles(rootDir) {
// No shell: `cmd.exe` treats single quotes as literal characters, so a
// POSIX-quoted pathspec reaches git intact on Windows and matches nothing.
return execFileSync('git', ['ls-files', ...SCANNED_PATHSPECS], {
cwd: rootDir,
encoding: 'utf8',
maxBuffer: 32 * 1024 * 1024,
})
.trim()
.split('\n')
.filter(Boolean)
.filter(isScanned);
}
const isMain =
process.argv[1] &&
path.resolve(process.argv[1]) ===
path.resolve(fileURLToPath(import.meta.url));
if (isMain) {
const rootDir = process.cwd();
const files = listScannedFiles(rootDir);
const sources = await Promise.all(
files.map(async (file) => ({
file,
source: await readFile(path.join(rootDir, file), 'utf8'),
}))
);
const required = requiredThemes(usedEntryPoints(sources));
const scope = themedComponents(
await readFile(path.join(rootDir, THEME_FILE), 'utf8')
);
const { unthemed, unused } = compareThemeScope(required, scope);
if (unthemed.length > 0) {
console.error(
`Material components the app imports but ${THEME_FILE} does not theme; they render without colours. Add them to $material-components:`
);
for (const [name, reason] of unthemed) {
console.error(`- ${name} (${reason})`);
}
process.exitCode = 1;
}
if (unused.length > 0) {
console.error(
`Material components ${THEME_FILE} themes although no runtime source imports them; their tokens only add to the initial stylesheet. Remove them from $material-components:`
);
for (const name of unused) {
console.error(`- ${name}`);
}
process.exitCode = 1;
}
if (!process.exitCode) {
console.log(
scope.all
? `Checked ${files.length} renderer sources; ${THEME_FILE} themes every Material component through all-component-themes.`
: `Checked ${files.length} renderer sources; ${THEME_FILE} themes exactly the ${scope.themed.size} Material components they import.`
);
}
}
@@ -0,0 +1,131 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';
import {
compareThemeScope,
requiredThemes,
themedComponents,
usedEntryPoints,
} from './check-material-theme-scope.mjs';
const lines = (...rows) => rows.join('\n');
test('usedEntryPoints lists each Material entry point with its first importer', () => {
const importers = usedEntryPoints([
{
file: 'a.component.ts',
source: lines(
"import { MatButtonModule } from '@angular/material/button';",
'import {',
' MatDialog,',
"} from '@angular/material/dialog';",
'import { MatIcon } from "@angular/material/icon";'
),
},
{
file: 'b.component.ts',
source: "import { MatButton } from '@angular/material/button';",
},
]);
assert.deepEqual(
[...importers],
[
['button', 'a.component.ts'],
['dialog', 'a.component.ts'],
['icon', 'a.component.ts'],
]
);
});
test('usedEntryPoints counts dynamic imports and ignores type-only ones and comments', () => {
const importers = usedEntryPoints([
{
file: 'lazy-mat-dialog.ts',
source: lines(
"import type { MatDialog } from '@angular/material/dialog';",
"type DialogModule = typeof import('@angular/material/dialog');",
'loadDialogModule = () =>',
" import('@angular/material/dialog');"
),
},
{
file: 'types-only.ts',
source: lines(
"import type { MatTooltip } from '@angular/material/tooltip';",
"// import { MatChipsModule } from '@angular/material/chips';",
"/* import { MatTabsModule } from '@angular/material/tabs'; */"
),
},
]);
assert.deepEqual([...importers], [['dialog', 'lazy-mat-dialog.ts']]);
});
test('requiredThemes always keeps core and expands the button entry point', () => {
const required = requiredThemes(
new Map([
['button', 'a.ts'],
['tooltip', 'b.ts'],
['testing', 'c.ts'],
])
);
assert.deepEqual(
[...required],
[
['core', 'always themed'],
['button', '@angular/material/button in a.ts'],
['icon-button', '@angular/material/button in a.ts'],
['fab', '@angular/material/button in a.ts'],
['tooltip', '@angular/material/tooltip in b.ts'],
]
);
});
test('themedComponents reads the list, direct includes and the all-components mixin', () => {
assert.deepEqual(
themedComponents(
lines(
'$material-components: (',
' core,',
' button, // used by every dialog',
' icon-button,',
');',
'@include mat.tooltip-theme($theme);'
)
),
{
themed: new Set(['core', 'button', 'icon-button', 'tooltip']),
all: false,
}
);
assert.deepEqual(
themedComponents('html { @include mat.all-component-themes($t); }'),
{ themed: new Set(), all: true }
);
});
test('compareThemeScope reports missing and surplus themes', () => {
const required = new Map([
['core', 'always themed'],
['button', '@angular/material/button in a.ts'],
]);
assert.deepEqual(
compareThemeScope(required, {
themed: new Set(['core', 'tabs']),
all: false,
}),
{
unthemed: [['button', '@angular/material/button in a.ts']],
unused: ['tabs'],
}
);
// all-component-themes leaves nothing unthemed, but is still surplus-free
// only when the list is empty.
assert.deepEqual(
compareThemeScope(required, { themed: new Set(), all: true }),
{ unthemed: [], unused: [] }
);
});
+2 -2
View File
@@ -3,11 +3,11 @@
"journeys": {
"launch": {
"renderer.initialBytes": {
"value": 1534604,
"value": 1345807,
"unit": "bytes",
"slack": 4096,
"updatedAt": "2026-10-13",
"evidencePr": 1928,
"evidencePr": 1931,
"measuredWith": "pnpm nx build web && pnpm run perf:initial-bytes"
},
"renderer.ipcCallsToFirstCard": {