diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8163855..79b978d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -30,3 +30,10 @@ jobs: - name: Build run: bun run build + + # DESIGN.md validation. Fails on structural errors only; warnings + # (e.g. contrast below AA, unused tokens) are visible in the job log + # but don't block merges. Tighten to `--fail-on warning` once the + # team has cleaned the warning surface. + - name: Lint DESIGN.md + run: bun run lint:design diff --git a/DESIGN.md b/DESIGN.md index 7b431b8..64d92c4 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -3,14 +3,14 @@ version: alpha name: Stream UI Default description: Default theme for stream-ui — neutral, accessible, agent-friendly. colors: - primary: "#508CDC" - primary-hover: "#5F9BEB" - neutral: "#7F7F7F" - success: "#50B464" - warning: "#DCAA46" - error: "#DC5A5A" - link: "#6496FF" - link-hover: "#82AFFF" + primary: "#1F6FC7" + primary-hover: "#2C7DD4" + neutral: "#6B7280" + success: "#2E8F48" + warning: "#B17A20" + error: "#C43D3D" + link: "#2F6FC7" + link-hover: "#1F5FB5" on-primary: "#FFFFFF" on-error: "#FFFFFF" typography: @@ -46,6 +46,14 @@ spacing: sm: 0.5rem md: 0.75rem lg: 1.5rem +motion: + duration: + fast: 120ms + base: 200ms + slow: 320ms + easing: + standard: "cubic-bezier(0.2, 0, 0, 1)" + emphasized: "cubic-bezier(0.3, 0, 0, 1)" components: button-primary: backgroundColor: "{colors.primary}" @@ -57,6 +65,33 @@ components: backgroundColor: "{colors.error}" textColor: "{colors.on-error}" rounded: "{rounded.md}" + alert-info: + backgroundColor: "{colors.primary}" + alert-success: + backgroundColor: "{colors.success}" + alert-warning: + backgroundColor: "{colors.warning}" + alert-error: + backgroundColor: "{colors.error}" + badge-default: + backgroundColor: "{colors.neutral}" + link: + textColor: "{colors.link}" + link-hover: + textColor: "{colors.link-hover}" +variants: + dark: + colors: + primary: "#7BB0F0" + primary-hover: "#92BFF5" + neutral: "#A8A8A8" + success: "#66C67A" + warning: "#E8BA5A" + error: "#EB7A7A" + link: "#8FB4FF" + link-hover: "#B0C8FF" + on-primary: "#0B1116" + on-error: "#0B1116" --- ## Overview @@ -93,6 +128,22 @@ than custom CSS. Three rounded levels: `sm` (inline tags, code blocks), `md` (cards, buttons, inputs), and `pill` (badges, progress bars). +## Motion + +Stream-UI renders the majority of its UI at agent-cadence — values change in +bursts, not continuously — so transitions should feel reactive, not decorative. + +- **duration.fast (120ms)** — local hover/focus shifts; color/background tweens. +- **duration.base (200ms)** — progress bar fills, state changes on a single + component (e.g. button press). +- **duration.slow (320ms)** — structural changes (layout reflow, dialog + enter/exit). Avoid beyond this; the agent's own streaming already provides + the perceived motion. + +Use `easing.standard` for general acceleration; `emphasized` for first-time +reveals (enter animations). No bounce, no overshoot — streaming UI should feel +precise, not playful. + ## Components Component tokens map to stream-ui's built-in kinds. Variants (hover, pressed, diff --git a/package.json b/package.json index 0b672c9..2c0d9d9 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,8 @@ ], "scripts": { "tokens": "bun run scripts/design-to-css.ts", + "lint:design": "bun run scripts/lint-design.ts", + "diff:design": "bunx @google/design.md@latest diff", "build": "bun run tokens && tsup src/index.ts --format esm --dts --clean && bun run scripts/build-styles.ts", "dev": "bun run tokens && tsup src/index.ts --format esm --dts --watch --onSuccess \"bun run scripts/build-styles.ts\"", "playground": "bun run tokens && vite", diff --git a/playground/main.ts b/playground/main.ts index ca16062..b96dc15 100644 --- a/playground/main.ts +++ b/playground/main.ts @@ -11,7 +11,13 @@ import { render, VERSION, } from '../src/index' -import { type LayoutPreset, type ResizerPair, readSettings, writeSettings } from './settings' +import { + type LayoutPreset, + type ResizerPair, + readSettings, + type ThemePreset, + writeSettings, +} from './settings' import { mountSettingsPopover } from './settings-ui' // ─── Message state and localStorage ───────────────────────────────────── @@ -291,11 +297,40 @@ async function doLogout(): Promise { window.location.reload() } +// ─── theme ─────────────────────────────────────────────────────────────── +// Demonstrates DESIGN.md → CSS-var theming end-to-end with zero code changes +// to components. Heritage overrides a handful of semantic tokens inline; +// system/light/dark toggle the `.sui-theme-*` class that the generator emits +// alongside the :root block. +const HERITAGE_OVERRIDES: Record = { + '--sui-colors-primary': '#b8422e', + '--sui-colors-primary-hover': '#cb4f37', + '--sui-colors-link': '#b8422e', + '--sui-colors-link-hover': '#cb4f37', + '--sui-colors-on-primary': '#f7f5f2', + '--sui-components-button-primary-background-color': '#b8422e', + '--sui-components-button-primary-text-color': '#f7f5f2', + '--sui-components-button-primary-hover-background-color': '#cb4f37', +} + +function applyTheme(theme: ThemePreset): void { + const html = document.documentElement + html.classList.remove('sui-theme-light', 'sui-theme-dark', 'sui-theme-heritage') + for (const prop of Object.keys(HERITAGE_OVERRIDES)) html.style.removeProperty(prop) + if (theme !== 'system') html.classList.add(`sui-theme-${theme}`) + if (theme === 'heritage') { + for (const [k, v] of Object.entries(HERITAGE_OVERRIDES)) html.style.setProperty(k, v) + } +} + +applyTheme(readSettings().theme) + const settingsBtn = document.getElementById('settings-btn') as HTMLButtonElement | null const settingsPopover = document.getElementById('settings-popover') as HTMLDivElement | null if (settingsBtn && settingsPopover && grid) { mountSettingsPopover(settingsBtn, settingsPopover, { onLayoutChange: () => applyLayout(grid), + onThemeChange: (t) => applyTheme(t), onLogout: () => doLogout(), }) } diff --git a/playground/server.ts b/playground/server.ts index cd8748b..5acaa75 100644 --- a/playground/server.ts +++ b/playground/server.ts @@ -16,6 +16,22 @@ import { stepCountIs, streamText, tool } from 'ai' import { z } from 'zod' import { BUILTIN_KINDS } from '../src/types' +function loadDesignGuide(): string | null { + for (const candidate of [ + resolve(process.cwd(), 'DESIGN.md'), + resolve(import.meta.dir, '..', 'DESIGN.md'), + ]) { + try { + return readFileSync(candidate, 'utf8') + } catch { + // try next candidate + } + } + return null +} + +const DESIGN_GUIDE = loadDesignGuide() + function loadEnvFile(file: string): void { if (!existsSync(file)) return for (const line of readFileSync(file, 'utf8').split('\n')) { @@ -150,7 +166,20 @@ Guidelines: 7. If the latest user message is "[form submit: ] key="value" ...", the user submitted form . Acknowledge or advance — e.g. render_ui a success card. 8. If the latest user message is "[button clicked: ]", the user clicked a - button with that action. Continue the flow accordingly.` + button with that action. Continue the flow accordingly.${ + DESIGN_GUIDE + ? ` + +--- + +## Design system (from DESIGN.md) + +The following is the project's visual identity. Read the tokens to pick +component variants by *intent*, not hex values. Honor the Do's and Don'ts. + +${DESIGN_GUIDE}` + : '' + }` type AgentEvent = | { type: 'thinking'; text: string } diff --git a/playground/settings-ui.ts b/playground/settings-ui.ts index 1f10dd7..dc62a56 100644 --- a/playground/settings-ui.ts +++ b/playground/settings-ui.ts @@ -4,11 +4,14 @@ import { type LayoutPreset, MODEL_PRESETS, readSettings, + THEME_PRESETS, + type ThemePreset, writeSettings, } from './settings' export type PopoverCallbacks = { onLayoutChange: () => void + onThemeChange: (theme: ThemePreset) => void onLogout: () => void } @@ -18,6 +21,13 @@ const PRESET_LABELS: Record = { stacked: 'Stacked', } +const THEME_LABELS: Record = { + system: 'System', + light: 'Light', + dark: 'Dark', + heritage: 'Heritage', +} + export function mountSettingsPopover( btn: HTMLButtonElement, popover: HTMLDivElement, @@ -57,6 +67,7 @@ export function mountSettingsPopover( const s = readSettings() const isCustomModel = !MODEL_PRESETS.includes(s.model) popover.replaceChildren( + section('Theme', [themeRow(s.theme)]), section('Model', [ modelSelect(s.model, isCustomModel), customModelInput(s.model, isCustomModel), @@ -66,6 +77,28 @@ export function mountSettingsPopover( ) } + function themeRow(current: ThemePreset): HTMLElement { + const wrap = document.createElement('div') + wrap.className = 'settings-theme-row' + wrap.setAttribute('role', 'group') + wrap.setAttribute('aria-label', 'Theme') + for (const t of THEME_PRESETS) { + const btn = document.createElement('button') + btn.type = 'button' + btn.className = 'settings-theme-btn' + btn.textContent = THEME_LABELS[t] + btn.dataset.theme = t + btn.setAttribute('aria-pressed', t === current ? 'true' : 'false') + btn.addEventListener('click', () => { + writeSettings({ theme: t }) + cb.onThemeChange(t) + render() + }) + wrap.appendChild(btn) + } + return wrap + } + function section(title: string, children: HTMLElement[]): HTMLElement { const wrap = document.createElement('div') wrap.className = 'settings-section' diff --git a/playground/settings.ts b/playground/settings.ts index 14796c1..ea34f22 100644 --- a/playground/settings.ts +++ b/playground/settings.ts @@ -12,21 +12,30 @@ export const MODEL_PRESETS: ReadonlyArray = [ export type LayoutPreset = 'default' | 'sideBySide' | 'stacked' export const LAYOUT_PRESETS: ReadonlyArray = ['default', 'sideBySide', 'stacked'] +export type ThemePreset = 'system' | 'light' | 'dark' | 'heritage' +export const THEME_PRESETS: ReadonlyArray = ['system', 'light', 'dark', 'heritage'] + export type ResizerPair = 'chat-ai' | 'ai-ui' | 'top-bottom' export type SizeMap = Partial> export type SuiSettings = { model: string + theme: ThemePreset layout: LayoutPreset hideAI: boolean sizes: Record } const KEY_MODEL = 'sui.model' +const KEY_THEME = 'sui.theme' const KEY_LAYOUT = 'sui.layout.preset' const KEY_HIDE_AI = 'sui.layout.hideAI' const sizesKey = (p: LayoutPreset) => `sui.layout.sizes.${p}` +function isThemePreset(v: unknown): v is ThemePreset { + return v === 'system' || v === 'light' || v === 'dark' || v === 'heritage' +} + function readJSON(key: string, fallback: T): T { try { const raw = localStorage.getItem(key) @@ -64,8 +73,11 @@ export function readSettings(): SuiSettings { rawLayout === 'default' || rawLayout === 'sideBySide' || rawLayout === 'stacked' ? rawLayout : 'default' + const rawTheme = getItem(KEY_THEME) + const theme: ThemePreset = isThemePreset(rawTheme) ? rawTheme : 'system' return { model: getItem(KEY_MODEL) ?? DEFAULT_MODEL, + theme, layout, hideAI: getItem(KEY_HIDE_AI) === 'true', sizes: { @@ -78,6 +90,7 @@ export function readSettings(): SuiSettings { export type SettingsPatch = Partial<{ model: string + theme: ThemePreset layout: LayoutPreset hideAI: boolean sizes: Partial> @@ -86,6 +99,7 @@ export type SettingsPatch = Partial<{ export function writeSettings(patch: SettingsPatch): void { try { if (patch.model !== undefined) localStorage.setItem(KEY_MODEL, patch.model) + if (patch.theme !== undefined) localStorage.setItem(KEY_THEME, patch.theme) if (patch.layout !== undefined) localStorage.setItem(KEY_LAYOUT, patch.layout) if (patch.hideAI !== undefined) localStorage.setItem(KEY_HIDE_AI, String(patch.hideAI)) if (patch.sizes !== undefined) { diff --git a/playground/style.css b/playground/style.css index 2edeaea..e698fca 100644 --- a/playground/style.css +++ b/playground/style.css @@ -108,6 +108,31 @@ header { background: rgba(220, 60, 60, 0.25); } +.settings-theme-row { + display: flex; + flex-wrap: wrap; + gap: 0.35rem; +} +.settings-theme-btn { + flex: 1 0 auto; + padding: 0.35rem 0.5rem; + border: 1px solid rgba(127, 127, 127, 0.3); + background: transparent; + color: inherit; + border-radius: 0.35rem; + cursor: pointer; + font: inherit; + font-size: 0.8rem; +} +.settings-theme-btn[aria-pressed="true"] { + background: var(--sui-colors-primary, rgb(80, 140, 220)); + color: var(--sui-colors-on-primary, white); + border-color: var(--sui-colors-primary, rgb(80, 140, 220)); +} +.settings-theme-btn:hover:not([aria-pressed="true"]) { + background: rgba(127, 127, 127, 0.15); +} + h1 { margin: 0 0 0.25rem; font-size: 1.5rem; @@ -412,7 +437,8 @@ body.resizing-col { } .chat-input input:focus { - outline: 2px solid rgba(100, 150, 255, 0.5); + outline: 2px solid + color-mix(in srgb, var(--sui-colors-primary, rgb(100, 150, 255)) 50%, transparent); outline-offset: -1px; } diff --git a/scripts/design-to-css.test.ts b/scripts/design-to-css.test.ts new file mode 100644 index 0000000..895812e --- /dev/null +++ b/scripts/design-to-css.test.ts @@ -0,0 +1,68 @@ +import { spawnSync } from 'node:child_process' +import { readFileSync } from 'node:fs' +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { describe, expect, it } from 'vitest' + +const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..') +const TOKENS = join(ROOT, 'src', 'design-tokens.css') + +function run(): { out: string; status: number | null } { + const r = spawnSync('bun', ['run', 'scripts/design-to-css.ts'], { cwd: ROOT, encoding: 'utf8' }) + return { out: readFileSync(TOKENS, 'utf8'), status: r.status } +} + +describe('design-to-css generator', () => { + // The generated file is the contract between DESIGN.md and every consumer + // (styles.css, playground, agent-produced specs). These checks pin down the + // specific tokens each downstream selector depends on so a drift in the + // generator — or an unintended rename in DESIGN.md — fails loudly here + // instead of showing up as a cosmetic regression in the browser. + it('emits :root with every semantic color token styles.css references', () => { + const { status, out } = run() + expect(status).toBe(0) + expect(out).toMatch(/^:root \{/m) + for (const name of [ + '--sui-colors-primary:', + '--sui-colors-primary-hover:', + '--sui-colors-success:', + '--sui-colors-warning:', + '--sui-colors-error:', + '--sui-colors-link:', + '--sui-colors-on-primary:', + ]) { + expect(out).toContain(name) + } + }) + + it('emits motion tokens used by button/link/progress transitions', () => { + const { out } = run() + for (const name of [ + '--sui-motion-duration-fast:', + '--sui-motion-duration-base:', + '--sui-motion-easing-standard:', + ]) { + expect(out).toContain(name) + } + }) + + it('resolves token references via var()', () => { + const { out } = run() + expect(out).toContain( + '--sui-components-button-primary-background-color: var(--sui-colors-primary);', + ) + }) + + it('emits dark variant as both class and prefers-color-scheme media query', () => { + const { out } = run() + expect(out).toContain('.sui-theme-dark {') + expect(out).toContain('@media (prefers-color-scheme: dark) {') + expect(out).toContain(':root:not(.sui-theme-light) {') + }) + + it('lowercases hex values for biome compatibility', () => { + const { out } = run() + // Uppercase hex would fail biome lint; DESIGN.md is authored uppercase. + expect(out).not.toMatch(/#[0-9A-F]{3,8}\b/) + }) +}) diff --git a/scripts/design-to-css.ts b/scripts/design-to-css.ts index 36b5535..2a6344f 100644 --- a/scripts/design-to-css.ts +++ b/scripts/design-to-css.ts @@ -2,6 +2,11 @@ /** * Reads DESIGN.md front matter and emits src/design-tokens.css. * Run via `bun run tokens`. Also invoked by `build` and `playground`. + * + * Supports a `variants..` section for theme variants. `dark` is + * emitted as both `@media (prefers-color-scheme: dark)` and an opt-in + * `.sui-theme-` class; any other variant name is emitted only as the + * class, for explicit theme switching. */ import { readFileSync, writeFileSync } from 'node:fs' import { dirname, join } from 'node:path' @@ -40,34 +45,62 @@ function normalizeValue(value: string): string { return value.replace(/#[0-9A-Fa-f]{3,8}\b/g, (h) => h.toLowerCase()) } +const TOKEN_GROUPS = ['colors', 'typography', 'rounded', 'spacing', 'motion', 'components'] as const + +function collectVars(source: Record): string[] { + const out: string[] = [] + function emit(obj: Record, prefix: string): void { + for (const [rawKey, rawVal] of Object.entries(obj)) { + const key = kebab(rawKey) + const varName = `--sui-${prefix}-${key}` + if (typeof rawVal === 'string') { + out.push(`${varName}: ${normalizeValue(resolveRef(rawVal))};`) + } else if (typeof rawVal === 'number') { + out.push(`${varName}: ${rawVal};`) + } else if (rawVal && typeof rawVal === 'object' && !Array.isArray(rawVal)) { + emit(rawVal as Record, `${prefix}-${key}`) + } + } + } + for (const group of TOKEN_GROUPS) { + const section = source[group] + if (section && typeof section === 'object') { + emit(section as Record, kebab(group)) + } + } + return out +} + const lines: string[] = [ '/* AUTO-GENERATED from DESIGN.md — do not edit by hand.', ' * Run `bun run tokens` to regenerate after changing DESIGN.md. */', - ':root {', ] -function emit(obj: Record, prefix: string): void { - for (const [rawKey, rawVal] of Object.entries(obj)) { - const key = kebab(rawKey) - const varName = `--sui-${prefix}-${key}` - if (typeof rawVal === 'string') { - lines.push(` ${varName}: ${normalizeValue(resolveRef(rawVal))};`) - } else if (typeof rawVal === 'number') { - lines.push(` ${varName}: ${rawVal};`) - } else if (rawVal && typeof rawVal === 'object' && !Array.isArray(rawVal)) { - emit(rawVal as Record, `${prefix}-${key}`) - } - } -} +const rootVars = collectVars(tokens) +lines.push(':root {', ...rootVars.map((v) => ` ${v}`), '}') -for (const group of ['colors', 'typography', 'rounded', 'spacing', 'components'] as const) { - const section = tokens[group] - if (section && typeof section === 'object') { - emit(section as Record, kebab(group)) +const variants = tokens.variants +if (variants && typeof variants === 'object') { + for (const [name, payload] of Object.entries(variants as Record)) { + if (!payload || typeof payload !== 'object') continue + const variantVars = collectVars(payload as Record) + if (variantVars.length === 0) continue + const className = `.sui-theme-${kebab(name)}` + lines.push('', `${className} {`, ...variantVars.map((v) => ` ${v}`), '}') + if (name === 'dark') { + lines.push( + '', + '@media (prefers-color-scheme: dark) {', + ' :root:not(.sui-theme-light) {', + ...variantVars.map((v) => ` ${v}`), + ' }', + '}', + ) + } } } -lines.push('}', '') +lines.push('') writeFileSync(OUT, lines.join('\n')) console.log(`[design-to-css] wrote ${OUT}`) diff --git a/scripts/lint-design.ts b/scripts/lint-design.ts new file mode 100644 index 0000000..5a2fbb6 --- /dev/null +++ b/scripts/lint-design.ts @@ -0,0 +1,59 @@ +#!/usr/bin/env bun +/** + * Runs @google/design.md lint on DESIGN.md, prints a readable summary, + * and exits non-zero when structural errors are present. + * + * Warnings are surfaced but do NOT fail the build — they typically + * represent design-quality findings (contrast, unused tokens) that are + * better treated as review signals than merge gates. Tighten to fail + * on warnings when the team has cleaned the backlog. + */ +import { spawnSync } from 'node:child_process' + +type Finding = { severity: 'error' | 'warning' | 'info'; path?: string; message: string } +type Report = { + findings: Finding[] + summary: { errors: number; warnings: number; infos: number } +} + +const r = spawnSync('bunx', ['@google/design.md@latest', 'lint', '--format=json', 'DESIGN.md'], { + encoding: 'utf8', + stdio: ['inherit', 'pipe', 'pipe'], +}) +if (r.error) { + console.error('[lint:design] failed to spawn:', r.error.message) + process.exit(2) +} + +// The CLI writes progress text to stdout before the JSON report. Pull +// out the final JSON object by scanning from the last standalone `{`. +const raw = r.stdout.toString() +const start = raw.lastIndexOf('\n{') +const jsonText = start >= 0 ? raw.slice(start + 1) : raw +let report: Report +try { + report = JSON.parse(jsonText) as Report +} catch { + console.error('[lint:design] could not parse lint output:') + console.error(raw) + process.exit(2) +} + +const { findings, summary } = report +const byKind = { error: [] as Finding[], warning: [] as Finding[], info: [] as Finding[] } +for (const f of findings) byKind[f.severity]?.push(f) + +const fmt = (f: Finding) => ` [${f.severity}]${f.path ? ` ${f.path}` : ''} — ${f.message}` + +if (byKind.error.length) console.log(`Errors:\n${byKind.error.map(fmt).join('\n')}`) +if (byKind.warning.length) console.log(`Warnings:\n${byKind.warning.map(fmt).join('\n')}`) +if (byKind.info.length) console.log(`Info:\n${byKind.info.map(fmt).join('\n')}`) + +console.log( + `\nSummary: ${summary.errors} error(s), ${summary.warnings} warning(s), ${summary.infos} info.`, +) + +if (summary.errors > 0) { + console.error('\n[lint:design] structural errors — failing build.') + process.exit(1) +} diff --git a/src/design-tokens.css b/src/design-tokens.css index f819369..79156c7 100644 --- a/src/design-tokens.css +++ b/src/design-tokens.css @@ -1,14 +1,14 @@ /* AUTO-GENERATED from DESIGN.md — do not edit by hand. * Run `bun run tokens` to regenerate after changing DESIGN.md. */ :root { - --sui-colors-primary: #508cdc; - --sui-colors-primary-hover: #5f9beb; - --sui-colors-neutral: #7f7f7f; - --sui-colors-success: #50b464; - --sui-colors-warning: #dcaa46; - --sui-colors-error: #dc5a5a; - --sui-colors-link: #6496ff; - --sui-colors-link-hover: #82afff; + --sui-colors-primary: #1f6fc7; + --sui-colors-primary-hover: #2c7dd4; + --sui-colors-neutral: #6b7280; + --sui-colors-success: #2e8f48; + --sui-colors-warning: #b17a20; + --sui-colors-error: #c43d3d; + --sui-colors-link: #2f6fc7; + --sui-colors-link-hover: #1f5fb5; --sui-colors-on-primary: #ffffff; --sui-colors-on-error: #ffffff; --sui-typography-h1-font-family: inherit; @@ -35,6 +35,11 @@ --sui-spacing-sm: 0.5rem; --sui-spacing-md: 0.75rem; --sui-spacing-lg: 1.5rem; + --sui-motion-duration-fast: 120ms; + --sui-motion-duration-base: 200ms; + --sui-motion-duration-slow: 320ms; + --sui-motion-easing-standard: cubic-bezier(0.2, 0, 0, 1); + --sui-motion-easing-emphasized: cubic-bezier(0.3, 0, 0, 1); --sui-components-button-primary-background-color: var(--sui-colors-primary); --sui-components-button-primary-text-color: var(--sui-colors-on-primary); --sui-components-button-primary-rounded: var(--sui-rounded-md); @@ -42,4 +47,39 @@ --sui-components-button-danger-background-color: var(--sui-colors-error); --sui-components-button-danger-text-color: var(--sui-colors-on-error); --sui-components-button-danger-rounded: var(--sui-rounded-md); + --sui-components-alert-info-background-color: var(--sui-colors-primary); + --sui-components-alert-success-background-color: var(--sui-colors-success); + --sui-components-alert-warning-background-color: var(--sui-colors-warning); + --sui-components-alert-error-background-color: var(--sui-colors-error); + --sui-components-badge-default-background-color: var(--sui-colors-neutral); + --sui-components-link-text-color: var(--sui-colors-link); + --sui-components-link-hover-text-color: var(--sui-colors-link-hover); +} + +.sui-theme-dark { + --sui-colors-primary: #7bb0f0; + --sui-colors-primary-hover: #92bff5; + --sui-colors-neutral: #a8a8a8; + --sui-colors-success: #66c67a; + --sui-colors-warning: #e8ba5a; + --sui-colors-error: #eb7a7a; + --sui-colors-link: #8fb4ff; + --sui-colors-link-hover: #b0c8ff; + --sui-colors-on-primary: #0b1116; + --sui-colors-on-error: #0b1116; +} + +@media (prefers-color-scheme: dark) { + :root:not(.sui-theme-light) { + --sui-colors-primary: #7bb0f0; + --sui-colors-primary-hover: #92bff5; + --sui-colors-neutral: #a8a8a8; + --sui-colors-success: #66c67a; + --sui-colors-warning: #e8ba5a; + --sui-colors-error: #eb7a7a; + --sui-colors-link: #8fb4ff; + --sui-colors-link-hover: #b0c8ff; + --sui-colors-on-primary: #0b1116; + --sui-colors-on-error: #0b1116; + } } diff --git a/src/styles.css b/src/styles.css index 8854960..d11f3f5 100644 --- a/src/styles.css +++ b/src/styles.css @@ -129,20 +129,20 @@ font-size: 0.9rem; } .sui-alert-info { - background: rgba(80, 140, 220, 0.15); - border-left-color: rgb(80, 140, 220); + background: color-mix(in srgb, var(--sui-colors-primary, rgb(80, 140, 220)) 15%, transparent); + border-left-color: var(--sui-colors-primary, rgb(80, 140, 220)); } .sui-alert-success { - background: rgba(80, 180, 100, 0.15); - border-left-color: rgb(80, 180, 100); + background: color-mix(in srgb, var(--sui-colors-success, rgb(80, 180, 100)) 15%, transparent); + border-left-color: var(--sui-colors-success, rgb(80, 180, 100)); } .sui-alert-warning { - background: rgba(220, 170, 70, 0.18); - border-left-color: rgb(220, 170, 70); + background: color-mix(in srgb, var(--sui-colors-warning, rgb(220, 170, 70)) 18%, transparent); + border-left-color: var(--sui-colors-warning, rgb(220, 170, 70)); } .sui-alert-error { - background: rgba(220, 90, 90, 0.18); - border-left-color: rgb(220, 90, 90); + background: color-mix(in srgb, var(--sui-colors-error, rgb(220, 90, 90)) 18%, transparent); + border-left-color: var(--sui-colors-error, rgb(220, 90, 90)); } .sui-badge { @@ -155,16 +155,16 @@ align-self: flex-start; } .sui-badge-success { - background: rgba(80, 180, 100, 0.25); - color: rgb(80, 180, 100); + background: color-mix(in srgb, var(--sui-colors-success, rgb(80, 180, 100)) 25%, transparent); + color: var(--sui-colors-success, rgb(80, 180, 100)); } .sui-badge-warning { - background: rgba(220, 170, 70, 0.25); - color: rgb(220, 170, 70); + background: color-mix(in srgb, var(--sui-colors-warning, rgb(220, 170, 70)) 25%, transparent); + color: var(--sui-colors-warning, rgb(220, 170, 70)); } .sui-badge-error { - background: rgba(220, 90, 90, 0.25); - color: rgb(220, 90, 90); + background: color-mix(in srgb, var(--sui-colors-error, rgb(220, 90, 90)) 25%, transparent); + color: var(--sui-colors-error, rgb(220, 90, 90)); } .sui-spinner { @@ -206,9 +206,10 @@ .sui-progress-fill { height: 100%; - background: rgb(100, 150, 255); + background: var(--sui-colors-primary, rgb(100, 150, 255)); border-radius: 999px; - transition: width 0.3s ease; + transition: width var(--sui-motion-duration-base, 200ms) + var(--sui-motion-easing-standard, cubic-bezier(0.2, 0, 0, 1)); } .sui-progress-label { @@ -269,7 +270,8 @@ .sui-input:focus, .sui-textarea:focus, .sui-select:focus { - outline: 2px solid rgba(100, 150, 255, 0.5); + outline: 2px solid + color-mix(in srgb, var(--sui-colors-primary, rgb(100, 150, 255)) 50%, transparent); outline-offset: -1px; } @@ -312,6 +314,13 @@ border-radius: 0.5rem; cursor: pointer; align-self: flex-start; + transition: + background-color var(--sui-motion-duration-fast, 120ms) + var(--sui-motion-easing-standard, cubic-bezier(0.2, 0, 0, 1)), + border-color var(--sui-motion-duration-fast, 120ms) + var(--sui-motion-easing-standard, cubic-bezier(0.2, 0, 0, 1)), + color var(--sui-motion-duration-fast, 120ms) + var(--sui-motion-easing-standard, cubic-bezier(0.2, 0, 0, 1)); } .sui-button:hover, @@ -350,6 +359,8 @@ color: var(--sui-colors-link, rgb(100, 150, 255)); text-decoration: underline; text-underline-offset: 0.15em; + transition: color var(--sui-motion-duration-fast, 120ms) + var(--sui-motion-easing-standard, cubic-bezier(0.2, 0, 0, 1)); } .sui-link:hover { @@ -360,22 +371,22 @@ .sui-unknown { padding: 0.5rem 0.75rem; - border: 1px dashed rgba(220, 90, 90, 0.5); + border: 1px dashed color-mix(in srgb, var(--sui-colors-error, rgb(220, 90, 90)) 50%, transparent); border-radius: 0.5rem; font-family: ui-monospace, monospace; font-size: 0.8rem; - color: rgb(220, 90, 90); + color: var(--sui-colors-error, rgb(220, 90, 90)); } /* ─── validation errors ────────────────────────────────────────── */ .sui-input[aria-invalid="true"], .sui-textarea[aria-invalid="true"] { - border-color: rgb(220, 90, 90); + border-color: var(--sui-colors-error, rgb(220, 90, 90)); } .sui-input-error { - color: rgb(220, 90, 90); + color: var(--sui-colors-error, rgb(220, 90, 90)); font-size: 0.85em; margin-top: 0.25em; display: block; diff --git a/vitest.config.ts b/vitest.config.ts index 5bf67a1..e43333a 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -4,6 +4,11 @@ export default defineConfig({ root: '.', test: { environment: 'happy-dom', - include: ['src/**/*.test.ts', 'playground/**/*.test.ts', 'api/**/*.test.ts'], + include: [ + 'src/**/*.test.ts', + 'playground/**/*.test.ts', + 'api/**/*.test.ts', + 'scripts/**/*.test.ts', + ], }, })