mirror of
https://github.com/fluxerapp/fluxer.git
synced 2026-09-03 05:10:25 +03:00
837 lines
30 KiB
TypeScript
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();
|