Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion libs/design-tokens/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@
"default": "./src/index.js"
},
"./theme.css": "./src/lib/theme.css",
"./tokens.css": "./src/lib/tokens.css"
"./tokens.css": "./src/lib/tokens.css",
"./tokens-dark.css": "./src/lib/tokens-dark.css"
},
"repository": {
"type": "git",
Expand Down
69 changes: 49 additions & 20 deletions libs/design-tokens/scripts/generate-theme-css.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,12 @@ import { resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { baseTokens } from '../src/lib/base';
import { lightOverrides } from '../src/lib/light';
import { darkOverrides } from '../src/lib/dark';

const HERE = fileURLToPath(new URL('.', import.meta.url));
const OUTPUT_PATH = resolve(HERE, '..', 'src', 'lib', 'theme.css');
const TOKENS_OUTPUT_PATH = resolve(HERE, '..', 'src', 'lib', 'tokens.css');
const TOKENS_DARK_OUTPUT_PATH = resolve(HERE, '..', 'src', 'lib', 'tokens-dark.css');

const HEADER = `/*
* @threadplane/design-tokens/theme.css
Expand Down Expand Up @@ -181,36 +183,38 @@ const TOKENS_HEADER = `/*
*/
`;

function buildTokensBlock(): string {
type ThemeOverrides = typeof lightOverrides | typeof darkOverrides;

function buildTokensBlock(theme: ThemeOverrides): string {
const { typography, space, radius, shadows, brand } = baseTokens;
const lines: string[] = [':root {'];

lines.push(' /* Colors */');
lines.push(` --ds-bg: ${lightOverrides.bg};`);
lines.push(` --ds-accent: ${lightOverrides.accent};`);
lines.push(` --ds-accent-hover: ${lightOverrides.accentHover};`);
lines.push(` --ds-bg: ${theme.bg};`);
lines.push(` --ds-accent: ${theme.accent};`);
lines.push(` --ds-accent-hover: ${theme.accentHover};`);
lines.push(` --ds-accent-light: ${brand.accentLight};`);
lines.push(` --ds-accent-glow: ${lightOverrides.accentGlow};`);
lines.push(` --ds-accent-border: ${lightOverrides.accentBorder};`);
lines.push(` --ds-accent-border-hover: ${lightOverrides.accentBorderHover};`);
lines.push(` --ds-accent-surface: ${lightOverrides.accentSurface};`);
lines.push(` --ds-text-primary: ${lightOverrides.textPrimary};`);
lines.push(` --ds-text-secondary: ${lightOverrides.textSecondary};`);
lines.push(` --ds-text-muted: ${lightOverrides.textMuted};`);
lines.push(` --ds-text-inverted: ${lightOverrides.textInverted};`);
lines.push(` --ds-sidebar-bg: ${lightOverrides.sidebarBg};`);
lines.push(` --ds-accent-glow: ${theme.accentGlow};`);
lines.push(` --ds-accent-border: ${theme.accentBorder};`);
lines.push(` --ds-accent-border-hover: ${theme.accentBorderHover};`);
lines.push(` --ds-accent-surface: ${theme.accentSurface};`);
lines.push(` --ds-text-primary: ${theme.textPrimary};`);
lines.push(` --ds-text-secondary: ${theme.textSecondary};`);
lines.push(` --ds-text-muted: ${theme.textMuted};`);
lines.push(` --ds-text-inverted: ${theme.textInverted};`);
lines.push(` --ds-sidebar-bg: ${theme.sidebarBg};`);
lines.push(` --ds-angular-red: ${brand.angularRed};`);
lines.push(` --ds-render-green: ${brand.renderGreen};`);
lines.push(` --ds-chat-purple: ${brand.chatPurple};`);

lines.push('');
lines.push(' /* Surfaces */');
lines.push(` --ds-canvas: ${lightOverrides.canvas};`);
lines.push(` --ds-surface: ${lightOverrides.surface};`);
lines.push(` --ds-surface-tinted: ${lightOverrides.surfaceTinted};`);
lines.push(` --ds-surface-dim: ${lightOverrides.surfaceDim};`);
lines.push(` --ds-border: ${lightOverrides.border};`);
lines.push(` --ds-border-strong: ${lightOverrides.borderStrong};`);
lines.push(` --ds-canvas: ${theme.canvas};`);
lines.push(` --ds-surface: ${theme.surface};`);
lines.push(` --ds-surface-tinted: ${theme.surfaceTinted};`);
lines.push(` --ds-surface-dim: ${theme.surfaceDim};`);
lines.push(` --ds-border: ${theme.border};`);
lines.push(` --ds-border-strong: ${theme.borderStrong};`);

lines.push('');
lines.push(' /* Typography */');
Expand Down Expand Up @@ -265,8 +269,31 @@ function buildTokensBlock(): string {
return lines.join('\n') + '\n';
}

const TOKENS_DARK_HEADER = TOKENS_HEADER.replace(
'@threadplane/design-tokens/tokens.css',
'@threadplane/design-tokens/tokens-dark.css',
).replace(
'- libs/design-tokens/src/lib/light.ts',
`- libs/design-tokens/src/lib/dark.ts`,
).replace(
'Plain `:root { --ds-* }` custom properties for consumers that do not run',
'DARK-theme `:root { --ds-* }` custom properties for consumers that do not run',
);

export function generateTokensCss(): string {
return TOKENS_HEADER + buildTokensBlock();
return TOKENS_HEADER + buildTokensBlock(lightOverrides);
}

/**
* Dark twin of tokens.css, from darkOverrides. Exists because the cockpit
* example apps were designed dark: their var(--ds-*, fallback) fallbacks are
* hand-copies of darkOverrides (verified value-by-value, 2026-08-30, with
* minor drift - e.g. accentBorder 0.25 vs the token's 0.2). Wiring THIS file
* in makes those fallbacks dead text; wiring the light tokens.css would flip
* deliberately-dark apps to light.
*/
export function generateTokensDarkCss(): string {
return TOKENS_DARK_HEADER + buildTokensBlock(darkOverrides);
}

export function generateThemeCss(): string {
Expand All @@ -276,8 +303,10 @@ export function generateThemeCss(): string {
function main() {
writeFileSync(OUTPUT_PATH, generateThemeCss());
writeFileSync(TOKENS_OUTPUT_PATH, generateTokensCss());
writeFileSync(TOKENS_DARK_OUTPUT_PATH, generateTokensDarkCss());
console.log(`wrote ${OUTPUT_PATH}`);
console.log(`wrote ${TOKENS_OUTPUT_PATH}`);
console.log(`wrote ${TOKENS_DARK_OUTPUT_PATH}`);
}

// Only run main when invoked directly (not when imported by tests)
Expand Down
17 changes: 16 additions & 1 deletion libs/design-tokens/src/lib/generate-theme-css.spec.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { describe, it, expect } from 'vitest';
import { generateThemeCss, generateTokensCss } from '../../scripts/generate-theme-css';
import { generateThemeCss, generateTokensCss, generateTokensDarkCss } from '../../scripts/generate-theme-css';

const COMMITTED_PATH = resolve(__dirname, 'theme.css');
const COMMITTED_TOKENS_PATH = resolve(__dirname, 'tokens.css');
const COMMITTED_TOKENS_DARK_PATH = resolve(__dirname, 'tokens-dark.css');

describe('generate-theme-css', () => {
it('produces output that matches the committed theme.css', () => {
Expand All @@ -18,4 +19,18 @@ describe('generate-theme-css', () => {
const actual = generateTokensCss();
expect(actual).toBe(expected);
});

it('produces output that matches the committed tokens-dark.css', () => {
const expected = readFileSync(COMMITTED_TOKENS_DARK_PATH, 'utf-8');
const actual = generateTokensDarkCss();
expect(actual).toBe(expected);
});

it('light and dark tokens files define the same --ds-* names', () => {
const names = (css: string) =>
[...css.matchAll(/^\s*(--ds-[a-z0-9-]+):/gm)].map((m) => m[1]).sort();
expect(names(readFileSync(COMMITTED_TOKENS_DARK_PATH, 'utf-8'))).toEqual(
names(readFileSync(COMMITTED_TOKENS_PATH, 'utf-8')),
);
});
});
89 changes: 89 additions & 0 deletions libs/design-tokens/src/lib/tokens-dark.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
/*
* @threadplane/design-tokens/tokens-dark.css
*
* GENERATED FILE — DO NOT EDIT BY HAND.
*
* DARK-theme `:root { --ds-* }` custom properties for consumers that do not run
* Tailwind (the Angular cockpit and example apps). Same values as theme.css,
* different naming convention and no `@theme` wrapper.
*
* Regenerate with:
* npx nx run design-tokens:generate-theme-css
*
* Source of truth:
* - libs/design-tokens/src/lib/dark.ts
* - libs/design-tokens/src/lib/base.ts
*
* The names here are a consumer contract — cockpit and example apps reference
* them. ds-var-contract.spec.ts fails if one disappears.
*/
:root {
/* Colors */
--ds-bg: rgb(17, 17, 17);
--ds-accent: #64C3FD;
--ds-accent-hover: #8dd4ff;
--ds-accent-light: #64C3FD;
--ds-accent-glow: rgba(100, 195, 253, 0.25);
--ds-accent-border: rgba(100, 195, 253, 0.2);
--ds-accent-border-hover: rgba(100, 195, 253, 0.35);
--ds-accent-surface: rgba(100, 195, 253, 0.08);
--ds-text-primary: rgb(245, 245, 245);
--ds-text-secondary: rgb(200, 200, 200);
--ds-text-muted: rgb(160, 160, 160);
--ds-text-inverted: rgb(17, 17, 17);
--ds-sidebar-bg: rgba(28, 28, 28, 0.65);
--ds-angular-red: #DD0031;
--ds-render-green: #1a7a40;
--ds-chat-purple: #5a00c8;

/* Surfaces */
--ds-canvas: rgb(17, 17, 17);
--ds-surface: rgb(28, 28, 28);
--ds-surface-tinted: rgb(44, 44, 44);
--ds-surface-dim: rgb(10, 10, 10);
--ds-border: rgb(45, 45, 45);
--ds-border-strong: rgb(60, 60, 60);

/* Typography */
--ds-font-serif: "EB Garamond", Georgia, serif;
--ds-font-sans: Inter, system-ui, sans-serif;
--ds-font-mono: "JetBrains Mono", monospace;

/* Typography — type scale */
--ds-h1-size: clamp(48px, 6vw, 72px);
--ds-h1-line: 1.08;
--ds-h2-size: clamp(36px, 4.5vw, 56px);
--ds-h2-line: 1.12;
--ds-h3-size: 28px;
--ds-h3-line: 1.25;
--ds-h3-weight: 600;
--ds-eyebrow-size: 12px;
--ds-eyebrow-line: 1.4;
--ds-eyebrow-weight: 700;
--ds-eyebrow-spacing: 0.12em;
--ds-body-lg-size: 20px;
--ds-body-lg-line: 1.6;
--ds-body-size: 16px;
--ds-body-line: 1.6;
--ds-caption-size: 14px;
--ds-caption-line: 1.5;

/* Shadows */
--ds-shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05);
--ds-shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.10), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
--ds-shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.10), 0 4px 6px -2px rgba(0, 0, 0, 0.05);
--ds-shadow-focus: 0 0 0 3px rgba(0, 64, 144, 0.25);

/* Radius */
--ds-radius-sm: 6px;
--ds-radius-md: 10px;
--ds-radius-lg: 14px;
--ds-radius-xl: 20px;
--ds-radius-full: 999px;

/* Space */
--ds-section-y: clamp(64px, 8vw, 120px);
--ds-section-y-tight: clamp(48px, 6vw, 80px);
--ds-container-x: clamp(20px, 4vw, 40px);
--ds-container-max: 1200px;
}
Loading