diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 5a3fdcdb29..5d01d0185d 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -100,6 +100,10 @@ jobs:
if: steps.plan.outputs.code == 'true'
run: npm run format:check
+ - name: Check icon sizes
+ if: steps.plan.outputs.code == 'true'
+ run: npm run check:icon-size
+
- name: Build
if: steps.plan.outputs.code == 'true'
run: npm run build
diff --git a/apps/desktop/src/renderer/remote-project-directory-dialog.tsx b/apps/desktop/src/renderer/remote-project-directory-dialog.tsx
index 6eb90f2ece..f702aad677 100644
--- a/apps/desktop/src/renderer/remote-project-directory-dialog.tsx
+++ b/apps/desktop/src/renderer/remote-project-directory-dialog.tsx
@@ -7,7 +7,7 @@ import { Layout, LayoutContent, LayoutFooter } from '@astryxdesign/core/Layout';
import { HStack } from '@astryxdesign/core/Stack';
import { Text } from '@astryxdesign/core/Text';
import { useUiLocale } from '@maka/ui';
-import { Check, Eye, EyeOff, FolderOpen } from '@maka/ui/icons';
+import { ICON_SIZE, Check, Eye, EyeOff, FolderOpen } from '@maka/ui/icons';
import type {
DesktopProjectDirectoryEntry,
DesktopProjectDirectoryRoot,
@@ -204,9 +204,9 @@ export function RemoteProjectDirectoryDialog(props: {
}
+ icon={}
endContent={candidate.id === root?.id
- ?
+ ?
: undefined}
onClick={() => selectRoot(candidate)}
/>
@@ -253,7 +253,7 @@ export function RemoteProjectDirectoryDialog(props: {
key={entry.name}
variant="ghost"
label={entry.name}
- icon={}
+ icon={}
isDisabled={registering}
onClick={() => navigate([...segments, entry.name])}
/>
@@ -274,8 +274,8 @@ export function RemoteProjectDirectoryDialog(props: {
? copy.remoteDirectoryHideHidden
: copy.remoteDirectoryShowHidden}
icon={showHidden
- ?
- : }
+ ?
+ : }
aria-pressed={showHidden}
isDisabled={registering}
onClick={() => setShowHidden((current) => !current)}
diff --git a/apps/desktop/src/renderer/session-inspector-panel.tsx b/apps/desktop/src/renderer/session-inspector-panel.tsx
index d91cedd9a5..c7ef7bd3ba 100644
--- a/apps/desktop/src/renderer/session-inspector-panel.tsx
+++ b/apps/desktop/src/renderer/session-inspector-panel.tsx
@@ -710,7 +710,7 @@ function StepRow(props: {
}
+ icon={}
label={`${copy.copyPricingKey}: ${pricingKey}`}
onClick={() => {
void props.onCopyPricingKey(pricingKey);
diff --git a/apps/desktop/src/renderer/settings/about-settings-page.tsx b/apps/desktop/src/renderer/settings/about-settings-page.tsx
index 75a7d58dfd..a5fad5d3b3 100644
--- a/apps/desktop/src/renderer/settings/about-settings-page.tsx
+++ b/apps/desktop/src/renderer/settings/about-settings-page.tsx
@@ -1,6 +1,6 @@
import { useEffect, useId, useState } from 'react';
import { Badge, Link, List, ListItem } from '@astryxdesign/core';
-import { Sparkles } from '@maka/ui/icons';
+import { ICON_SIZE, Sparkles } from '@maka/ui/icons';
import {
Banner,
Button,
@@ -174,7 +174,7 @@ export function AboutSettingsPage(props: { onOpenKeyboardHelp?(): void }) {
as_wrapper="div"
className="settingsAboutHero"
as="h2"
- icon={ /* 64% of the 48px plate, matching .providerLogo's fill */}
+ icon={ /* plate glyph inside the About hero mark */}
iconClassName="settingsAboutLogo"
headingRowClassName="settingsAboutHeading"
title="Maka"
diff --git a/apps/desktop/src/renderer/settings/request-customization-editor.tsx b/apps/desktop/src/renderer/settings/request-customization-editor.tsx
index 29101030e5..064b6b0c61 100644
--- a/apps/desktop/src/renderer/settings/request-customization-editor.tsx
+++ b/apps/desktop/src/renderer/settings/request-customization-editor.tsx
@@ -7,7 +7,7 @@ import {
} from '@maka/core/runtime-policy';
import { type RequestHeaderUpdate } from '@maka/core/llm-connections';
import { Button, IconButton, TextArea, TextInput } from '@maka/ui';
-import { Trash2 } from '@maka/ui/icons';
+import { ICON_SIZE, Trash2 } from '@maka/ui/icons';
import { PasswordInput } from './password-input';
export interface RequestHeaderDraft {
@@ -148,7 +148,7 @@ export function RequestHeadersEditor(props: {
variant="ghost"
size="sm"
label={props.copy.removeHeader}
- icon={}
+ icon={}
isDisabled={props.disabled}
onClick={() =>
props.onHeadersChange(props.headers.filter(({ id }) => id !== header.id))
diff --git a/package-lock.json b/package-lock.json
index d7f92e39ed..d872ca19e7 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -25,6 +25,7 @@
"@ai-sdk/provider-utils": "5.0.27",
"@astryxdesign/cli": "0.4.0",
"@astryxdesign/core": "0.4.0",
+ "@babel/parser": "^7.29.7",
"@biomejs/biome": "2.5.6",
"@types/node": "^26.1.2",
"dugite": "3.2.2",
diff --git a/package.json b/package.json
index bcdc1d46cd..74ffb2c722 100644
--- a/package.json
+++ b/package.json
@@ -59,6 +59,7 @@
"package:windows-autoupdate-next": "node scripts/package-windows-autoupdate-next.mjs",
"verify:windows-autoupdate": "node scripts/verify-windows-autoupdate.mjs",
"astryx:theme": "node scripts/build-astryx-theme.mjs",
+ "check:icon-size": "node --test scripts/check-icon-size.test.mjs && node scripts/check-icon-size.mjs",
"astryx:surface-inventory": "node scripts/check-astryx-surface-inventory.mjs",
"astryx:surface-inventory:write": "node scripts/generate-astryx-surface-inventory.mjs",
"sync:model-metadata": "node scripts/sync-model-metadata.mjs",
@@ -75,6 +76,7 @@
"@ai-sdk/provider-utils": "5.0.27",
"@astryxdesign/cli": "0.4.0",
"@astryxdesign/core": "0.4.0",
+ "@babel/parser": "^7.29.7",
"@biomejs/biome": "2.5.6",
"@types/node": "^26.1.2",
"dugite": "3.2.2",
diff --git a/packages/ui/stories/icons.stories.tsx b/packages/ui/stories/icons.stories.tsx
index a157e6659c..2fe003ff6f 100644
--- a/packages/ui/stories/icons.stories.tsx
+++ b/packages/ui/stories/icons.stories.tsx
@@ -67,7 +67,7 @@ export const LucideIcons: Story = {
textAlign: 'center',
}}
>
-
+
{name}
))}
diff --git a/scripts/check-icon-size.mjs b/scripts/check-icon-size.mjs
new file mode 100644
index 0000000000..c9d6594db1
--- /dev/null
+++ b/scripts/check-icon-size.mjs
@@ -0,0 +1,521 @@
+#!/usr/bin/env node
+/**
+ * Product lucide icons must pick a rung from ICON_SIZE, not a statically
+ * provable raw pixel. Parse TSX so comments and unrelated components with a
+ * numeric size prop do not become false positives while aliases, derived icon
+ * collections, constant expressions, and props-object spreads remain governed.
+ */
+import { globSync, readFileSync } from 'node:fs';
+import { dirname, relative, resolve } from 'node:path';
+import { fileURLToPath, pathToFileURL } from 'node:url';
+import { parse } from '@babel/parser';
+
+const root = fileURLToPath(new URL('..', import.meta.url));
+const GLOBS = [
+ 'packages/ui/src/**/*.{ts,tsx}',
+ 'packages/ui/stories/**/*.{ts,tsx}',
+ 'apps/desktop/src/**/*.{ts,tsx}',
+];
+const ICON_METADATA_EXPORTS = new Set(['ICON_SIZE', 'LucideIcon', 'LucideProps']);
+const NUMERIC_SIZE_STRING = /^[+-]?(?:\d+|\d*\.\d+)$/;
+
+function normalizedRelativePath(repoRoot, file) {
+ return relative(repoRoot, file).split('\\').join('/');
+}
+
+function withoutModuleExtension(path) {
+ return path.replace(/\.(?:m?[jt]sx?)$/u, '');
+}
+
+function isIconSeamSpecifier(specifier, file, repoRoot) {
+ if (specifier === '@maka/ui/icons') return true;
+ if (!specifier.startsWith('.')) return false;
+ return (
+ withoutModuleExtension(resolve(dirname(file), specifier)) ===
+ resolve(repoRoot, 'packages/ui/src/icons')
+ );
+}
+
+function unwrapExpression(expression) {
+ if (
+ expression.type === 'ParenthesizedExpression' ||
+ expression.type === 'TSAsExpression' ||
+ expression.type === 'TSTypeAssertion' ||
+ expression.type === 'TSSatisfiesExpression' ||
+ expression.type === 'TSNonNullExpression'
+ ) {
+ return unwrapExpression(expression.expression);
+ }
+ return expression;
+}
+
+function isStaticRawSize(expression, scope) {
+ const unwrapped = unwrapExpression(expression);
+ if (unwrapped.type === 'NumericLiteral') return true;
+ if (unwrapped.type === 'StringLiteral') {
+ return NUMERIC_SIZE_STRING.test(unwrapped.value.trim());
+ }
+ if (unwrapped.type === 'TemplateLiteral' && unwrapped.expressions.length === 0) {
+ return NUMERIC_SIZE_STRING.test(unwrapped.quasis[0]?.value.cooked?.trim() ?? '');
+ }
+ if (unwrapped.type === 'Identifier') {
+ return resolveBinding(scope, unwrapped.name) === 'raw-size';
+ }
+ if (
+ unwrapped.type === 'MemberExpression' &&
+ !unwrapped.computed &&
+ unwrapped.property.type === 'Identifier'
+ ) {
+ const object = inferExpressionValue(unwrapped.object, scope);
+ return (
+ object?.kind === 'record' && object.properties.get(unwrapped.property.name) === 'raw-size'
+ );
+ }
+ if (
+ unwrapped.type === 'UnaryExpression' &&
+ (unwrapped.operator === '+' || unwrapped.operator === '-')
+ ) {
+ return isStaticRawSize(unwrapped.argument, scope);
+ }
+ if (
+ unwrapped.type === 'BinaryExpression' &&
+ ['+', '-', '*', '/', '%', '**'].includes(unwrapped.operator)
+ ) {
+ return isStaticRawSize(unwrapped.left, scope) && isStaticRawSize(unwrapped.right, scope);
+ }
+ if (unwrapped.type === 'ConditionalExpression') {
+ return (
+ isStaticRawSize(unwrapped.consequent, scope) && isStaticRawSize(unwrapped.alternate, scope)
+ );
+ }
+ return false;
+}
+
+function propertyName(property) {
+ if (property.type === 'Identifier' || property.type === 'StringLiteral')
+ return property.name ?? property.value;
+ return undefined;
+}
+
+function mergeValueKinds(values) {
+ const present = values.filter((value) => value !== 'empty' && value !== 'unknown');
+ if (present.length === 0) return values.includes('empty') ? 'empty' : 'unknown';
+ if (present.every((value) => value === present[0])) return present[0];
+ if (present.every((value) => value?.kind === 'collection')) {
+ return {
+ kind: 'collection',
+ element: mergeValueKinds(present.map((value) => value.element)),
+ };
+ }
+ return 'unknown';
+}
+
+function inferExpressionValue(expression, scope) {
+ const unwrapped = unwrapExpression(expression);
+ if (isStaticRawSize(unwrapped, scope)) return 'raw-size';
+ if (unwrapped.type === 'Identifier') return resolveBinding(scope, unwrapped.name);
+ if (
+ unwrapped.type === 'MemberExpression' &&
+ !unwrapped.computed &&
+ unwrapped.property.type === 'Identifier'
+ ) {
+ const object = inferExpressionValue(unwrapped.object, scope);
+ if (object === 'namespace') {
+ return ICON_METADATA_EXPORTS.has(unwrapped.property.name) ? 'not-icon' : 'icon';
+ }
+ if (object?.kind === 'record') {
+ return object.properties.get(unwrapped.property.name) ?? 'unknown';
+ }
+ }
+ if (unwrapped.type === 'ConditionalExpression') {
+ return mergeValueKinds([
+ inferExpressionValue(unwrapped.consequent, scope),
+ inferExpressionValue(unwrapped.alternate, scope),
+ ]);
+ }
+ if (unwrapped.type === 'ObjectExpression') {
+ const properties = new Map();
+ for (const property of unwrapped.properties) {
+ if (property.type !== 'ObjectProperty' || property.computed) continue;
+ const name = propertyName(property.key);
+ if (name !== undefined) properties.set(name, inferExpressionValue(property.value, scope));
+ }
+ return { kind: 'record', properties };
+ }
+ if (unwrapped.type === 'ArrayExpression') {
+ const elements = unwrapped.elements
+ .filter((element) => element && element.type !== 'SpreadElement')
+ .map((element) => inferExpressionValue(element, scope));
+ return { kind: 'collection', element: mergeValueKinds(elements) };
+ }
+ if (unwrapped.type === 'CallExpression') {
+ return inferCallResult(unwrapped, scope);
+ }
+ return 'unknown';
+}
+
+function createScope(parent = null) {
+ return { parent, names: new Map() };
+}
+
+function defineBinding(scope, name, kind) {
+ scope.names.set(name, kind);
+}
+
+function resolveBinding(scope, name) {
+ for (let current = scope; current; current = current.parent) {
+ if (current.names.has(name)) return current.names.get(name);
+ }
+ return 'unbound';
+}
+
+function patternNames(pattern, names = []) {
+ if (!pattern) return names;
+ if (pattern.type === 'Identifier') {
+ names.push(pattern.name);
+ return names;
+ }
+ if (pattern.type === 'AssignmentPattern') return patternNames(pattern.left, names);
+ if (pattern.type === 'RestElement') return patternNames(pattern.argument, names);
+ if (pattern.type === 'TSParameterProperty') return patternNames(pattern.parameter, names);
+ if (pattern.type === 'ArrayPattern') {
+ for (const element of pattern.elements) patternNames(element, names);
+ return names;
+ }
+ if (pattern.type === 'ObjectPattern') {
+ for (const property of pattern.properties) {
+ if (property.type === 'RestElement') patternNames(property.argument, names);
+ else patternNames(property.value, names);
+ }
+ }
+ return names;
+}
+
+function bindPattern(scope, pattern, value = 'not-icon') {
+ if (!pattern) return;
+ if (pattern.type === 'Identifier') {
+ defineBinding(scope, pattern.name, value);
+ return;
+ }
+ if (pattern.type === 'AssignmentPattern') {
+ bindPattern(scope, pattern.left, value);
+ return;
+ }
+ if (pattern.type === 'RestElement') {
+ bindPattern(scope, pattern.argument, 'not-icon');
+ return;
+ }
+ if (pattern.type === 'TSParameterProperty') {
+ bindPattern(scope, pattern.parameter, value);
+ return;
+ }
+ if (pattern.type === 'ArrayPattern') {
+ pattern.elements.forEach((element, index) => {
+ const elementValue = value?.kind === 'tuple' ? value.elements[index] : 'not-icon';
+ bindPattern(scope, element, elementValue ?? 'not-icon');
+ });
+ return;
+ }
+ if (pattern.type === 'ObjectPattern') {
+ for (const property of pattern.properties) {
+ if (property.type === 'RestElement') {
+ bindPattern(scope, property.argument, 'not-icon');
+ continue;
+ }
+ const name = property.computed ? undefined : propertyName(property.key);
+ const propertyValue =
+ value?.kind === 'record' && name !== undefined
+ ? (value.properties.get(name) ?? 'unknown')
+ : 'not-icon';
+ bindPattern(scope, property.value, propertyValue);
+ }
+ }
+}
+
+function isFunctionNode(node) {
+ return (
+ node.type === 'FunctionDeclaration' ||
+ node.type === 'FunctionExpression' ||
+ node.type === 'ArrowFunctionExpression' ||
+ node.type === 'ClassMethod' ||
+ node.type === 'ClassPrivateMethod' ||
+ node.type === 'ObjectMethod'
+ );
+}
+
+function childEntries(node) {
+ const entries = [];
+ for (const [key, value] of Object.entries(node)) {
+ if (key === 'type' || key === 'loc' || key === 'start' || key === 'end' || key === 'range') {
+ continue;
+ }
+ if (Array.isArray(value)) {
+ for (const child of value) {
+ if (child && typeof child === 'object' && typeof child.type === 'string') {
+ entries.push(child);
+ }
+ }
+ } else if (value && typeof value === 'object' && typeof value.type === 'string') {
+ entries.push(value);
+ }
+ }
+ return entries;
+}
+
+function bindParams(scope, node, values = []) {
+ const params = node.params ?? [];
+ params.forEach((param, index) => bindPattern(scope, param, values[index] ?? 'not-icon'));
+ if (node.id?.type === 'Identifier') defineBinding(scope, node.id.name, 'not-icon');
+}
+
+function rawSizeExpressionForAttribute(attribute, scope) {
+ if (
+ attribute.type !== 'JSXAttribute' ||
+ attribute.name.type !== 'JSXIdentifier' ||
+ attribute.name.name !== 'size' ||
+ !attribute.value
+ ) {
+ return undefined;
+ }
+ if (attribute.value.type === 'StringLiteral') {
+ return NUMERIC_SIZE_STRING.test(attribute.value.value.trim()) ? attribute : undefined;
+ }
+ return attribute.value.type === 'JSXExpressionContainer' &&
+ attribute.value.expression.type !== 'JSXEmptyExpression' &&
+ isStaticRawSize(attribute.value.expression, scope)
+ ? attribute
+ : undefined;
+}
+
+function rawSizeExpressionForSpread(attribute, scope) {
+ if (attribute.type !== 'JSXSpreadAttribute') return undefined;
+ const value = inferExpressionValue(attribute.argument, scope);
+ return value?.kind === 'record' && value.properties.get('size') === 'raw-size'
+ ? attribute
+ : undefined;
+}
+
+function importedName(specifier) {
+ if (specifier.imported.type === 'Identifier') return specifier.imported.name;
+ return specifier.imported.value;
+}
+
+function bindIconImports(program, file, repoRoot, scope) {
+ for (const statement of program.body) {
+ if (
+ statement.type !== 'ImportDeclaration' ||
+ statement.importKind === 'type' ||
+ !isIconSeamSpecifier(statement.source.value, file, repoRoot)
+ ) {
+ continue;
+ }
+ for (const specifier of statement.specifiers) {
+ if (specifier.type === 'ImportNamespaceSpecifier') {
+ defineBinding(scope, specifier.local.name, 'namespace');
+ } else if (specifier.type === 'ImportSpecifier' && specifier.importKind !== 'type') {
+ const exportedName = importedName(specifier);
+ if (!ICON_METADATA_EXPORTS.has(exportedName)) {
+ defineBinding(scope, specifier.local.name, 'icon');
+ }
+ }
+ }
+ }
+}
+
+function functionReturnValue(node, parentScope, parameterValues) {
+ const scope = createScope(parentScope);
+ bindParams(scope, node, parameterValues);
+ if (node.body.type !== 'BlockStatement') return inferExpressionValue(node.body, scope);
+ const values = [];
+ const visit = (child) => {
+ if (!child || typeof child !== 'object' || typeof child.type !== 'string') return;
+ if (child !== node && isFunctionNode(child)) return;
+ if (child.type === 'ReturnStatement' && child.argument) {
+ values.push(inferExpressionValue(child.argument, scope));
+ return;
+ }
+ for (const descendant of childEntries(child)) visit(descendant);
+ };
+ visit(node.body);
+ return mergeValueKinds(values);
+}
+
+function collectionCallbackValues(call, scope) {
+ if (
+ call.callee.type !== 'MemberExpression' ||
+ call.callee.computed ||
+ call.callee.property.type !== 'Identifier' ||
+ !['map', 'flatMap', 'filter', 'sort'].includes(call.callee.property.name)
+ ) {
+ return undefined;
+ }
+ const collection = inferExpressionValue(call.callee.object, scope);
+ if (collection?.kind !== 'collection') return undefined;
+ return [collection.element, 'not-icon', 'not-icon'];
+}
+
+function inferCallResult(call, scope) {
+ if (
+ call.callee.type === 'MemberExpression' &&
+ !call.callee.computed &&
+ call.callee.object.type === 'Identifier' &&
+ call.callee.object.name === 'Object' &&
+ call.callee.property.type === 'Identifier' &&
+ call.callee.property.name === 'entries' &&
+ call.arguments.length > 0 &&
+ call.arguments[0].type !== 'SpreadElement' &&
+ inferExpressionValue(call.arguments[0], scope) === 'namespace'
+ ) {
+ return {
+ kind: 'collection',
+ element: { kind: 'tuple', elements: ['not-icon', 'icon'] },
+ };
+ }
+ if (
+ call.callee.type !== 'MemberExpression' ||
+ call.callee.computed ||
+ call.callee.property.type !== 'Identifier'
+ ) {
+ return 'unknown';
+ }
+ const receiver = inferExpressionValue(call.callee.object, scope);
+ if (receiver?.kind !== 'collection') return 'unknown';
+ const method = call.callee.property.name;
+ if (method === 'filter' || method === 'sort') return receiver;
+ if (method !== 'map' && method !== 'flatMap') return 'unknown';
+ const callback = call.arguments[0];
+ if (!callback || callback.type === 'SpreadElement' || !isFunctionNode(callback)) {
+ return 'unknown';
+ }
+ const returned = functionReturnValue(callback, scope, [receiver.element, 'not-icon', 'not-icon']);
+ if (method === 'flatMap' && returned?.kind === 'collection') return returned;
+ return { kind: 'collection', element: returned };
+}
+
+function isGovernedIconTag(tagName, scope) {
+ if (tagName.type === 'JSXIdentifier') {
+ return resolveBinding(scope, tagName.name) === 'icon';
+ }
+ return (
+ tagName.type === 'JSXMemberExpression' &&
+ tagName.object.type === 'JSXIdentifier' &&
+ tagName.property.type === 'JSXIdentifier' &&
+ resolveBinding(scope, tagName.object.name) === 'namespace' &&
+ !ICON_METADATA_EXPORTS.has(tagName.property.name)
+ );
+}
+
+function collectHits(node, scope, sourceText, file, hits, parameterValues) {
+ if (!node || typeof node !== 'object' || typeof node.type !== 'string') return;
+
+ if (isFunctionNode(node)) {
+ const inner = createScope(scope);
+ bindParams(inner, node, parameterValues);
+ for (const child of childEntries(node)) collectHits(child, inner, sourceText, file, hits);
+ return;
+ }
+
+ if (node.type === 'CallExpression') {
+ const callbackValues = collectionCallbackValues(node, scope);
+ for (const child of childEntries(node)) {
+ const isCallback = callbackValues && node.arguments.includes(child) && isFunctionNode(child);
+ collectHits(child, scope, sourceText, file, hits, isCallback ? callbackValues : undefined);
+ }
+ return;
+ }
+
+ if (node.type === 'CatchClause') {
+ const inner = createScope(scope);
+ for (const name of patternNames(node.param)) defineBinding(inner, name, 'not-icon');
+ collectHits(node.body, inner, sourceText, file, hits);
+ return;
+ }
+
+ if (node.type === 'BlockStatement') {
+ const inner = createScope(scope);
+ for (const child of node.body) collectHits(child, inner, sourceText, file, hits);
+ return;
+ }
+
+ if (
+ node.type === 'ForStatement' ||
+ node.type === 'ForInStatement' ||
+ node.type === 'ForOfStatement'
+ ) {
+ const inner = createScope(scope);
+ for (const child of childEntries(node)) collectHits(child, inner, sourceText, file, hits);
+ return;
+ }
+
+ if (node.type === 'VariableDeclarator') {
+ if (node.init) collectHits(node.init, scope, sourceText, file, hits);
+ if (node.id.type === 'Identifier') {
+ defineBinding(
+ scope,
+ node.id.name,
+ node.init ? inferExpressionValue(node.init, scope) : 'not-icon',
+ );
+ } else {
+ for (const name of patternNames(node.id)) defineBinding(scope, name, 'not-icon');
+ }
+ return;
+ }
+
+ if (node.type === 'JSXOpeningElement' && isGovernedIconTag(node.name, scope)) {
+ for (const attribute of node.attributes) {
+ const violation =
+ rawSizeExpressionForAttribute(attribute, scope) ??
+ rawSizeExpressionForSpread(attribute, scope);
+ if (!violation) continue;
+ hits.push({
+ file,
+ line: violation.loc.start.line,
+ column: violation.loc.start.column + 1,
+ expression: sourceText.slice(violation.start, violation.end),
+ });
+ }
+ }
+
+ for (const child of childEntries(node)) collectHits(child, scope, sourceText, file, hits);
+}
+
+export function findRawIconSizes(sourceText, file, options = {}) {
+ const repoRoot = options.repoRoot ?? root;
+ const program = parse(sourceText, {
+ sourceType: 'module',
+ sourceFilename: file,
+ plugins: ['typescript', 'jsx'],
+ }).program;
+ const moduleScope = createScope();
+ bindIconImports(program, file, repoRoot, moduleScope);
+ const hits = [];
+ collectHits(program, moduleScope, sourceText, normalizedRelativePath(repoRoot, file), hits);
+ return hits;
+}
+
+export function scanIconSizes(repoRoot = root) {
+ const hits = [];
+ for (const pattern of GLOBS) {
+ for (const relativeFile of globSync(pattern, { cwd: repoRoot })) {
+ const file = resolve(repoRoot, relativeFile);
+ hits.push(...findRawIconSizes(readFileSync(file, 'utf8'), file, { repoRoot }));
+ }
+ }
+ return hits;
+}
+
+function run() {
+ const hits = scanIconSizes();
+ if (hits.length > 0) {
+ console.error(
+ `icon sizes must use ICON_SIZE (meta/control/chrome/empty/plate):\n${hits
+ .map((hit) => ` ${hit.file}:${hit.line}:${hit.column} ${hit.expression}`)
+ .join('\n')}`,
+ );
+ process.exitCode = 1;
+ return;
+ }
+ console.log('icon size scale: ok');
+}
+
+if (process.argv[1] && pathToFileURL(resolve(process.argv[1])).href === import.meta.url) run();
diff --git a/scripts/check-icon-size.test.mjs b/scripts/check-icon-size.test.mjs
new file mode 100644
index 0000000000..e805dc81e8
--- /dev/null
+++ b/scripts/check-icon-size.test.mjs
@@ -0,0 +1,114 @@
+import assert from 'node:assert/strict';
+import { test } from 'node:test';
+import { findRawIconSizes } from './check-icon-size.mjs';
+
+const repoRoot = '/repo';
+
+function expressions(source, file = `${repoRoot}/apps/desktop/src/example.tsx`) {
+ return findRawIconSizes(source, file, { repoRoot }).map((hit) => hit.expression);
+}
+
+test('rejects numeric sizes on named, aliased, derived, and namespace icon imports', () => {
+ assert.deepEqual(
+ expressions(`
+ import { ICON_SIZE, Search, Trash2 as DeleteIcon } from '@maka/ui/icons';
+ import * as Icons from '@maka/ui/icons';
+ const SelectedIcon = true ? Search : DeleteIcon;
+ export const Example = () => <>
+
+
+
+
+ >;
+ `),
+ ['size={16}', 'size={15.5}', 'size={14 as const}', 'size={+28}'],
+ );
+});
+
+test('accepts ICON_SIZE and ignores unrelated size props, comments, and non-numeric strings', () => {
+ assert.deepEqual(
+ expressions(`
+ import { ICON_SIZE, Search } from '@maka/ui/icons';
+ /* */
+ const sample = '';
+ export const Example = () => <>
+
+
+
+ >;
+ `),
+ [],
+ );
+});
+
+test('rejects numeric string sizes on icon tags', () => {
+ assert.deepEqual(
+ expressions(`
+ import { Search } from '@maka/ui/icons';
+ export const Example = () => ;
+ `),
+ ['size="16"'],
+ );
+});
+
+test('rejects statically provable expressions, passthroughs, and props spreads', () => {
+ assert.deepEqual(
+ expressions(`
+ import { Search } from '@maka/ui/icons';
+ const raw = 8 + 8;
+ const props = { size: '16' };
+ export const Example = () => <>
+
+
+
+
+
+
+ >;
+ `),
+ ["size={'16'}", 'size={`16`}', 'size={8 + 8}', 'size={raw}', 'size={props.size}', '{...props}'],
+ );
+});
+
+test('tracks icon provenance through Object.entries, flatMap, and destructured map callbacks', () => {
+ assert.deepEqual(
+ expressions(`
+ import * as Icons from '@maka/ui/icons';
+ const ICONS = Object.entries(Icons)
+ .flatMap(([name, value]) => value ? [{ name, Comp: value }] : [])
+ .sort((a, b) => String(a.name).localeCompare(String(b.name)));
+ export const Example = () => ICONS.map(({ Comp }) => );
+ `),
+ ['size={20}'],
+ );
+});
+
+test('does not treat shadowed or unrelated local tags as icons', () => {
+ assert.deepEqual(
+ expressions(`
+ import { Search } from '@maka/ui/icons';
+ function Shadow(Search) {
+ return ;
+ }
+ export const Example = () => {
+ const Comp = Avatar;
+ return ;
+ };
+ `),
+ [],
+ );
+});
+
+test('resolves the relative icon seam', () => {
+ assert.deepEqual(
+ expressions(
+ `
+ import { Search } from './icons.js';
+ import * as Icons from './icons.js';
+ export const Example = () => <>>;
+ `,
+ `${repoRoot}/packages/ui/src/example.tsx`,
+ ),
+ ['size={13}', 'size={20}'],
+ );
+});