Files
fluxer/fluxer_app/scripts/GenerateThemeVariables.ts

837 lines
30 KiB
TypeScript

// SPDX-License-Identifier: AGPL-3.0-or-later
import {existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync} from 'node:fs';
import {dirname, join, relative, resolve} from 'node:path';
import {SKELETON_SURFACE_TOKENS} from '@app/features/app/components/skeleton/SkeletonSurfaceContract';
type ThemeVariableKind = 'color' | 'font' | 'dimension' | 'number' | 'shadow' | 'transition' | 'other';
interface CssSource {
file: string;
label: string;
generatedBy?: string;
}
interface VariableDefinition {
name: string;
kind: ThemeVariableKind;
groupId: string;
groupLabel: string;
source: string;
}
const PRIORITY_CSS_SOURCES: ReadonlyArray<CssSource> = [
{file: 'src/app/globals.css', label: 'globals'},
{
file: 'src/features/theme/styles/generated/color-system.css',
label: 'color-system',
generatedBy: 'pnpm generate:colors',
},
{
file: 'src/features/theme/styles/generated/message-layout.css',
label: 'message-layout',
generatedBy: 'pnpm generate:message-layout',
},
];
const PRIORITY_SOURCE_INDEX = new Map(PRIORITY_CSS_SOURCES.map((source, index) => [source.file, index]));
const IGNORED_SOURCE_PREFIXES = ['src/features/theme_studio/', 'src/theme/'];
const BUNDLED_FONT_VARIABLES_CSS = resolve(
import.meta.dirname,
'..',
'..',
'packages',
'fonts',
'css',
'variables.css',
);
function bundledFontStack(name: string): string {
const css = readFileSync(BUNDLED_FONT_VARIABLES_CSS, 'utf8');
const declaration = new RegExp(`${name}:([^;]*);`).exec(css);
if (!declaration) {
throw new Error(`${name} is not declared in ${BUNDLED_FONT_VARIABLES_CSS}`);
}
return declaration[1].replace(/\s+/g, ' ').trim();
}
const EXTRA_GLOBAL_DEFAULTS: ReadonlyArray<{name: string; value: string; source: string}> = [
{
name: '--font-sans',
value: bundledFontStack('--font-sans'),
source: 'runtime-fonts',
},
{
name: '--font-mono',
value: bundledFontStack('--font-mono'),
source: 'runtime-fonts',
},
{name: '--font-size', value: '1rem', source: 'runtime-accessibility'},
{name: '--chat-horizontal-padding', value: '1rem', source: 'runtime-accessibility'},
{name: '--message-group-spacing', value: '1rem', source: 'runtime-accessibility'},
{name: '--link-decoration', value: 'none', source: 'runtime-accessibility'},
{name: '--markup-strikethrough-color', value: 'currentColor', source: 'runtime-accessibility'},
];
const GROUP_LABELS: Record<string, string> = {
typography: 'Typography',
surfaces: 'Surfaces',
headers: 'Headers',
text: 'Text',
brand: 'Brand & accents',
status: 'Status indicators',
borders: 'Borders & focus',
alerts: 'Alerts & callouts',
markup: 'Markup & mentions',
buttons: 'Buttons',
code: 'Code & terminal',
tables: 'Tables',
scrolling: 'Scrolling',
layout: 'Layout',
messages: 'Messages',
emoji: 'Emoji',
motion: 'Motion',
layering: 'Layering',
media: 'Media',
forms: 'Forms',
other: 'Other',
};
function stripAtRuleBlocks(css: string): string {
let output = '';
let index = 0;
while (index < css.length) {
if (css[index] !== '@') {
output += css[index];
index += 1;
continue;
}
const nextSemicolon = css.indexOf(';', index);
const nextBrace = css.indexOf('{', index);
if (nextBrace === -1 || (nextSemicolon !== -1 && nextSemicolon < nextBrace)) {
index = nextSemicolon === -1 ? css.length : nextSemicolon + 1;
continue;
}
let depth = 0;
let cursor = nextBrace;
for (; cursor < css.length; cursor += 1) {
if (css[cursor] === '{') depth += 1;
if (css[cursor] === '}') {
depth -= 1;
if (depth === 0) {
cursor += 1;
break;
}
}
}
index = cursor;
}
return output;
}
function toPosixPath(path: string): string {
return path.replaceAll('\\', '/');
}
function discoverCssSources(appDir: string): ReadonlyArray<CssSource> {
const srcDir = join(appDir, 'src');
const files: Array<string> = [];
const visit = (directory: string) => {
for (const entry of readdirSync(directory, {withFileTypes: true})) {
const absolutePath = join(directory, entry.name);
if (entry.isDirectory()) {
visit(absolutePath);
continue;
}
if (!entry.isFile() || !entry.name.endsWith('.css')) continue;
const sourceFile = toPosixPath(relative(appDir, absolutePath));
if (IGNORED_SOURCE_PREFIXES.some((prefix) => sourceFile.startsWith(prefix))) continue;
files.push(sourceFile);
}
};
visit(srcDir);
const discovered = new Set(files);
const missing = PRIORITY_CSS_SOURCES.filter((source) => !discovered.has(source.file));
if (missing.length > 0) {
const remedies = missing.map(
(source) => ` ${source.file} is missing. Run \`${source.generatedBy ?? 'pnpm build'}\` first.`,
);
throw new Error(
`Cannot generate the theme variable manifest from a partial input.\n${remedies.join('\n')}\n` +
'Writing anyway would silently delete every variable those sources declare from a tracked file.',
);
}
return files
.sort((left, right) => {
const leftPriority = PRIORITY_SOURCE_INDEX.get(left);
const rightPriority = PRIORITY_SOURCE_INDEX.get(right);
if (leftPriority !== undefined || rightPriority !== undefined) {
return (leftPriority ?? Number.MAX_SAFE_INTEGER) - (rightPriority ?? Number.MAX_SAFE_INTEGER);
}
return left.localeCompare(right);
})
.map((file) => ({
file,
label: PRIORITY_CSS_SOURCES.find((source) => source.file === file)?.label ?? file.replace(/^src\//, ''),
}));
}
function selectorHas(selector: string, target: string): boolean {
return selector
.split(',')
.map((part) => part.trim())
.includes(target);
}
function extractDeclarations(block: string): Array<[string, string]> {
const declarations: Array<[string, string]> = [];
const pattern = /(--[a-zA-Z0-9_-]+)\s*:\s*([^;]+);/g;
let match: RegExpExecArray | null;
while ((match = pattern.exec(block)) !== null) {
const name = match[1] as string;
const value = (match[2] as string).replace(/\s+/g, ' ').trim();
declarations.push([name, value]);
}
return declarations;
}
function readSourceVariables(appDir: string): {
darkDefaults: Map<string, string>;
lightDefaults: Map<string, string>;
sources: Map<string, string>;
} {
const darkDefaults = new Map<string, string>();
const lightOverrides = new Map<string, string>();
const sources = new Map<string, string>();
for (const source of discoverCssSources(appDir)) {
const absolutePath = join(appDir, source.file);
const css = stripAtRuleBlocks(readFileSync(absolutePath, 'utf8').replace(/\/\*[\s\S]*?\*\//g, ''));
const blockPattern = /([^{}]+)\{([^{}]*)\}/g;
let match: RegExpExecArray | null;
while ((match = blockPattern.exec(css)) !== null) {
const selector = (match[1] as string).trim();
const block = match[2] as string;
const isRoot = selectorHas(selector, ':root');
const isLight = selectorHas(selector, '.theme-light');
if (!isRoot && !isLight) continue;
for (const [name, value] of extractDeclarations(block)) {
if (isRoot) {
darkDefaults.set(name, value);
sources.set(name, source.label);
}
if (isLight) {
lightOverrides.set(name, value);
sources.set(name, source.label);
}
}
}
}
for (const extra of EXTRA_GLOBAL_DEFAULTS) {
if (!darkDefaults.has(extra.name)) {
darkDefaults.set(extra.name, extra.value);
sources.set(extra.name, extra.source);
}
}
const lightDefaults = new Map(darkDefaults);
for (const [name, value] of lightOverrides) {
lightDefaults.set(name, value);
}
return {darkDefaults, lightDefaults, sources};
}
function findMatchingParen(text: string, openIndex: number): number {
let depth = 0;
for (let index = openIndex; index < text.length; index += 1) {
const character = text[index];
if (character === '(') depth += 1;
if (character === ')') {
depth -= 1;
if (depth === 0) return index;
}
}
return -1;
}
function splitVarArguments(inner: string): {dependency: string; fallback: string | null} {
let depth = 0;
for (let index = 0; index < inner.length; index += 1) {
const character = inner[index];
if (character === '(') depth += 1;
if (character === ')') depth -= 1;
if (character === ',' && depth === 0) {
return {dependency: inner.slice(0, index).trim(), fallback: inner.slice(index + 1).trim()};
}
}
return {dependency: inner.trim(), fallback: null};
}
function resolveVariableValue(name: string, values: ReadonlyMap<string, string>, stack = new Set<string>()): string {
const value = values.get(name);
if (!value) return '';
let result = '';
let cursor = 0;
while (cursor < value.length) {
const start = value.indexOf('var(', cursor);
if (start === -1) {
result += value.slice(cursor);
return result;
}
const close = findMatchingParen(value, start + 3);
if (close === -1) {
result += value.slice(cursor);
return result;
}
result += value.slice(cursor, start);
const full = value.slice(start, close + 1);
const {dependency, fallback} = splitVarArguments(value.slice(start + 4, close));
if (dependency === '--saturation-factor') {
result += full;
} else if (stack.has(dependency) || !values.get(dependency)) {
result += fallback ?? full;
} else {
const nextStack = new Set(stack);
nextStack.add(name);
result += resolveVariableValue(dependency, values, nextStack);
}
cursor = close + 1;
}
return result;
}
function getGroupId(name: string): string {
if (name.startsWith('--font')) return 'typography';
if (name.startsWith('--z-index')) return 'layering';
if (name.startsWith('--transition')) return 'motion';
if (name.startsWith('--shadow')) return 'borders';
if (name.includes('scrollbar')) return 'scrolling';
if (name.startsWith('--message')) return 'messages';
if (name.includes('typing')) return 'messages';
if (name.includes('emoji')) return 'emoji';
if (name.includes('textarea') || name.includes('input') || name.includes('form')) return 'forms';
if (name.includes('button') || name.includes('control-button')) return 'buttons';
if (name.startsWith('--code') || name.startsWith('--ansi') || name === '--text-code') return 'code';
if (name.includes('table')) return 'tables';
if (name.startsWith('--markup') || name.includes('spoiler')) return 'markup';
if (name.startsWith('--alert')) return 'alerts';
if (name.startsWith('--status')) return 'status';
if (name.startsWith('--brand') || name.startsWith('--accent') || name.startsWith('--plutonium')) return 'brand';
if (name.startsWith('--text')) return 'text';
if (name.includes('border') || name.includes('focus') || name.includes('radius')) return 'borders';
if (name.includes('layout') || name.includes('spacing') || name.includes('padding') || name.includes('gap'))
return 'layout';
if (name.includes('width') || name.includes('height') || name.includes('size') || name.includes('gutter'))
return 'layout';
if (name.includes('media') || name.includes('avatar') || name.includes('guild-icon')) return 'media';
if (name.includes('bg') || name.includes('background') || name.includes('surface') || name.includes('guild-list')) {
return 'surfaces';
}
return 'other';
}
function getKind(name: string, value: string): ThemeVariableKind {
const lowerName = name.toLowerCase();
const lowerValue = value.toLowerCase();
if (name === '--font-sans' || name === '--font-mono') return 'font';
if (name.startsWith('--shadow')) return 'shadow';
if (name.startsWith('--transition') || /\b\d+(?:\.\d+)?m?s\b/.test(lowerValue)) return 'transition';
if (/^-?\d+(?:\.\d+)?$/.test(value)) return 'number';
if (lowerName.includes('opacity')) return 'number';
if (lowerValue.includes(' solid ')) return 'other';
if (
lowerValue === 'transparent' ||
lowerValue === 'currentcolor' ||
lowerValue.startsWith('#') ||
lowerValue.startsWith('hsl') ||
lowerValue.startsWith('rgb') ||
lowerValue.startsWith('color-mix')
) {
return 'color';
}
if (
/(?:^|\s)-?\d*\.?\d+(?:px|rem|em|%|vh|vw|dvh|svh|cqi)\b/.test(lowerValue) ||
lowerValue.includes('calc(') ||
lowerValue.includes('clamp(') ||
lowerValue.includes('min(') ||
lowerValue.includes('max(')
) {
return 'dimension';
}
if (
lowerName.startsWith('--ansi') ||
lowerName.includes('color') ||
lowerName.startsWith('--text-') ||
lowerName.endsWith('-text') ||
lowerName.includes('-text-') ||
lowerName.includes('bg') ||
lowerName.includes('background') ||
lowerName.includes('fill') ||
lowerName.includes('accent') ||
lowerName.includes('brand') ||
lowerName.includes('status') ||
lowerName.includes('alert') ||
lowerName.includes('selection')
) {
return 'color';
}
return 'other';
}
function buildDefinitions(
darkDefaults: ReadonlyMap<string, string>,
sources: ReadonlyMap<string, string>,
): Array<VariableDefinition> {
return [...darkDefaults.keys()]
.sort((left, right) => left.localeCompare(right))
.map((name) => {
const value = resolveVariableValue(name, darkDefaults);
const groupId = getGroupId(name);
return {
name,
kind: getKind(name, value),
groupId,
groupLabel: GROUP_LABELS[groupId] ?? GROUP_LABELS.other,
source: sources.get(name) ?? 'unknown',
};
});
}
function renderStringArray(name: string, values: ReadonlyArray<string>): string {
const body = values.map((value) => `\t${JSON.stringify(value)},`).join('\n');
return `export const ${name}: ReadonlyArray<string> = [\n${body}\n];`;
}
function renderValueMap(name: string, values: ReadonlyMap<string, string>): string {
const body = [...values.keys()]
.sort((left, right) => left.localeCompare(right))
.map((key) => `\t${JSON.stringify(key)}: ${JSON.stringify(resolveVariableValue(key, values))},`)
.join('\n');
return `export const ${name}: Readonly<Record<string, string>> = {\n${body}\n};`;
}
function renderNameUnion(definitions: ReadonlyArray<VariableDefinition>): string {
const body = definitions.map((definition) => `\t| ${JSON.stringify(definition.name)}`).join('\n');
return `export type ThemeVariableName =\n${body};`;
}
function renderDefinitions(definitions: ReadonlyArray<VariableDefinition>): string {
const body = definitions
.map(
(definition) =>
`\t{name: ${JSON.stringify(definition.name)}, kind: ${JSON.stringify(definition.kind)}, groupId: ${JSON.stringify(definition.groupId)}, groupLabel: ${JSON.stringify(definition.groupLabel)}, source: ${JSON.stringify(definition.source)}},`,
)
.join('\n');
return `export const THEME_VARIABLES: ReadonlyArray<ThemeVariableDefinition> = [\n${body}\n];`;
}
function render(appDir: string): string {
const {darkDefaults, lightDefaults, sources} = readSourceVariables(appDir);
const definitions = buildDefinitions(darkDefaults, sources);
const colorVariables = definitions
.filter((definition) => definition.kind === 'color')
.map((definition) => definition.name);
const fontVariables = definitions
.filter((definition) => definition.kind === 'font')
.map((definition) => definition.name);
return `// SPDX-License-Identifier: AGPL-3.0-or-later
// Generated by scripts/GenerateThemeVariables.ts. Do not edit by hand.
export type ThemeVariableKind = 'color' | 'font' | 'dimension' | 'number' | 'shadow' | 'transition' | 'other';
export interface ThemeVariableDefinition {
\tname: string;
\tkind: ThemeVariableKind;
\tgroupId: string;
\tgroupLabel: string;
\tsource: string;
}
${renderNameUnion(definitions)}
${renderDefinitions(definitions)}
${renderStringArray(
'THEME_VARIABLE_NAMES',
definitions.map((definition) => definition.name),
)}
${renderStringArray('THEME_COLOR_VARIABLES', colorVariables)}
${renderStringArray('THEME_FONT_VARIABLES', fontVariables)}
${renderValueMap('THEME_STUDIO_DARK_DEFAULT_VARIABLE_VALUES', darkDefaults)}
${renderValueMap('THEME_STUDIO_LIGHT_DEFAULT_VARIABLE_VALUES', lightDefaults)}
`;
}
interface SkeletonSurfaceInvariant {
file: string;
requires?: ReadonlyArray<string>;
requiresPattern?: ReadonlyArray<string>;
forbids?: ReadonlyArray<string>;
forbidsPattern?: ReadonlyArray<string>;
counts?: Readonly<Record<string, number>>;
minimums?: Readonly<Record<string, number>>;
}
const SKELETON_CHROME_BORDER_TOKEN = 'var(--skeleton-chrome-border)';
const SKELETON_SURFACE_INVARIANTS: ReadonlyArray<SkeletonSurfaceInvariant> = [
{
file: 'src/app/globals.css',
counts: {
'--skeleton-chrome-border-color': 2,
'--skeleton-chrome-border-color: color-mix(in srgb, var(--background-modifier-accent) 25%, transparent);': 1,
'--skeleton-chrome-border: 0.0625rem solid var(--skeleton-chrome-border-color);': 1,
'--chat-horizontal-padding-default: 1rem;': 1,
'--guild-members-columns-selectable: var(--guild-members-select-column-width) var(--guild-members-columns);': 1,
'--guilds-layout-item-bg: color-mix(in srgb, var(--guild-list-foreground) 72%, var(--background-primary) 28%);': 1,
},
},
{
file: 'src/features/app/components/skeleton/ChatSkeleton.module.css',
minimums: {[SKELETON_CHROME_BORDER_TOKEN]: 1},
forbidsPattern: ['border[a-z-]*:\\s*[^;]*var\\(--background-modifier-accent\\)'],
forbids: ['scopeBadge', 'scope-badge'],
},
{
file: 'src/features/app/components/skeleton/ChatSkeleton.tsx',
requires: [
"import composerWrapperStyles from '@app/features/channel/components/textarea/InputWrapper.module.css';",
'composerWrapperStyles.composerRoot',
"'--chat-horizontal-padding': remFromPx(messagePresentation.messageGutterPx),",
"'--font-size': remFromPx(messagePresentation.fontSizePx),",
"'--message-group-spacing': remFromPx(messagePresentation.groupSpacingPx),",
"'--message-compact-timestamp-width': remFromPx(messagePresentation.compactTimestampWidthPx),",
],
forbids: ['scopeBadge', 'scope-badge', '@tanstack/react-virtual'],
},
{
file: 'src/features/app/components/skeleton/DiscoverySkeleton.module.css',
minimums: {[SKELETON_CHROME_BORDER_TOKEN]: 2},
forbidsPattern: ['border[a-z-]*:\\s*[^;]*var\\(--background-modifier-accent\\)'],
},
{
file: 'src/features/app/components/skeleton/FriendsSkeleton.module.css',
minimums: {[SKELETON_CHROME_BORDER_TOKEN]: 4},
forbidsPattern: ['border[a-z-]*:\\s*[^;]*var\\(--background-modifier-accent\\)'],
forbids: ['.activeNowPreview'],
},
{
file: 'src/features/app/components/skeleton/FriendsSkeleton.tsx',
forbids: ['LIVE_BADGE', 'CONTEXT_CHEVRON', 'ACTIVE_NOW_CONTEXT_ICON_SIZE'],
requires: ['function resolveFriendsListSections('],
},
{
file: 'src/features/app/components/skeleton/GuildRailSkeleton.module.css',
requires: [
'.outageSlot {\n\tmin-height: var(--guild-list-item-box-size);\n}',
'.sectionTrailingGap {\n\tpadding-bottom: var(--guild-list-item-gap);\n}',
'.itemsTrailingGapCancel {\n\tmargin-bottom: calc(-1 * var(--guild-list-item-gap));\n}',
'background-color: var(--guilds-layout-item-bg);',
".item[data-selected='true'] .fluxerIcon",
'clip-path: inset(0);',
'\ttransform: translateY(0rem);',
],
forbids: ['--guild-list-indicator-', '::before'],
counts: {'z-index: 0;': 3, 'z-index: 1;': 3},
},
{
file: 'src/features/app/components/skeleton/GuildRailSkeleton.tsx',
requires: [
"import guildStyles from '@app/features/app/components/layout/GuildsLayout.module.css';",
'guildStyles.guildIndicator',
'guildStyles.guildIndicatorBar',
'resolveGuildListIndicatorBarTarget(',
'const InlineDMPlaceholder = ',
],
requiresPattern: ['<ChatCircleIcon[^>]*weight="fill"[^>]*className=\\{styles\\.fluxerIconGlyph\\}'],
forbids: ['data-indicator', 'GUILD_RAIL_INDICATOR_METRICS', 'Math.round'],
counts: {'styles.sectionTrailingGap': 2, 'styles.itemsTrailingGapCancel': 1},
},
{
file: 'src/features/ui/components/Scroller.module.css',
counts: {'clip-path: inset(0);': 1},
},
{
file: 'src/features/channel/components/MemberListSkeleton.tsx',
counts: {'style={MEMBER_LIST_METRICS_STYLE}': 2},
},
{
file: 'src/features/channel/components/textarea/InputWrapper.module.css',
requires: ['.composerRoot:has(.statusTypingSlot)::before'],
},
{
file: 'src/features/app/components/layout/GuildsLayout.module.css',
requires: [
'.guildListScrollContainer.guildListScrollContainer {\n\toverflow-anchor: auto;\n}',
'--layout-user-area-overlay-height',
'\tpadding-bottom: calc(\n\t\tvar(--layout-user-area-overlay-height, var(--layout-user-area-reserved-height, 0px)) +\n\t\tvar(--spacing-2)\n\t);',
'.messageBubbleIcon {\n\theight: 1.75rem;\n\twidth: 1.75rem;',
'.guildIndicator {',
'.guildIndicatorBar {',
],
forbids: ['.fluxerSymbolIcon'],
},
{
file: 'src/features/app/components/layout/GuildsLayout.tsx',
forbids: ['useListScrollAnchor', 'usePersistentScrollAnchor', '@tanstack/react-virtual'],
counts: {'scrollNode.scrollTop = ': 2},
},
{
file: 'src/features/discovery/discovery/DiscoveryPage.tsx',
counts: {"import {useVirtualizer} from '@tanstack/react-virtual';": 1},
},
{
file: 'src/features/app/components/LongPressable.ts',
forbids: ['useImperativeHandle'],
requires: ['useMergeRefs'],
},
{
file: 'src/features/app/components/layout/sidebar_nav/GuildListIndicator.ts',
requires: ['height: remFromPx(resolveGuildListIndicatorHeight(request))'],
forbids: ['transform', 'scale', 'getAppZoomFactor', 'getAppRemScale'],
counts: {
'function resolveGuildListIndicatorHeight': 1,
'export function resolveGuildListIndicatorBarTarget': 1,
'export ': 3,
},
},
{
file: 'src/features/app/components/layout/sidebar_nav/FluxerButton.tsx',
requires: [
'const shouldShowHoverState = isHovering || contextMenuOpen;',
'ChatCircleIcon',
'styles.messageBubbleIcon',
'styles.contextMenuHover',
'resolveGuildListIndicatorBarTarget(',
],
forbids: ['scale', 'getAppZoomFactor'],
},
{
file: 'src/features/app/components/layout/sidebar_nav/FavoritesButton.tsx',
requires: ['resolveGuildListIndicatorBarTarget('],
forbids: ['scale', 'getAppZoomFactor'],
},
{
file: 'src/features/app/components/layout/sidebar_nav/GuildListDMItem.tsx',
requires: [
'const shouldShowHoverState = isHovering || contextMenuOpen;',
'guildStyles.contextMenuHover',
'resolveGuildListIndicatorBarTarget(',
],
forbids: ['@tanstack/react-virtual', 'scale', 'getAppZoomFactor'],
},
{
file: 'src/features/app/components/layout/sidebar_nav/GuildFolderItem.tsx',
requires: [
'const shouldShowHoverState = isHovering || contextMenuOpen;',
'const FOLDER_BACKGROUND_FADE_TARGET = Object.freeze({opacity: 0});',
"const FOLDER_GUILDS_COLLAPSE_TARGET = Object.freeze({opacity: 0, translateY: '-0.5rem'});",
"to={{opacity: 1, translateY: '0rem'}}",
'resolveGuildListIndicatorBarTarget(',
'guildStyles.guildIndicatorBar',
],
forbids: ['@tanstack/react-virtual', 'scale', 'getAppZoomFactor'],
},
{
file: 'src/features/app/components/layout/sidebar_nav/GuildFolderItem.module.css',
forbids: ['.folderIndicator'],
},
{
file: 'src/features/app/components/layout/sidebar_nav/GuildListItemContent.tsx',
requires: [
'const selectedFromThisRow = peekDirectSelection(DirectSelectionSurface.GUILD_RAIL);',
'if (isInitialMount || selectedFromThisRow || !props.isSelected) return;',
],
forbids: ['preserveInitialScrollPosition', '@tanstack/react-virtual', 'scale', 'getAppZoomFactor'],
},
{
file: 'src/features/app/components/layout/sidebar_nav/GuildListItemPresentation.tsx',
forbids: ['@tanstack/react-virtual', 'scale', 'getAppZoomFactor'],
},
{
file: 'src/features/channel/components/ChannelMembers.tsx',
forbids: ['@tanstack/react-virtual'],
requires: ['styles.virtualRow'],
},
];
const SKELETON_STYLESHEET_DIRECTORIES: ReadonlyArray<string> = [
'src/features/app/components/skeleton',
'src/features/channel/components',
'src/features/user/components/profile',
];
const SKELETON_STYLESHEET_SUFFIX = 'Skeleton.module.css';
const BORDER_DECLARATION_PATTERN =
/(?<![\w-])border(?:-(?:top|right|bottom|left|block|inline|block-start|block-end|inline-start|inline-end))?\s*:\s*([^;}]+)/g;
const RAW_COLOR_PATTERN = /#[0-9a-fA-F]{3,8}\b|\b(?:rgba?|hsla?|hwb|lab|lch|oklab|oklch|color-mix)\(/;
function collectSkeletonStylesheets(appDir: string): ReadonlyArray<string> {
const files: Array<string> = [];
const visit = (directory: string) => {
for (const entry of readdirSync(directory, {withFileTypes: true})) {
const absolutePath = join(directory, entry.name);
if (entry.isDirectory()) {
visit(absolutePath);
continue;
}
if (!entry.isFile() || !entry.name.endsWith(SKELETON_STYLESHEET_SUFFIX)) continue;
files.push(toPosixPath(relative(appDir, absolutePath)));
}
};
for (const directory of SKELETON_STYLESHEET_DIRECTORIES) {
visit(join(appDir, directory));
}
return files.sort();
}
interface SkeletonSharedRuleSource {
readonly file: string;
readonly selectors: ReadonlyArray<string>;
}
const SKELETON_SHARED_RULE_SOURCES: ReadonlyArray<SkeletonSharedRuleSource> = [
{
file: 'src/features/app/components/layout/GuildsLayout.module.css',
selectors: ['.guildIndicator', '.guildIndicatorBar'],
},
];
const CUSTOM_PROPERTY_REFERENCE_PATTERN = /var\(\s*(--[a-zA-Z0-9-]+)/g;
function assertSkeletonSharedRuleTokens(appDir: string): void {
const violations: Array<string> = [];
const declaredTokens = new Set<string>(SKELETON_SURFACE_TOKENS);
for (const source of SKELETON_SHARED_RULE_SOURCES) {
const contents = readFileSync(join(appDir, source.file), 'utf8');
for (const selector of source.selectors) {
const rule = new RegExp(`^${selector.replace('.', '\\.')}\\s*\\{([^}]*)\\}`, 'mu').exec(contents);
if (rule == null) {
violations.push(`${source.file} must declare ${selector}; skeleton markup renders through that rule.`);
continue;
}
for (const match of rule[1].matchAll(CUSTOM_PROPERTY_REFERENCE_PATTERN)) {
const token = match[1];
if (declaredTokens.has(token)) continue;
violations.push(
`${source.file} ${selector} reads ${token}, which skeleton markup also renders through; ` +
'list it in SKELETON_SURFACE_TOKENS.',
);
}
}
}
if (violations.length > 0) {
throw new Error(`Skeleton surface invariants violated:\n${violations.map((line) => ` - ${line}`).join('\n')}`);
}
}
const SKELETON_CHROME_BORDER_COLOR_DECLARATION = /--skeleton-chrome-border-color\s*:\s*([^;}]+)/g;
function assertSkeletonChromeBorderColourIsSoftened(appDir: string, violations: Array<string>): void {
const file = 'src/app/globals.css';
const contents = readFileSync(join(appDir, file), 'utf8');
for (const match of contents.matchAll(SKELETON_CHROME_BORDER_COLOR_DECLARATION)) {
const value = match[1].trim();
if (value.startsWith('var(--background-modifier-accent')) {
violations.push(
`${file} declares --skeleton-chrome-border-color as ${JSON.stringify(value)}; ` +
'skeleton chrome must use the softened colour, never the raw accent.',
);
}
}
}
function assertSkeletonBorderTokens(appDir: string): void {
const violations: Array<string> = [];
assertSkeletonChromeBorderColourIsSoftened(appDir, violations);
for (const file of collectSkeletonStylesheets(appDir)) {
const contents = readFileSync(join(appDir, file), 'utf8');
if (contents.includes('--skeleton-chrome-border-color')) {
violations.push(
`${file} names --skeleton-chrome-border-color directly; skeleton chrome must use ${SKELETON_CHROME_BORDER_TOKEN}.`,
);
}
for (const match of contents.matchAll(BORDER_DECLARATION_PATTERN)) {
const value = match[1].trim();
if (RAW_COLOR_PATTERN.test(value) || value.includes('--background-modifier-accent')) {
violations.push(
`${file} writes a literal border colour in ${JSON.stringify(`border: ${value}`)}; ` +
'skeleton borders must reference a shared token.',
);
}
}
}
if (violations.length > 0) {
throw new Error(`Skeleton surface invariants violated:\n${violations.map((line) => ` - ${line}`).join('\n')}`);
}
}
function assertSkeletonSurfaceInvariants(appDir: string): void {
const violations: Array<string> = [];
for (const invariant of SKELETON_SURFACE_INVARIANTS) {
const path = join(appDir, invariant.file);
if (!existsSync(path)) {
violations.push(`${invariant.file} is missing; a skeleton surface invariant depends on it.`);
continue;
}
const contents = readFileSync(path, 'utf8');
for (const required of invariant.requires ?? []) {
if (!contents.includes(required)) {
violations.push(`${invariant.file} must contain ${JSON.stringify(required)}.`);
}
}
for (const forbidden of invariant.forbids ?? []) {
if (contents.includes(forbidden)) {
violations.push(`${invariant.file} must not contain ${JSON.stringify(forbidden)}.`);
}
}
for (const source of invariant.requiresPattern ?? []) {
if (!new RegExp(source, 'u').test(contents)) {
violations.push(`${invariant.file} must match /${source}/.`);
}
}
for (const source of invariant.forbidsPattern ?? []) {
if (new RegExp(source, 'u').test(contents)) {
violations.push(`${invariant.file} must not match /${source}/.`);
}
}
for (const [needle, expected] of Object.entries(invariant.counts ?? {})) {
const actual = contents.split(needle).length - 1;
if (actual !== expected) {
violations.push(`${invariant.file} must contain ${needle} exactly ${expected} time(s); found ${actual}.`);
}
}
for (const [needle, minimum] of Object.entries(invariant.minimums ?? {})) {
const actual = contents.split(needle).length - 1;
if (actual < minimum) {
violations.push(`${invariant.file} must contain ${needle} at least ${minimum} time(s); found ${actual}.`);
}
}
}
if (violations.length > 0) {
throw new Error(`Skeleton surface invariants violated:\n${violations.map((line) => ` - ${line}`).join('\n')}`);
}
}
function main(): void {
const scriptDir = import.meta.dirname;
const appDir = resolve(scriptDir, '..');
assertSkeletonSurfaceInvariants(appDir);
assertSkeletonSharedRuleTokens(appDir);
assertSkeletonBorderTokens(appDir);
const outputPath = join(appDir, 'src', 'features', 'theme', 'variables', 'ThemeVariableManifest.ts');
const contents = render(appDir);
if (process.argv.includes('--check')) {
if (!existsSync(outputPath) || readFileSync(outputPath, 'utf8') !== contents) {
throw new Error(`${relative(appDir, outputPath)} is stale. Run pnpm generate:theme-variables.`);
}
console.log(`Checked ${relative(appDir, outputPath)}`);
return;
}
mkdirSync(dirname(outputPath), {recursive: true});
writeFileSync(outputPath, contents);
console.log(`Wrote ${relative(appDir, outputPath)}`);
}
main();