diff --git a/libs/design-tokens/package.json b/libs/design-tokens/package.json index 3531bb954..74183274c 100644 --- a/libs/design-tokens/package.json +++ b/libs/design-tokens/package.json @@ -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", diff --git a/libs/design-tokens/scripts/generate-theme-css.ts b/libs/design-tokens/scripts/generate-theme-css.ts index 463b240e6..f47aa73a6 100644 --- a/libs/design-tokens/scripts/generate-theme-css.ts +++ b/libs/design-tokens/scripts/generate-theme-css.ts @@ -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 @@ -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 */'); @@ -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 { @@ -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) diff --git a/libs/design-tokens/src/lib/generate-theme-css.spec.ts b/libs/design-tokens/src/lib/generate-theme-css.spec.ts index ad2d820b8..b346c2db8 100644 --- a/libs/design-tokens/src/lib/generate-theme-css.spec.ts +++ b/libs/design-tokens/src/lib/generate-theme-css.spec.ts @@ -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', () => { @@ -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')), + ); + }); }); diff --git a/libs/design-tokens/src/lib/tokens-dark.css b/libs/design-tokens/src/lib/tokens-dark.css new file mode 100644 index 000000000..51990e2ca --- /dev/null +++ b/libs/design-tokens/src/lib/tokens-dark.css @@ -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; +}