refactor: consolidate color definitions into single source of truth

- Create frontend/colors.mjs as SSOT for all design colors
- Create scripts/generate-css-vars.mjs to auto-generate CSS variables
- Update tailwind.config.ts to import colors from colors.mjs
- Update frontend/package.json: add 'generate-css-vars' script to build pipeline
- Update start_servers.py: include CSS variable generation before npm build
- All color changes now require only one edit in colors.mjs
- Build process auto-syncs CSS variables and Tailwind colors

Eliminates DRY violation where colors were duplicated in:
  - tailwind.config.ts (Tailwind utility classes)
  - globals.css (CSS custom properties)

Single source of truth approach ensures:
  ✓ No inconsistency between Tailwind and CSS variables
  ✓ One place to update colors for all contexts
  ✓ Automated CSS variable generation on every build

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-04-25 17:31:25 +03:00
parent de1708a754
commit e715afd814
11 changed files with 281 additions and 264 deletions

View File

@@ -5,14 +5,16 @@
@tailwind utilities;
@layer base {
:root {
:root {
/* Base colors from DESIGN.md - AUTO-GENERATED from colors.mjs */
/* Base colors from DESIGN.md */
--background: #131313;
--on-background: #e5e2e1;
--foreground: #e5e2e1;
/* Surface tones from DESIGN.md */
--surface: #131313;
/* Surface colors from DESIGN.md */
--surface-DEFAULT: #131313;
--surface-dim: #131313;
--surface-bright: #3a3939;
--surface-container-lowest: #0e0e0e;
@@ -23,41 +25,41 @@
--surface-variant: #353534;
/* Primary colors from DESIGN.md */
--primary: #ffb781;
--on-primary: #4e2600;
--primary-DEFAULT: #ffb781;
--primary-foreground: #4e2600;
--primary-container: #f58618;
--on-primary-container: #5b2d00;
--primary-on-container: #5b2d00;
--primary-fixed: #ffdcc4;
--primary-fixed-dim: #ffb781;
--on-primary-fixed: #2f1400;
--on-primary-fixed-variant: #6f3800;
--inverse-primary: #924c00;
--primary-on-fixed: #2f1400;
--primary-on-fixed-variant: #6f3800;
--primary-inverse: #924c00;
/* Secondary colors from DESIGN.md */
--secondary: #c8c6c5;
--on-secondary: #303030;
--secondary-DEFAULT: #c8c6c5;
--secondary-foreground: #303030;
--secondary-container: #474746;
--on-secondary-container: #b7b5b4;
--secondary-on-container: #b7b5b4;
--secondary-fixed: #e5e2e1;
--secondary-fixed-dim: #c8c6c5;
--on-secondary-fixed: #1b1c1c;
--on-secondary-fixed-variant: #474746;
--secondary-on-fixed: #1b1c1c;
--secondary-on-fixed-variant: #474746;
/* Tertiary colors from DESIGN.md */
--tertiary: #00e639;
--on-tertiary: #003907;
--tertiary-DEFAULT: #00e639;
--tertiary-foreground: #003907;
--tertiary-container: #00bd2d;
--on-tertiary-container: #00440a;
--tertiary-on-container: #00440a;
--tertiary-fixed: #72ff70;
--tertiary-fixed-dim: #00e639;
--on-tertiary-fixed: #002203;
--on-tertiary-fixed-variant: #00530e;
--tertiary-on-fixed: #002203;
--tertiary-on-fixed-variant: #00530e;
/* Error colors from DESIGN.md */
--error: #ffb4ab;
--on-error: #690005;
--error-DEFAULT: #ffb4ab;
--error-foreground: #690005;
--error-container: #93000a;
--on-error-container: #ffdad6;
--error-on-container: #ffdad6;
/* Outline colors from DESIGN.md */
--outline: #a48c7c;
@@ -67,15 +69,25 @@
--inverse-surface: #e5e2e1;
--inverse-on-surface: #313030;
/* Semantic colors from DESIGN.md */
--border: #353534;
--muted: #9f9e9d;
--info: #64b5f6;
--success: #00e639;
--warning: #ffb781;
--alert: #ffb4ab;
/* Semantic spacing tokens */
--spacing-unit: 4px;
--spacing-stack-sm: 8px;
--spacing-stack-md: 16px;
--spacing-gutter: 16px;
--spacing-margin: 32px;
--spacing-container-gap: 24px;
--spacing-stack-sm: 8px;
--spacing-stack-md: 16px;
}
* {
@apply border-border;
border-radius: 0 !important;

98
frontend/colors.mjs Normal file
View File

@@ -0,0 +1,98 @@
/**
* SINGLE SOURCE OF TRUTH for all design colors
* Used by:
* - tailwind.config.ts (Tailwind utility classes)
* - scripts/generate-css-vars.mjs (CSS variables in globals.css)
*
* Changes here automatically propagate to both files via build step.
*/
export const colors = {
// Base colors from DESIGN.md
background: "#131313",
"on-background": "#e5e2e1",
foreground: "#e5e2e1",
// Surface tones from DESIGN.md
surface: {
DEFAULT: "#131313",
dim: "#131313",
bright: "#3a3939",
"container-lowest": "#0e0e0e",
"container-low": "#1c1b1b",
container: "#201f1f",
"container-high": "#2a2a2a",
"container-highest": "#353534",
variant: "#353534",
},
// Primary colors from DESIGN.md
primary: {
DEFAULT: "#ffb781",
foreground: "#4e2600",
container: "#f58618",
"on-container": "#5b2d00",
fixed: "#ffdcc4",
"fixed-dim": "#ffb781",
"on-fixed": "#2f1400",
"on-fixed-variant": "#6f3800",
inverse: "#924c00",
},
// Secondary colors from DESIGN.md
secondary: {
DEFAULT: "#c8c6c5",
foreground: "#303030",
container: "#474746",
"on-container": "#b7b5b4",
fixed: "#e5e2e1",
"fixed-dim": "#c8c6c5",
"on-fixed": "#1b1c1c",
"on-fixed-variant": "#474746",
},
// Tertiary colors from DESIGN.md
tertiary: {
DEFAULT: "#00e639",
foreground: "#003907",
container: "#00bd2d",
"on-container": "#00440a",
fixed: "#72ff70",
"fixed-dim": "#00e639",
"on-fixed": "#002203",
"on-fixed-variant": "#00530e",
},
// Error colors from DESIGN.md
error: {
DEFAULT: "#ffb4ab",
foreground: "#690005",
container: "#93000a",
"on-container": "#ffdad6",
},
// Outline colors from DESIGN.md
outline: "#a48c7c",
"outline-variant": "#564335",
// Inverse colors from DESIGN.md
"inverse-surface": "#e5e2e1",
"inverse-on-surface": "#313030",
// Semantic colors
border: "#353534",
muted: "#9f9e9d",
info: "#64b5f6",
success: "#00e639",
warning: "#ffb781",
alert: "#ffb4ab",
};
export const spacing = {
unit: "4px",
"stack-sm": "8px",
"stack-md": "16px",
gutter: "16px",
margin: "32px",
"container-gap": "24px",
};

View File

@@ -3,8 +3,9 @@
"version": "0.2.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"generate-css-vars": "node scripts/generate-css-vars.mjs",
"dev": "npm run generate-css-vars && next dev",
"build": "npm run generate-css-vars && next build",
"start": "next start",
"lint": "next lint",
"test": "vitest",

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,124 @@
#!/usr/bin/env node
/**
* Generate CSS variables from colors.mjs into globals.css
* Run before npm build to ensure globals.css is in sync with colors.mjs
*
* Usage: node scripts/generate-css-vars.mjs
*/
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
import { colors, spacing } from '../colors.mjs';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const globalsPath = path.join(__dirname, '../app/globals.css');
function flattenColorObject(obj, prefix = '') {
const result = {};
for (const [key, value] of Object.entries(obj)) {
const varName = prefix ? `${prefix}-${key}` : key;
if (typeof value === 'object' && value !== null && !key.includes('-')) {
// Nested object like primary: { DEFAULT: ..., container: ... }
Object.assign(result, flattenColorObject(value, varName));
} else if (typeof value === 'string') {
// Flat string value
result[varName] = value;
}
}
return result;
}
function generateCSSVariables() {
const cssVars = flattenColorObject(colors);
let css = ' :root {\n';
css += ' /* Base colors from DESIGN.md - AUTO-GENERATED from colors.mjs */\n';
// Group variables by category for readability
const categories = {
'Base': ['background', 'on-background', 'foreground'],
'Surface': Object.keys(cssVars).filter(k => k.startsWith('surface')),
'Primary': Object.keys(cssVars).filter(k => k.startsWith('primary')),
'Secondary': Object.keys(cssVars).filter(k => k.startsWith('secondary')),
'Tertiary': Object.keys(cssVars).filter(k => k.startsWith('tertiary')),
'Error': Object.keys(cssVars).filter(k => k.startsWith('error')),
'Outline': Object.keys(cssVars).filter(k => k.startsWith('outline')),
'Inverse': Object.keys(cssVars).filter(k => k.startsWith('inverse')),
'Semantic': Object.keys(cssVars).filter(k =>
['border', 'muted', 'info', 'success', 'warning', 'alert'].includes(k)
),
};
for (const [category, keys] of Object.entries(categories)) {
if (keys.length > 0) {
css += `\n /* ${category} colors from DESIGN.md */\n`;
for (const key of keys) {
css += ` --${key}: ${cssVars[key]};\n`;
}
}
}
css += '\n /* Semantic spacing tokens */\n';
for (const [key, value] of Object.entries(spacing)) {
css += ` --spacing-${key}: ${value};\n`;
}
css += ' }\n';
return css;
}
function updateGlobalsCSS() {
const content = fs.readFileSync(globalsPath, 'utf-8');
// Find the start of @layer base and the :root closing brace
const layerBaseStart = content.indexOf('@layer base {');
const rootStart = content.indexOf(':root {', layerBaseStart);
if (layerBaseStart === -1 || rootStart === -1) {
console.error('❌ Could not find @layer base or :root in globals.css');
process.exit(1);
}
// Find the matching closing brace for :root
let braceCount = 0;
let rootEnd = -1;
for (let i = rootStart; i < content.length; i++) {
if (content[i] === '{') braceCount++;
if (content[i] === '}') {
braceCount--;
if (braceCount === 0) {
rootEnd = i;
break;
}
}
}
if (rootEnd === -1) {
console.error('❌ Could not find closing brace for :root');
process.exit(1);
}
// Generate new CSS variables
const newCSSVars = generateCSSVariables();
// Reconstruct: everything before :root + generated :root + everything after
const before = content.substring(0, rootStart);
const after = content.substring(rootEnd + 1);
const newContent = before + newCSSVars + after;
fs.writeFileSync(globalsPath, newContent, 'utf-8');
console.log('✓ Generated CSS variables in globals.css from colors.mjs');
}
try {
updateGlobalsCSS();
} catch (error) {
console.error('❌ Error generating CSS variables:', error.message);
process.exit(1);
}

View File

@@ -1,4 +1,6 @@
import type { Config } from "tailwindcss";
// @ts-ignore - colors.mjs uses ESM exports, TypeScript may warn but it works at runtime
import { colors as colorsDefinition } from "./colors.mjs";
const config: Config = {
content: [
@@ -38,90 +40,7 @@ const config: Config = {
fontFamily: {
sans: ["'Space Grotesk'", "sans-serif"],
},
colors: {
// Base colors from DESIGN.md
background: "#131313",
"on-background": "#e5e2e1",
foreground: "#e5e2e1",
// Surface tones from DESIGN.md
surface: {
DEFAULT: "#131313",
dim: "#131313",
bright: "#3a3939",
"container-lowest": "#0e0e0e",
"container-low": "#1c1b1b",
container: "#201f1f",
"container-high": "#2a2a2a",
"container-highest": "#353534",
variant: "#353534",
},
// Primary colors from DESIGN.md
primary: {
DEFAULT: "#ffb781",
foreground: "#4e2600",
container: "#f58618",
"on-container": "#5b2d00",
fixed: "#ffdcc4",
"fixed-dim": "#ffb781",
"on-fixed": "#2f1400",
"on-fixed-variant": "#6f3800",
inverse: "#924c00",
},
// Secondary colors from DESIGN.md
secondary: {
DEFAULT: "#c8c6c5",
foreground: "#303030",
container: "#474746",
"on-container": "#b7b5b4",
fixed: "#e5e2e1",
"fixed-dim": "#c8c6c5",
"on-fixed": "#1b1c1c",
"on-fixed-variant": "#474746",
},
// Tertiary colors from DESIGN.md
tertiary: {
DEFAULT: "#00e639",
foreground: "#003907",
container: "#00bd2d",
"on-container": "#00440a",
fixed: "#72ff70",
"fixed-dim": "#00e639",
"on-fixed": "#002203",
"on-fixed-variant": "#00530e",
},
// Error colors from DESIGN.md
error: {
DEFAULT: "#ffb4ab",
foreground: "#690005",
container: "#93000a",
"on-container": "#ffdad6",
},
// Outline colors from DESIGN.md
outline: "#a48c7c",
"outline-variant": "#564335",
// Semantic surface colors (for compatibility)
muted: {
DEFAULT: "#474746",
foreground: "#a48c7c",
},
border: {
DEFAULT: "#353534",
strong: "#ffb781",
},
success: {
DEFAULT: "#00e639",
foreground: "#003907",
},
warning: "#ffb781",
info: "#c8c6c5",
},
colors: colorsDefinition,
keyframes: {
scan: {
'0%, 100%': { top: '0%' },

File diff suppressed because one or more lines are too long