feat(desktop): embed BoosAPI Desktop client as a subproject

Vendor the rebranded BoosAPI Desktop client (Tauri + Vue) into metapi as
a monorepo subdirectory under desktop/. Source only — node_modules,
dist, Cargo target and the upstream .git history are excluded.

desktop/ keeps its own independent Cargo workspace (desktop/src-tauri
with crates/*); it is not part of the backend boosapi crate, so a
`cargo check` at the metapi root still compiles only the backend. Build
the desktop app from desktop/src-tauri per desktop/README.md.

Note: app icons are still placeholder assets pending the final BoosAPI
logo. The updater signing keys live outside the repo and are not
included.
This commit is contained in:
boos
2026-06-22 11:32:57 +08:00
parent d9c78ecc73
commit e0a5f57793
2305 changed files with 449355 additions and 0 deletions
@@ -0,0 +1,378 @@
#!/usr/bin/env node
/**
* Theme generation CLI.
*
* Usage:
* # Generate from key colors (hex)
* node scripts/theme-gen/cli.mjs generate \
* --name claude \
* --bg "#f5f4ed" --fg "#141413" --brand "#c96442" \
* [--surface "#faf9f5"] [--border "#e8e6dc"] [--link "#0366d6"]
*
* # Convert a single color
* node scripts/theme-gen/cli.mjs convert "#c96442"
*
* # Validate contrast of a generated theme
* node scripts/theme-gen/cli.mjs validate --file themes/claude.css
*
* # Demo: generate themes for several well-known DESIGN.md palettes
* node scripts/theme-gen/cli.mjs demo
*/
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'
import { dirname, resolve } from 'node:path'
import process from 'node:process'
import { fileURLToPath } from 'node:url'
import { hslObjToHex, toHsl, toOklch, toOklchCss, toShadcnHsl } from './color.mjs'
import { generateBothSchemes, validateContrast } from './dark.mjs'
const __dirname = dirname(fileURLToPath(import.meta.url))
/** Token keys that are not color values (font stacks, radius, etc.) — rendered in the shared block */
const NON_COLOR_TOKENS = new Set(['font-sans', 'font-mono', 'font-serif', 'radius'])
// ─── CLI dispatch ───────────────────────────────────────────────────
const [,, command, ...args] = process.argv
const commands = { generate, convert, validate, build }
if (!command || !commands[command]) {
console.log(`
Theme Generator for markstream-vue
Commands:
build Build all themes from registry.json
generate Generate a single theme CSS from key colors
convert Convert a CSS color to shadcn HSL / OKLCH
validate Check WCAG contrast for a theme
Run with --help for per-command usage.
`)
process.exit(0)
}
commands[command](args)
// ─── Commands ───────────────────────────────────────────────────────
function convert(args) {
const color = args[0]
if (!color) {
console.error('Usage: convert <color>\n e.g. convert "#c96442"\n e.g. convert "oklch(0.6 0.15 40)"')
process.exit(1)
}
const hsl = toHsl(color)
const shadcn = toShadcnHsl(color)
const hex = hslObjToHex(hsl)
const oklch = toOklch(color)
const oklchCss = toOklchCss(color)
console.log(`Input: ${color}`)
console.log(`hex: ${hex}`)
console.log(`HSL: h=${hsl.h} s=${hsl.s}% l=${hsl.l}%${hsl.a < 1 ? ` a=${hsl.a}` : ''}`)
console.log(`shadcn: ${shadcn}`)
console.log(`OKLCH: L=${oklch.l} C=${oklch.c} H=${oklch.h}${oklch.a < 1 ? ` a=${oklch.a}` : ''}`)
console.log(`oklch(): ${oklchCss}`)
}
function generate(args) {
const opts = parseArgs(args)
if (opts.help) {
console.log(`
Usage: generate --name <name> --bg <hex> --fg <hex> --brand <hex>
[--surface <hex>] [--border <hex>] [--link <hex>]
[--error <hex>] [--secondary-text <hex>]
[--brand-fg <hex>] [--ring <hex>]
[--font-sans <stack>] [--font-mono <stack>] [--font-serif <stack>]
[--out <dir>] [--format css|json]
`)
process.exit(0)
}
const name = opts.name || 'custom'
const format = opts.format || 'css'
const outDir = opts.out || resolve(__dirname, '../../themes')
if (!opts.bg || !opts.fg || !opts.brand) {
console.error('Error: --bg, --fg, and --brand are required.')
process.exit(1)
}
// Build font overrides if any --font-* flags provided
const fonts = {}
if (opts['font-sans'])
fonts.sans = opts['font-sans']
if (opts['font-mono'])
fonts.mono = opts['font-mono']
if (opts['font-serif'])
fonts.serif = opts['font-serif']
const keyColors = {
background: opts.bg,
foreground: opts.fg,
brand: opts.brand,
...(opts.surface && { surface: opts.surface }),
...(opts.border && { border: opts.border }),
...(opts.link && { link: opts.link }),
...(opts.error && { error: opts.error }),
...(opts['secondary-text'] && { secondaryText: opts['secondary-text'] }),
...(opts['brand-fg'] && { brandForeground: opts['brand-fg'] }),
...(opts.ring && { ring: opts.ring }),
...(Object.keys(fonts).length > 0 && { fonts }),
}
const { light, dark } = generateBothSchemes(keyColors)
// Validate contrast
console.log(`\n── ${name} (light) ──`)
printContrast(validateContrast(light))
console.log(`\n── ${name} (dark) ──`)
printContrast(validateContrast(dark))
// Output
mkdirSync(outDir, { recursive: true })
if (format === 'json') {
const path = resolve(outDir, `${name}.json`)
writeFileSync(path, JSON.stringify({ name, light, dark }, null, 2))
console.log(`\nWritten: ${path}`)
}
else {
const css = renderThemeCss(name, light, dark)
const path = resolve(outDir, `${name}.css`)
writeFileSync(path, css)
console.log(`\nWritten: ${path}`)
}
}
function validate(args) {
const opts = parseArgs(args)
const filePath = opts.file || args[0]
if (!filePath) {
console.error('Usage: validate --file <path-to-theme.css>')
process.exit(1)
}
const css = readFileSync(resolve(filePath), 'utf-8')
const tokens = parseCssTokens(css)
if (tokens.light) {
console.log('\n── Light scheme ──')
printContrast(validateContrast(tokens.light))
}
if (tokens.dark) {
console.log('\n── Dark scheme ──')
printContrast(validateContrast(tokens.dark))
}
}
function build(args) {
const opts = parseArgs(args)
const registryPath = opts.registry || resolve(__dirname, 'registry.json')
const outDir = opts.out || resolve(__dirname, '../../themes')
const filter = opts.only ? opts.only.split(',') : null
const registry = JSON.parse(readFileSync(registryPath, 'utf-8'))
const entries = filter
? registry.filter(e => filter.includes(e.id) || filter.includes(e.category))
: registry
console.log(`Building ${entries.length} themes from registry...\n`)
mkdirSync(outDir, { recursive: true })
let passCount = 0
let warnCount = 0
let failCount = 0
for (const entry of entries) {
const keyColors = {
background: entry.colors.background,
foreground: entry.colors.foreground,
brand: entry.colors.brand,
...(entry.colors.surface && { surface: entry.colors.surface }),
...(entry.colors.secondaryText && { secondaryText: entry.colors.secondaryText }),
...(entry.colors.border && { border: entry.colors.border }),
...(entry.colors.error && { error: entry.colors.error }),
...(entry.colors.link && { link: entry.colors.link }),
...(entry.colors.brandForeground && { brandForeground: entry.colors.brandForeground }),
...(entry.colors.ring && { ring: entry.colors.ring }),
...(entry.colors.info && { info: entry.colors.info }),
...(entry.colors.success && { success: entry.colors.success }),
...(entry.colors.warning && { warning: entry.colors.warning }),
...(entry.colors.highlight && { highlight: entry.colors.highlight }),
...(entry.colors.diffAdded && { diffAdded: entry.colors.diffAdded }),
...(entry.colors.diffRemoved && { diffRemoved: entry.colors.diffRemoved }),
...(entry.fonts && { fonts: entry.fonts }),
...(entry.radius && { radius: entry.radius }),
...(entry.dark && { dark: entry.dark }),
}
const { light, dark } = generateBothSchemes(keyColors)
const lightReport = validateContrast(light)
const darkReport = validateContrast(dark)
const errors = [...lightReport, ...darkReport].filter(r => !r.passAA && r.severity === 'error').length
const warns = [...lightReport, ...darkReport].filter(r => !r.passAA && r.severity === 'warn').length
let status
if (errors > 0) {
status = `✗ ${errors}`
failCount++
}
else if (warns > 0) {
status = `~ ${warns}`
warnCount++
}
else {
status = '✓'
passCount++
}
const css = renderThemeCss(entry.id, light, dark)
const path = resolve(outDir, `${entry.id}.css`)
writeFileSync(path, css)
const cat = (entry.category || '').padEnd(10)
const mode = entry.native === 'dark' ? '◐' : '◑'
console.log(` ${status} ${mode} ${cat} ${entry.id}`)
}
console.log(`\n${passCount} pass, ${warnCount} warn, ${failCount} fail — ${entries.length} themes → themes/`)
// Also generate an index CSS that imports all themes
const indexLines = entries.map(e => `@import './${e.id}.css';`)
writeFileSync(resolve(outDir, 'index.css'), `${indexLines.join('\n')}\n`)
console.log(`Index written → themes/index.css`)
}
// ─── CSS rendering ──────────────────────────────────────────────────
function splitTokens(tokens) {
const colors = {}
const shared = {}
for (const [key, val] of Object.entries(tokens)) {
if (NON_COLOR_TOKENS.has(key))
shared[key] = val
else
colors[key] = val
}
return { colors, shared }
}
function renderThemeCss(name, light, dark) {
const { colors: lightColors, shared } = splitTokens(light)
const { colors: darkColors } = splitTokens(dark)
const lines = []
lines.push(`/**`)
lines.push(` * markstream-vue theme: ${name}`)
lines.push(` * Auto-generated by scripts/theme-gen/cli.mjs`)
lines.push(` */`)
lines.push('')
// Shared tokens (fonts, radius — mode-independent)
if (Object.keys(shared).length > 0) {
lines.push(`/* ── Shared ── */`)
lines.push(`.markstream-vue[data-theme="${name}"] {`)
for (const [key, val] of Object.entries(shared)) {
lines.push(` --ms-${key}: ${val};`)
}
lines.push('}')
lines.push('')
}
lines.push(`/* ── Light ── */`)
lines.push(`.markstream-vue[data-theme="${name}"] {`)
for (const [key, val] of Object.entries(lightColors)) {
lines.push(` --ms-${key}: ${val};`)
}
lines.push('}')
lines.push('')
lines.push(`/* ── Dark ── */`)
lines.push(`.dark .markstream-vue[data-theme="${name}"],`)
lines.push(`.markstream-vue[data-theme="${name}"].dark {`)
for (const [key, val] of Object.entries(darkColors)) {
lines.push(` --ms-${key}: ${val};`)
}
lines.push('}')
lines.push('')
return lines.join('\n')
}
// ─── Helpers ────────────────────────────────────────────────────────
function parseArgs(args) {
const result = {}
for (let i = 0; i < args.length; i++) {
if (args[i].startsWith('--')) {
const key = args[i].slice(2)
const next = args[i + 1]
if (!next || next.startsWith('--')) {
result[key] = true
}
else {
result[key] = next
i++
}
}
else {
result._positional = result._positional || []
result._positional.push(args[i])
}
}
return result
}
/**
* Parse --ms-* tokens from a CSS file (simple regex, not a full parser).
*/
function parseCssTokens(css) {
const result = {}
// Find light block (first rule block)
const lightMatch = css.match(/\.markstream-vue\[data-theme[^\]]+\]\s*\{([^}]+)\}/)
if (lightMatch) {
result.light = extractTokens(lightMatch[1])
}
// Find dark block
const darkMatch = css.match(/\.dark[^{]*\{([^}]+)\}\s*$/)
if (darkMatch) {
result.dark = extractTokens(darkMatch[1])
}
return result
}
function extractTokens(block) {
const tokens = {}
for (const line of block.split('\n')) {
const trimmed = line.trim()
if (!trimmed.startsWith('--ms-'))
continue
const colonIndex = trimmed.indexOf(':')
const semicolonIndex = trimmed.lastIndexOf(';')
if (colonIndex === -1 || semicolonIndex === -1 || semicolonIndex <= colonIndex)
continue
const key = trimmed.slice('--ms-'.length, colonIndex).trim()
const value = trimmed.slice(colonIndex + 1, semicolonIndex).trim()
if (!key || !value)
continue
tokens[key] = value
}
return tokens
}
function printContrast(results) {
for (const r of results) {
const icon = r.passAA ? (r.passAAA ? '◉' : '○') : (r.severity === 'error' ? '✗' : '~')
const ratio = r.ratio.toFixed(1).padStart(5)
const sev = r.severity === 'warn' && !r.passAA ? ' [warn]' : ''
console.log(` ${icon} ${ratio}:1 ${r.pair} (≥${r.threshold})${sev}`)
}
}
@@ -0,0 +1,619 @@
/**
* Color conversion utilities for theme generation.
* Zero dependencies — pure math.
*
* Supported color spaces: sRGB, HSL, OKLCH.
* All public functions accept flexible input (hex, rgba, hsl, oklch string)
* and output either structured objects or shadcn bare format "H S% L%".
*/
// ─── Parsing ────────────────────────────────────────────────────────
/**
* Parse any CSS color string into { r, g, b, a } (0-255 / 0-1).
* Supports: #rgb, #rrggbb, #rrggbbaa, rgb(), rgba(), hsl(), hsla(), oklch().
*/
export function parseColor(str) {
str = str.trim()
// hex
if (str.startsWith('#'))
return parseHex(str)
// rgb / rgba
const rgbArgs = extractFunctionArgs(str, ['rgb', 'rgba'])
if (rgbArgs)
return parseRgbArgs(rgbArgs)
// hsl / hsla → convert to rgb internally
const hslArgs = extractFunctionArgs(str, ['hsl', 'hsla'])
if (hslArgs)
return parseHslArgs(hslArgs)
// oklch → convert to rgb via OKLab → linear RGB → sRGB
const oklchArgs = extractFunctionArgs(str, ['oklch'])
if (oklchArgs)
return parseOklchArgs(oklchArgs)
throw new Error(`Unsupported color format: "${str}"`)
}
function extractFunctionArgs(str, names) {
const lower = str.toLowerCase()
for (const name of names) {
const prefix = `${name}(`
if (lower.startsWith(prefix) && str.endsWith(')'))
return str.slice(prefix.length, -1).trim()
}
return null
}
function parseHex(hex) {
hex = hex.replace('#', '')
if (hex.length === 3)
hex = hex[0] + hex[0] + hex[1] + hex[1] + hex[2] + hex[2]
if (hex.length === 4)
hex = hex[0] + hex[0] + hex[1] + hex[1] + hex[2] + hex[2] + hex[3] + hex[3]
const r = Number.parseInt(hex.slice(0, 2), 16)
const g = Number.parseInt(hex.slice(2, 4), 16)
const b = Number.parseInt(hex.slice(4, 6), 16)
const a = hex.length === 8 ? Number.parseInt(hex.slice(6, 8), 16) / 255 : 1
return { r, g, b, a }
}
function parseRgbArgs(args) {
// Handles both comma-separated and space-separated (with optional / alpha)
// Channels can be 0-255 or 0%-100% (100% = 255)
const parts = args.split(/[\s,/]+/).map(s => s.trim()).filter(Boolean)
const r = parseRgbChannel(parts[0])
const g = parseRgbChannel(parts[1])
const b = parseRgbChannel(parts[2])
const a = parts[3] !== undefined ? parseAlpha(parts[3]) : 1
return { r, g, b, a }
}
function parseRgbChannel(v) {
if (v.endsWith('%'))
return Math.round(Number.parseFloat(v) / 100 * 255)
return Number.parseFloat(v)
}
function parseHslArgs(args) {
// H: angle — deg (default), rad, grad, turn
// S, L: percentage (the % sign is required per spec but we tolerate bare numbers)
const parts = args.split(/[\s,/]+/).map(s => s.trim()).filter(Boolean)
let h = Number.parseFloat(parts[0])
h = parseAngle(parts[0], h)
const s = Number.parseFloat(parts[1]) / 100
const l = Number.parseFloat(parts[2]) / 100
const a = parts[3] !== undefined ? parseAlpha(parts[3]) : 1
const { r, g, b } = hslToRgbRaw(h, s, l)
return { r, g, b, a }
}
function parseOklchArgs(args) {
// CSS oklch: oklch(L C H) or oklch(L C H / alpha)
// L: 0-1 or 0%-100% (100% = 1)
// C: 0-0.4 or 0%-100% (100% = 0.4, per CSS Color 4)
// H: angle — deg (default), rad, grad, turn
const parts = args.split(/[\s,/]+/).map(s => s.trim()).filter(Boolean)
let l = Number.parseFloat(parts[0])
if (parts[0].endsWith('%'))
l /= 100
l = Math.min(Math.max(l, 0), 1)
let c = Number.parseFloat(parts[1])
if (parts[1]?.endsWith('%'))
c = c / 100 * 0.4 // CSS Color 4: 100% chroma = 0.4
let h = Number.parseFloat(parts[2]) || 0
h = parseAngle(parts[2], h)
const a = parts[3] !== undefined ? parseAlpha(parts[3]) : 1
const { r, g, b } = oklchToSrgb(l, c, h)
return { r, g, b, a }
}
function parseAlpha(v) {
if (v.endsWith('%'))
return Number.parseFloat(v) / 100
return Number.parseFloat(v)
}
/**
* Parse CSS angle units. Returns degrees.
* Supports: deg (default), rad, grad, turn.
* Note: 'grad' must be checked before 'rad' since 'grad'.endsWith('rad') === true.
*/
function parseAngle(raw, numericValue) {
if (!raw)
return numericValue
if (raw.endsWith('grad'))
return numericValue * 0.9
if (raw.endsWith('rad'))
return numericValue * 180 / Math.PI
if (raw.endsWith('turn'))
return numericValue * 360
// 'deg' suffix or bare number — already in degrees
return numericValue
}
// ─── OKLCH ↔ sRGB conversion chain ─────────────────────────────────
//
// OKLCH ↔ OKLab ↔ LMS (cube) ↔ Linear RGB ↔ sRGB (gamma)
//
// Reference: Björn Ottosson — https://bottosson.github.io/posts/oklab/
/**
* OKLCH → sRGB { r, g, b } (0-255).
* Uses CSS Color 4 gamut mapping: binary-search chroma reduction in OKLCH
* to preserve lightness and hue. Falls back to simple clamp only for
* achromatic colors or near-boundary values.
*
* @param {number} L Lightness 0-1
* @param {number} C Chroma ≥ 0
* @param {number} H Hue 0-360
*/
function oklchToSrgb(L, C, H) {
// Fast path: achromatic or no chroma
if (C <= 0 || Number.isNaN(H))
return oklabToLinearSrgb_gammaEncode(L, 0, 0)
// Try direct conversion first
const direct = oklchToLinearSrgbChannels(L, C, H)
if (isInGamut(direct))
return gammaEncodeRgb(direct)
// CSS Color 4 gamut mapping: binary search on chroma, keep L and H fixed.
// Tolerance: ΔE_ok < 0.002 (spec uses 0.02 in deltaEOK, ≈ 0.002 in raw OKLab)
const EPSILON = 0.001
let lo = 0
let hi = C
let mapped = direct
while (hi - lo > EPSILON) {
const mid = (lo + hi) / 2
mapped = oklchToLinearSrgbChannels(L, mid, H)
if (isInGamut(mapped))
lo = mid
else
hi = mid
}
// Use the last in-gamut result (lo), clamp rounding errors
mapped = oklchToLinearSrgbChannels(L, lo, H)
return gammaEncodeRgb(mapped)
}
/** Check if linear RGB channels are within [0, 1] gamut (with tiny tolerance). */
function isInGamut({ lr, lg, lb }) {
const E = 0.000075 // tolerance for floating point
return lr >= -E && lr <= 1 + E
&& lg >= -E && lg <= 1 + E
&& lb >= -E && lb <= 1 + E
}
/** OKLCH → raw linear sRGB channels (may be out of gamut). */
function oklchToLinearSrgbChannels(L, C, H) {
const hRad = H * Math.PI / 180
const a = C * Math.cos(hRad)
const b = C * Math.sin(hRad)
// OKLab → LMS (cube root form)
const l_ = L + 0.3963377774 * a + 0.2158037573 * b
const m_ = L - 0.1055613458 * a - 0.0638541728 * b
const s_ = L - 0.0894841775 * a - 1.2914855480 * b
const l = l_ * l_ * l_
const m = m_ * m_ * m_
const s = s_ * s_ * s_
return {
lr: +4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s,
lg: -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s,
lb: -0.0041960863 * l - 0.7034186147 * m + 1.7076147010 * s,
}
}
/** Gamma-encode and clamp linear RGB channels → sRGB { r, g, b } 0-255. */
function gammaEncodeRgb({ lr, lg, lb }) {
return {
r: clamp(Math.round(linearToGamma(clamp(lr, 0, 1)) * 255), 0, 255),
g: clamp(Math.round(linearToGamma(clamp(lg, 0, 1)) * 255), 0, 255),
b: clamp(Math.round(linearToGamma(clamp(lb, 0, 1)) * 255), 0, 255),
}
}
/**
* OKLab → sRGB { r, g, b } (0-255). Direct conversion with gamma encode + clamp.
* Used internally for achromatic fast path.
*/
function oklabToLinearSrgb_gammaEncode(L) {
// For achromatic, a=0 b=0 so we just need L → LMS → linear RGB
const l_ = L
const m_ = L
const s_ = L
const l = l_ * l_ * l_
const m = m_ * m_ * m_
const s = s_ * s_ * s_
return gammaEncodeRgb({
lr: +4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s,
lg: -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s,
lb: -0.0041960863 * l - 0.7034186147 * m + 1.7076147010 * s,
})
}
/**
* sRGB { r, g, b } (0-255) → OKLab { L, a, b }.
*/
function srgbToOklab(r, g, b) {
// sRGB → Linear sRGB
const lr = gammaToLinear(r / 255)
const lg = gammaToLinear(g / 255)
const lb = gammaToLinear(b / 255)
// Linear sRGB → LMS
const l = 0.4122214708 * lr + 0.5363325363 * lg + 0.0514459929 * lb
const m = 0.2119034982 * lr + 0.6806995451 * lg + 0.1073969566 * lb
const s = 0.0883024619 * lr + 0.2817188376 * lg + 0.6299787005 * lb
// Cube root
const l_ = Math.cbrt(l)
const m_ = Math.cbrt(m)
const s_ = Math.cbrt(s)
// LMS → OKLab
return {
L: 0.2104542553 * l_ + 0.7936177850 * m_ - 0.0040720468 * s_,
a: 1.9779984951 * l_ - 2.4285922050 * m_ + 0.4505937099 * s_,
b: 0.0259040371 * l_ + 0.7827717662 * m_ - 0.8086757660 * s_,
}
}
/**
* sRGB { r, g, b } (0-255) → OKLCH { l: 0-1, c: ≥0, h: 0-360 }.
*/
export function rgbToOklch(r, g, b) {
const { L, a, b: b_ } = srgbToOklab(r, g, b)
const c = Math.sqrt(a * a + b_ * b_)
let h = Math.atan2(b_, a) * 180 / Math.PI
if (h < 0)
h += 360
return {
l: round(L, 4),
c: round(c, 4),
h: round(h, 2),
}
}
/**
* OKLCH { l, c, h } → sRGB { r, g, b } (0-255).
*/
export function oklchToRgb(l, c, h) {
return oklchToSrgb(l, c, h)
}
/** sRGB gamma encode (linear → gamma). */
function linearToGamma(x) {
if (x <= 0)
return 0
if (x >= 1)
return 1
return x <= 0.0031308
? 12.92 * x
: 1.055 * (x ** (1 / 2.4)) - 0.055
}
/** sRGB gamma decode (gamma → linear). */
function gammaToLinear(x) {
if (x <= 0)
return 0
if (x >= 1)
return 1
return x <= 0.04045
? x / 12.92
: ((x + 0.055) / 1.055) ** 2.4
}
// ─── Conversions ────────────────────────────────────────────────────
/**
* RGB (0-255) → HSL { h: 0-360, s: 0-100, l: 0-100 }
*/
export function rgbToHsl(r, g, b) {
r /= 255
g /= 255
b /= 255
const max = Math.max(r, g, b)
const min = Math.min(r, g, b)
const d = max - min
const l = (max + min) / 2
let h = 0
let s = 0
if (d !== 0) {
s = l > 0.5 ? d / (2 - max - min) : d / (max + min)
switch (max) {
case r:
h = ((g - b) / d + (g < b ? 6 : 0)) / 6
break
case g:
h = ((b - r) / d + 2) / 6
break
case b:
h = ((r - g) / d + 4) / 6
break
}
}
return {
h: round(h * 360, 1),
s: round(s * 100, 1),
l: round(l * 100, 1),
}
}
/**
* HSL (h: 0-360, s: 0-1, l: 0-1) → RGB { r, g, b } (0-255)
*/
function hslToRgbRaw(h, s, l) {
h = ((h % 360) + 360) % 360
const c = (1 - Math.abs(2 * l - 1)) * s
const x = c * (1 - Math.abs((h / 60) % 2 - 1))
const m = l - c / 2
let r, g, b
if (h < 60)
[r, g, b] = [c, x, 0]
else if (h < 120)
[r, g, b] = [x, c, 0]
else if (h < 180)
[r, g, b] = [0, c, x]
else if (h < 240)
[r, g, b] = [0, x, c]
else if (h < 300)
[r, g, b] = [x, 0, c]
else [r, g, b] = [c, 0, x]
return {
r: Math.round((r + m) * 255),
g: Math.round((g + m) * 255),
b: Math.round((b + m) * 255),
}
}
export function hslToRgb(h, s, l) {
return hslToRgbRaw(h, s / 100, l / 100)
}
/**
* HSL → hex string "#rrggbb"
*/
export function hslToHex(h, s, l) {
const { r, g, b } = hslToRgb(h, s, l)
return `#${[r, g, b].map(c => c.toString(16).padStart(2, '0')).join('')}`
}
// ─── Format helpers ─────────────────────────────────────────────────
/**
* Any CSS color string → shadcn bare HSL format "H S% L%"
* e.g. "222.2 84% 4.9%"
*/
export function toShadcnHsl(color) {
const { r, g, b } = parseColor(color)
const { h, s, l } = rgbToHsl(r, g, b)
return `${h} ${s}% ${l}%`
}
/**
* Any CSS color string → { h, s, l, a } (h: 0-360, s: 0-100, l: 0-100)
*/
export function toHsl(color) {
const { r, g, b, a } = parseColor(color)
const { h, s, l } = rgbToHsl(r, g, b)
return { h, s, l, a }
}
/**
* HSL object → shadcn bare format "H S% L%"
*/
export function hslToShadcn({ h, s, l }) {
return `${round(h, 1)} ${round(s, 1)}% ${round(l, 1)}%`
}
/**
* HSL object → hex "#rrggbb"
*/
export function hslObjToHex({ h, s, l }) {
return hslToHex(h, s, l)
}
// ─── OKLCH format helpers ───────────────────────────────────────────
/**
* Any CSS color string → OKLCH { l: 0-1, c: ≥0, h: 0-360, a: 0-1 }
*/
export function toOklch(color) {
const { r, g, b, a } = parseColor(color)
const oklch = rgbToOklch(r, g, b)
return { ...oklch, a }
}
/**
* Any CSS color string → CSS oklch() string "oklch(L C H)"
*/
export function toOklchCss(color) {
const { l, c, h, a } = toOklch(color)
const base = `oklch(${round(l, 4)} ${round(c, 4)} ${round(h, 2)})`
if (a < 1)
return `oklch(${round(l, 4)} ${round(c, 4)} ${round(h, 2)} / ${round(a, 3)})`
return base
}
/**
* OKLCH object → CSS oklch() string
*/
export function oklchToCss({ l, c, h, a }) {
if (a !== undefined && a < 1)
return `oklch(${round(l, 4)} ${round(c, 4)} ${round(h, 2)} / ${round(a, 3)})`
return `oklch(${round(l, 4)} ${round(c, 4)} ${round(h, 2)})`
}
/**
* OKLCH object → hex "#rrggbb"
*/
export function oklchToHex({ l, c, h }) {
const { r, g, b } = oklchToRgb(l, c, h)
return `#${[r, g, b].map(v => v.toString(16).padStart(2, '0')).join('')}`
}
/**
* HSL object { h, s, l } → OKLCH { l, c, h }
*/
export function hslToOklch({ h, s, l }) {
const { r, g, b } = hslToRgb(h, s, l)
return rgbToOklch(r, g, b)
}
/**
* OKLCH { l, c, h } → HSL { h, s, l } (all 0-360/0-100 range)
*/
export function oklchToHsl({ l, c, h }) {
const { r, g, b } = oklchToRgb(l, c, h)
return rgbToHsl(r, g, b)
}
// ─── Color manipulation ─────────────────────────────────────────────
/**
* Adjust lightness: positive = lighter, negative = darker.
* Clamps 0-100.
*/
export function adjustLightness(hsl, amount) {
return { ...hsl, l: clamp(hsl.l + amount, 0, 100) }
}
/**
* Adjust saturation: positive = more saturated, negative = less.
* Clamps 0-100.
*/
export function adjustSaturation(hsl, amount) {
return { ...hsl, s: clamp(hsl.s + amount, 0, 100) }
}
/**
* Mix two HSL colors by ratio (0 = all colorA, 1 = all colorB).
*/
export function mixHsl(a, b, ratio = 0.5) {
return {
h: lerpHue(a.h, b.h, ratio),
s: lerp(a.s, b.s, ratio),
l: lerp(a.l, b.l, ratio),
a: lerp(a.a ?? 1, b.a ?? 1, ratio),
}
}
/**
* Mix two colors in OKLCH space (perceptually uniform).
* Inputs/outputs are OKLCH { l, c, h }.
*/
export function mixOklch(a, b, ratio = 0.5) {
return {
l: lerp(a.l, b.l, ratio),
c: lerp(a.c, b.c, ratio),
h: lerpHue(a.h, b.h, ratio),
a: lerp(a.a ?? 1, b.a ?? 1, ratio),
}
}
/**
* Adjust OKLCH lightness: positive = lighter, negative = darker.
* Clamps 0-1.
*/
export function adjustOklchLightness(oklch, amount) {
return { ...oklch, l: clamp(oklch.l + amount, 0, 1) }
}
/**
* Adjust OKLCH chroma: positive = more vivid, negative = more muted.
* Clamps ≥ 0.
*/
export function adjustOklchChroma(oklch, amount) {
return { ...oklch, c: Math.max(0, oklch.c + amount) }
}
// ─── WCAG Contrast ──────────────────────────────────────────────────
/**
* Relative luminance from HSL (WCAG 2.1 definition).
*/
export function luminance(hsl) {
const { r, g, b } = hslToRgb(hsl.h, hsl.s, hsl.l)
const [rs, gs, bs] = [r, g, b].map((c) => {
c /= 255
return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4
})
return 0.2126 * rs + 0.7152 * gs + 0.0722 * bs
}
/**
* WCAG contrast ratio between two HSL colors. Returns 1–21.
*/
export function contrastRatio(a, b) {
const la = luminance(a)
const lb = luminance(b)
const lighter = Math.max(la, lb)
const darker = Math.min(la, lb)
return (lighter + 0.05) / (darker + 0.05)
}
/**
* Check if contrast meets WCAG AA (4.5:1 for normal text, 3:1 for large text).
*/
export function meetsAA(fg, bg, largeText = false) {
return contrastRatio(fg, bg) >= (largeText ? 3 : 4.5)
}
/**
* Check if contrast meets WCAG AAA (7:1 for normal text, 4.5:1 for large text).
*/
export function meetsAAA(fg, bg, largeText = false) {
return contrastRatio(fg, bg) >= (largeText ? 4.5 : 7)
}
// ─── Internal helpers ───────────────────────────────────────────────
function round(n, decimals = 1) {
const f = 10 ** decimals
return Math.round(n * f) / f
}
function clamp(v, min, max) {
return Math.min(max, Math.max(min, v))
}
function lerp(a, b, t) {
return a + (b - a) * t
}
/**
* Interpolate hue on the shortest arc.
*/
function lerpHue(a, b, t) {
let diff = b - a
if (diff > 180)
diff -= 360
if (diff < -180)
diff += 360
return ((a + diff * t) % 360 + 360) % 360
}
@@ -0,0 +1,338 @@
/**
* Dark / light scheme generator.
*
* Takes a palette token map (from palette.mjs) and produces the opposite
* scheme. Preserves hue identity, adjusts lightness/saturation, and
* validates WCAG contrast.
*
* Strategy:
* Light → Dark: backgrounds go very dark, text goes light, brand adjusts for contrast
* Dark → Light: backgrounds go very light, text goes dark, brand adjusts for contrast
*/
import {
adjustLightness,
contrastRatio,
hslToRgb,
meetsAA,
rgbToHsl,
toHsl,
} from './color.mjs'
import { generatePalette } from './palette.mjs'
// ─── Main entry ─────────────────────────────────────────────────────
/**
* Given a token map (Record<string, "H S% L%">), generate the opposite scheme.
*
* @param {Record<string, string>} tokens - Token map from generatePalette()
* @param {'light'|'dark'} sourceMode - What mode the input tokens represent
* @returns {Record<string, string>} Token map for the opposite mode
*/
export function generateOppositeScheme(tokens, sourceMode) {
const bg = parseShadcnHsl(tokens.background)
const fg = parseShadcnHsl(tokens.foreground)
const brand = parseShadcnHsl(tokens.primary)
const targetIsLight = sourceMode === 'dark'
// Core strategy: flip lightness around the midpoint, keep hue + saturation close
const newBg = flipForBackground(bg, targetIsLight)
const newFg = flipForForeground(fg, targetIsLight, newBg)
const newBrand = adjustBrandForScheme(brand, targetIsLight, newBg)
// Generate full palette from derived key colors.
// Signal & content tokens are not passed — the opposite scheme
// only emits them if the source registry provided them.
return generatePalette({
background: hslToHex(newBg),
foreground: hslToHex(newFg),
brand: hslToHex(newBrand),
})
}
/**
* Generate both light and dark from any single scheme.
*
* @param {object} keyColors - { background, foreground, brand, ... }
* @returns {{ light: Record<string, string>, dark: Record<string, string> }}
*/
export function generateBothSchemes(keyColors) {
const bg = toHsl(keyColors.background)
const sourceIsLight = bg.l > 50
const primary = generatePalette(keyColors)
let opposite
if (keyColors.dark && keyColors.dark.background && sourceIsLight) {
// Explicit dark palette provided — use directly instead of algorithmic flip.
// Missing fields fall back to light-side values.
opposite = generatePalette({
background: keyColors.dark.background,
foreground: keyColors.dark.foreground || keyColors.foreground,
brand: keyColors.dark.brand || keyColors.brand,
...(keyColors.dark.surface && { surface: keyColors.dark.surface }),
...((keyColors.dark.secondaryText || keyColors.secondaryText) && {
secondaryText: keyColors.dark.secondaryText || keyColors.secondaryText,
}),
...((keyColors.dark.border) && { border: keyColors.dark.border }),
...((keyColors.dark.error || keyColors.error) && {
error: keyColors.dark.error || keyColors.error,
}),
...((keyColors.dark.link || keyColors.link) && {
link: keyColors.dark.link || keyColors.link,
}),
...((keyColors.dark.brandForeground || keyColors.brandForeground) && {
brandForeground: keyColors.dark.brandForeground || keyColors.brandForeground,
}),
...((keyColors.dark.ring) && { ring: keyColors.dark.ring }),
...((keyColors.dark.info || keyColors.info) && {
info: keyColors.dark.info || keyColors.info,
}),
...((keyColors.dark.success || keyColors.success) && {
success: keyColors.dark.success || keyColors.success,
}),
...((keyColors.dark.warning || keyColors.warning) && {
warning: keyColors.dark.warning || keyColors.warning,
}),
...((keyColors.dark.highlight || keyColors.highlight) && {
highlight: keyColors.dark.highlight || keyColors.highlight,
}),
...((keyColors.dark.diffAdded || keyColors.diffAdded) && {
diffAdded: keyColors.dark.diffAdded || keyColors.diffAdded,
}),
...((keyColors.dark.diffRemoved || keyColors.diffRemoved) && {
diffRemoved: keyColors.dark.diffRemoved || keyColors.diffRemoved,
}),
...(keyColors.fonts && { fonts: keyColors.fonts }),
})
}
else {
opposite = generateOppositeScheme(primary, sourceIsLight ? 'light' : 'dark')
}
// Shared tokens are mode-independent — copy from primary to opposite
for (const key of ['font-sans', 'font-mono', 'font-serif', 'radius']) {
if (primary[key] && !opposite[key])
opposite[key] = primary[key]
}
return sourceIsLight
? { light: primary, dark: opposite }
: { light: opposite, dark: primary }
}
// ─── Lightness flipping ─────────────────────────────────────────────
/**
* Flip background lightness for the target scheme.
*/
function flipForBackground(bg, targetIsLight) {
if (targetIsLight) {
// Dark → Light: target L around 97-100, keep hue, reduce saturation slightly
return {
h: bg.h,
s: Math.max(bg.s * 0.6, 0),
l: mapRange(bg.l, 0, 20, 96, 100),
}
}
else {
// Light → Dark: target L around 4-10, keep hue, reduce saturation
return {
h: bg.h,
s: Math.max(bg.s * 0.8, 0),
l: mapRange(bg.l, 90, 100, 4, 10),
}
}
}
/**
* Flip foreground lightness for the target scheme.
*/
function flipForForeground(fg, targetIsLight, newBg) {
let newFg
if (targetIsLight) {
// Dark → Light: target L around 5-15
newFg = {
h: fg.h,
s: Math.min(fg.s * 1.3, 100),
l: mapRange(fg.l, 85, 100, 4, 12),
}
}
else {
// Light → Dark: target L around 90-98
newFg = {
h: fg.h,
s: Math.max(fg.s * 0.7, 0),
l: mapRange(fg.l, 0, 15, 92, 98),
}
}
// Ensure sufficient contrast
if (!meetsAA(newFg, newBg)) {
newFg = targetIsLight
? adjustLightness(newFg, -10)
: adjustLightness(newFg, 10)
}
return newFg
}
/**
* Adjust brand color for the target scheme — preserve identity, ensure contrast.
*/
function adjustBrandForScheme(brand, targetIsLight, newBg) {
let adjusted = { ...brand }
if (targetIsLight) {
// For light scheme, brand may need to be darker for contrast
if (adjusted.l > 60)
adjusted.l = 45
}
else {
// For dark scheme, brand may need to be lighter for visibility
if (adjusted.l < 40)
adjusted.l = 55
}
// Fine-tune for contrast against background
const cr = contrastRatio(adjusted, newBg)
if (cr < 3) {
adjusted = targetIsLight
? adjustLightness(adjusted, -15)
: adjustLightness(adjusted, 15)
}
return adjusted
}
// ─── Contrast validation report ─────────────────────────────────────
/**
* Validate a token map for WCAG contrast on all critical pairs.
*
* Each check has a severity:
* 'error' — text readability, will cause real accessibility issues
* 'warn' — best-practice, may not cause issues if supplemented (hover, underline)
*
* Returns an array of { pair, ratio, passAA, threshold, severity } objects.
*/
export function validateContrast(tokens) {
// [fgKey, bgKey, label, threshold, severity]
const pairs = [
// Hard: text readability
['foreground', 'background', 'Primary text', 4.5, 'error'],
['muted-foreground', 'background', 'Secondary text', 4.5, 'error'],
['primary-foreground', 'primary', 'Button text', 4.5, 'error'],
['accent-foreground', 'accent', 'Accent text', 4.5, 'error'],
['destructive-foreground', 'destructive', 'Error text', 4.5, 'error'],
['popover-foreground', 'popover', 'Popover text', 4.5, 'error'],
['link', 'background', 'Link on background', 4.5, 'error'],
// Soft: supplementary / non-text
['link', 'foreground', 'Link vs body text', 3, 'warn'],
['border', 'background', 'Border visibility', 3, 'warn'],
]
const bgL = parseShadcnHsl(tokens.background).l
const isLight = bgL > 50
// Semantic colors as text on REAL admonition header background:
// alphaBlend(semanticColor, muted, alpha) where alpha = 0.06 (light) / 0.12 (dark)
if (tokens.muted) {
const muted = parseShadcnHsl(tokens.muted)
const alpha = isLight ? 0.06 : 0.12
for (const key of ['info', 'success', 'warning']) {
if (!tokens[key])
continue
const semColor = parseShadcnHsl(tokens[key])
const headerBg = alphaBlendHsl(semColor, muted, alpha)
const ratio = contrastRatio(semColor, headerBg)
pairs.push([key, null, `${key} on header bg`, 4.5, 'error', ratio, headerBg])
}
}
return pairs.map(([fgKey, bgKey, label, threshold, severity, preRatio]) => {
let ratio
if (preRatio !== undefined) {
ratio = preRatio
}
else {
const fg = parseShadcnHsl(tokens[fgKey])
const bg = parseShadcnHsl(tokens[bgKey])
ratio = contrastRatio(fg, bg)
}
return {
pair: label,
fg: fgKey,
bg: bgKey,
ratio: Math.round(ratio * 100) / 100,
passAA: ratio >= threshold,
passAAA: ratio >= (threshold === 3 ? 4.5 : 7),
threshold,
severity,
}
})
}
// ─── Helpers ────────────────────────────────────────────────────────
/** Alpha-blend fg over bg in RGB space (same as CSS alpha compositing). */
function alphaBlendHsl(fg, bg, alpha) {
const f = hslToRgb(fg.h, fg.s, fg.l)
const b = hslToRgb(bg.h, bg.s, bg.l)
return rgbToHsl(
Math.round(f.r * alpha + b.r * (1 - alpha)),
Math.round(f.g * alpha + b.g * (1 - alpha)),
Math.round(f.b * alpha + b.b * (1 - alpha)),
)
}
/**
* Parse shadcn bare HSL "H S% L%" back to { h, s, l }.
*/
function parseShadcnHsl(str) {
const parts = str.match(/[\d.]+/g)
if (!parts || parts.length < 3)
throw new Error(`Invalid shadcn HSL: "${str}"`)
return {
h: Number.parseFloat(parts[0]),
s: Number.parseFloat(parts[1]),
l: Number.parseFloat(parts[2]),
}
}
/**
* HSL object → hex (for feeding back to generatePalette).
*/
function hslToHex({ h, s, l }) {
// Import would create circular dep, inline the conversion
s /= 100
l /= 100
const c = (1 - Math.abs(2 * l - 1)) * s
const x = c * (1 - Math.abs((h / 60) % 2 - 1))
const m = l - c / 2
let r, g, b
if (h < 60)
[r, g, b] = [c, x, 0]
else if (h < 120)
[r, g, b] = [x, c, 0]
else if (h < 180)
[r, g, b] = [0, c, x]
else if (h < 240)
[r, g, b] = [0, x, c]
else if (h < 300)
[r, g, b] = [x, 0, c]
else [r, g, b] = [c, 0, x]
const toHex = v => Math.round((v + m) * 255).toString(16).padStart(2, '0')
return `#${toHex(r)}${toHex(g)}${toHex(b)}`
}
/**
* Map a value from one range to another, clamped.
*/
function mapRange(value, inMin, inMax, outMin, outMax) {
const clamped = Math.min(Math.max(value, inMin), inMax)
const ratio = (clamped - inMin) / (inMax - inMin)
return outMin + ratio * (outMax - outMin)
}
@@ -0,0 +1,318 @@
#!/usr/bin/env node
/**
* Extract expanded registry fields from awesome-design-md preview HTML files.
*
* Reads preview.html and preview-dark.html for each theme to extract:
* - dark section (background, foreground, surface, secondaryText, border)
* - error color
* - brandForeground (CTA button text)
* - ring color
*
* Usage:
* node scripts/theme-gen/extract-registry.mjs --source ../awesome-design-md/design-md
* node scripts/theme-gen/extract-registry.mjs --source ../awesome-design-md/design-md --only claude
* node scripts/theme-gen/extract-registry.mjs --source ../awesome-design-md/design-md --dry-run
*/
import { readFileSync, writeFileSync } from 'node:fs'
import { dirname, resolve } from 'node:path'
import process from 'node:process'
import { fileURLToPath } from 'node:url'
import { contrastRatio, toHsl } from './color.mjs'
const __dirname = dirname(fileURLToPath(import.meta.url))
// ─── CLI args ───────────────────────────────────────────────────────
const args = process.argv.slice(2)
const opts = {}
for (let i = 0; i < args.length; i++) {
if (args[i].startsWith('--')) {
const key = args[i].slice(2)
const next = args[i + 1]
if (!next || next.startsWith('--')) {
opts[key] = true
}
else {
opts[key] = next
i++
}
}
}
const sourceDir = opts.source || resolve(__dirname, '../../../awesome-design-md/design-md')
const registryPath = resolve(__dirname, 'registry.json')
const dryRun = opts['dry-run'] || false
const onlyId = opts.only || null
// ─── Parse CSS variables from HTML ──────────────────────────────────
function extractCssVars(html) {
const vars = {}
for (const declaration of html.split(';')) {
const tokenStart = declaration.lastIndexOf('--')
if (tokenStart === -1)
continue
const chunk = declaration.slice(tokenStart).trim()
const colonIndex = chunk.indexOf(':')
if (colonIndex === -1)
continue
const key = chunk.slice(2, colonIndex).trim()
const val = chunk.slice(colonIndex + 1).trim()
if (!key || !val)
continue
vars[key] = val
}
return vars
}
/**
* Check if a CSS color value is opaque (no alpha channel < 1).
*/
function isOpaque(val) {
if (val.startsWith('#')) {
// #rrggbbaa — check if alpha < ff
if (val.length === 9)
return val.slice(7).toLowerCase() === 'ff'
if (val.length === 5)
return val[4].toLowerCase() === 'f'
return true
}
const alphaValue = extractFunctionalAlpha(val)
if (alphaValue != null) {
const a = Number.parseFloat(alphaValue)
return a >= 0.9
}
return true
}
function extractFunctionalAlpha(val) {
const openIndex = val.indexOf('(')
const closeIndex = val.lastIndexOf(')')
if (openIndex === -1 || closeIndex === -1 || closeIndex <= openIndex)
return null
const inner = val.slice(openIndex + 1, closeIndex).trim()
if (!inner)
return null
if (inner.includes('/'))
return inner.split('/').pop()?.trim() ?? null
const parts = inner.split(',').map(part => part.trim()).filter(Boolean)
return parts.length === 4 ? parts[3] : null
}
/**
* Find a color value by trying multiple candidate variable names.
* Only returns opaque solid colors (skips rgba with alpha < 0.9).
*/
function findColor(vars, ...candidates) {
for (const name of candidates) {
const val = vars[name]
if (!val)
continue
if ((val.startsWith('#') || val.startsWith('rgb') || val.startsWith('hsl')) && isOpaque(val))
return val
}
return null
}
/**
* Extract CTA button text color from inline styles in HTML.
* Looks for buttons with the brand background color and extracts their text color.
*/
function findBrandForeground(html, brandColor) {
if (!brandColor)
return null
const brandHex = brandColor.toLowerCase()
// Look for button/CTA styles with brand background
// Pattern: background: <brand>; color: <fg>;
const brandBgRe = new RegExp(
`style="[^"]*background:\\s*(?:var\\(--[^)]*\\)|${brandHex.replace('#', '#?')})[^"]*color:\\s*(#[\\da-f]{3,8})`,
'gi',
)
// Try matching brand background specifically
const brandBgMatch = brandBgRe.exec(html)
if (brandBgMatch)
return brandBgMatch[1]
// Fallback: look for nav-cta or btn-brand class button colors
const ctaRe = /\.(?:nav-cta|btn-brand|btn-primary)\s*\{[^}]*color:\s*(?:var\(--([\w-]+)\)|(#[\da-f]{3,8}))/gi
let m
while ((m = ctaRe.exec(html)) !== null) {
if (m[2])
return m[2]
if (m[1]) {
const vars = extractCssVars(html)
const resolved = vars[m[1]]
if (resolved && resolved.startsWith('#'))
return resolved
}
}
return null
}
/**
* Extract ring/focus color from CSS variables.
*/
function findRing(vars) {
// Try specific ring variables
const ring = findColor(vars, 'color-ring-warm', 'color-ring', 'ring-warm', 'ring', 'shadow-ring-color', 'focus-ring')
if (ring)
return ring
// Try parsing ring from shadow-ring value: "rgba(...) 0px 0px 0px 1px" or "color 0px 0px 0px 1px"
for (const key of ['shadow-ring', 'shadow-ring-light']) {
const val = vars[key]
if (!val)
continue
const hexMatch = val.match(/(#[\da-f]{3,8})/i)
if (hexMatch && isOpaque(hexMatch[1]))
return hexMatch[1]
const rgbMatch = val.match(/(rgb\([^)]+\))/)
if (rgbMatch && isOpaque(rgbMatch[1]))
return rgbMatch[1]
// Skip rgba with alpha — those are overlays, not solid ring colors
}
return null
}
// ─── Main ───────────────────────────────────────────────────────────
const registry = JSON.parse(readFileSync(registryPath, 'utf-8'))
let updated = 0
let skipped = 0
for (const entry of registry) {
if (onlyId && entry.id !== onlyId)
continue
// Skip Claude — already manually updated
if (entry.id === 'claude') {
skipped++
continue
}
const themeDir = resolve(sourceDir, entry.source)
let lightHtml, darkHtml
try {
lightHtml = readFileSync(resolve(themeDir, 'preview.html'), 'utf-8')
darkHtml = readFileSync(resolve(themeDir, 'preview-dark.html'), 'utf-8')
}
catch {
console.log(` skip ${entry.id} (no preview files at ${entry.source})`)
skipped++
continue
}
const lightVars = extractCssVars(lightHtml)
const darkVars = extractCssVars(darkHtml)
const changes = []
// ── radius ──
if (!entry.radius) {
// Extract from card class border-radius (best proxy for container panels)
const cardRadiusMatch = lightHtml.match(/\.card\s*\{[^}]*border-radius:\s*([\d.]+)px/i)
|| lightHtml.match(/\.card[^{]*\{[^}]*border-radius:\s*([\d.]+)px/i)
if (cardRadiusMatch) {
const px = Number.parseFloat(cardRadiusMatch[1])
// Only accept reasonable container radii (4-20px)
if (px >= 4 && px <= 20) {
const rem = Math.round(px / 16 * 1000) / 1000
entry.radius = `${rem}rem`
changes.push(`radius=${px}px`)
}
}
}
// ── error ──
if (!entry.colors.error) {
const error = findColor(lightVars, 'color-error', 'error', 'neg', 'danger', 'destructive', 'color-danger', 'color-destructive', 'red', 'color-red')
if (error) {
entry.colors.error = error
changes.push(`error=${error}`)
}
}
// ── brandForeground ──
if (!entry.colors.brandForeground) {
const brandFg = findBrandForeground(lightHtml, entry.colors.brand)
if (brandFg) {
// Sanity check: must have reasonable contrast against brand (≥ 2:1)
try {
const cr = contrastRatio(toHsl(brandFg), toHsl(entry.colors.brand))
if (cr >= 2) {
entry.colors.brandForeground = brandFg
changes.push(`brandFg=${brandFg}`)
}
}
catch { /* skip unparseable */ }
}
}
// ── ring ──
if (!entry.colors.ring) {
const ring = findRing(lightVars)
if (ring) {
entry.colors.ring = ring
changes.push(`ring=${ring}`)
}
}
// ── dark section ──
if (!entry.dark) {
const dark = {}
const darkBg = findColor(darkVars, 'bg-page', 'bg', 'color-bg', 'background')
const darkFg = findColor(darkVars, 'text-primary', 'color-foreground', 'fg', 'white')
const darkSurface = findColor(darkVars, 'bg-card', 'surface', 'bg-surface', 'bg-panel', 'card')
const darkSecondary = findColor(darkVars, 'text-secondary', 'secondary', 'color-secondary')
const darkBorder = findColor(darkVars, 'border-color', 'border', 'border-subtle', 'color-border')
const darkError = findColor(darkVars, 'color-error', 'error', 'neg', 'danger', 'destructive')
const darkRing = findRing(darkVars)
if (darkBg)
dark.background = darkBg
if (darkFg)
dark.foreground = darkFg
if (darkSurface)
dark.surface = darkSurface
if (darkSecondary)
dark.secondaryText = darkSecondary
if (darkBorder)
dark.border = darkBorder
if (darkError && darkError !== entry.colors.error)
dark.error = darkError
if (darkRing)
dark.ring = darkRing
// Only add dark section if we got at least background
if (dark.background) {
entry.dark = dark
changes.push(`dark={bg:${dark.background} fg:${dark.foreground || '-'} surface:${dark.surface || '-'} border:${dark.border || '-'}}`)
}
}
if (changes.length > 0) {
const native = entry.native === 'dark' ? '◐' : '◑'
console.log(` ${native} ${entry.id.padEnd(15)} ${changes.join(', ')}`)
updated++
}
else {
skipped++
}
}
console.log(`\n${updated} updated, ${skipped} skipped`)
if (!dryRun) {
writeFileSync(registryPath, `${JSON.stringify(registry, null, 2)}\n`)
console.log(`Written → ${registryPath}`)
}
else {
console.log('(dry run — no files written)')
}
@@ -0,0 +1,389 @@
/**
* Layered palette generator.
*
* From a small set of key colors extracted from a DESIGN.md, derive the full
* --ms-* token map that markstream-vue expects.
*
* Input (all optional except background + foreground + brand):
* { background, foreground, brand, surface?, secondaryText?, border? }
*
* Output: { light: { ...tokens }, dark: { ...tokens } }
* where each token value is a shadcn bare HSL string "H S% L%".
*/
import {
adjustLightness,
contrastRatio,
hslToShadcn,
mixHsl,
toHsl,
} from './color.mjs'
// ─── Main entry ─────────────────────────────────────────────────────
/**
* Generate the full --ms-* token set from key colors + typography.
*
* @param {object} input
* @param {string} input.background - Page background (hex/css)
* @param {string} input.foreground - Primary text (hex/css)
* @param {string} input.brand - Brand / accent color (hex/css)
* @param {string} [input.surface] - Card / surface background
* @param {string} [input.secondaryText] - Secondary text color
* @param {string} [input.border] - Border color
* @param {string} [input.error] - Error / destructive color
* @param {string} [input.link] - Link color
* @param {string} [input.brandForeground] - Text on brand buttons (overrides auto)
* @param {string} [input.ring] - Focus ring color
* @param {string} [input.info] - Info / note semantic color
* @param {string} [input.success] - Success / tip semantic color
* @param {string} [input.warning] - Warning semantic color
* @param {string} [input.highlight] - Highlight / mark color
* @param {string} [input.diffAdded] - Diff added color
* @param {string} [input.diffRemoved] - Diff removed color
* @param {string} [input.radius] - Default component border-radius
* @param {object} [input.fonts] - Typography overrides
* @param {string} [input.fonts.sans] - Sans-serif font stack
* @param {string} [input.fonts.mono] - Monospace font stack
* @param {string} [input.fonts.serif] - Serif font stack (for heading variants)
* @returns {Record<string, string>} Map of token name → value
*/
export function generatePalette(input) {
const bg = toHsl(input.background)
const fg = toHsl(input.foreground)
const brand = toHsl(input.brand)
const isLight = bg.l > 50
const surface = input.surface ? toHsl(input.surface) : deriveSurface(bg, isLight)
// User-provided values pass through unchanged (trust the designer).
// Only derived values get contrast-corrected.
const secondaryText = input.secondaryText
? toHsl(input.secondaryText)
: deriveSecondaryText(fg, bg, isLight)
const border = input.border
? toHsl(input.border)
: deriveBorder(bg, fg, isLight)
const error = input.error ? toHsl(input.error) : deriveError(isLight)
const link = input.link ? toHsl(input.link) : deriveLink(brand, bg, fg, isLight)
// ── Bridge tokens (shadcn-compatible) ──
// muted must be computed before semantic colors (used for admonition header bg)
const muted = deriveMuted(bg, fg, isLight)
const mutedFg = secondaryText
const secondary = surface
const secondaryFg = isLight
? adjustLightness(fg, 10)
: adjustLightness(fg, -10)
const accent = deriveAccent(bg, brand, isLight)
const accentFg = isLight
? adjustLightness(fg, 5)
: adjustLightness(fg, -5)
const primary = brand
const primaryFg = input.brandForeground
? toHsl(input.brandForeground)
: derivePrimaryForeground(brand)
const destructive = error
const destructiveFg = derivePrimaryForeground(error)
const ring = input.ring
? toHsl(input.ring)
: fg
const popover = bg
const popoverFg = fg
// ── Extension tokens (markstream-specific) ──
// Signal & content colors are only emitted when explicitly provided.
// When absent, the library defaults in index.css apply.
const extensionTokens = {}
if (input.info) {
const c = toHsl(input.info)
extensionTokens.info = c
extensionTokens['info-foreground'] = derivePrimaryForeground(c)
}
if (input.success) {
const c = toHsl(input.success)
extensionTokens.success = c
extensionTokens['success-foreground'] = derivePrimaryForeground(c)
}
if (input.warning) {
const c = toHsl(input.warning)
extensionTokens.warning = c
extensionTokens['warning-foreground'] = derivePrimaryForeground(c)
}
if (input.diffAdded)
extensionTokens['diff-added'] = toHsl(input.diffAdded)
if (input.diffRemoved)
extensionTokens['diff-removed'] = toHsl(input.diffRemoved)
if (input.highlight) {
const c = toHsl(input.highlight)
extensionTokens.highlight = c
extensionTokens['highlight-foreground'] = derivePrimaryForeground(c)
}
const colorTokens = toTokenMap({
// Bridge
'background': bg,
'foreground': fg,
'muted': muted,
'muted-foreground': mutedFg,
'secondary': secondary,
'secondary-foreground': secondaryFg,
'accent': accent,
'accent-foreground': accentFg,
'primary': primary,
'primary-foreground': primaryFg,
'destructive': destructive,
'destructive-foreground': destructiveFg,
'border': border,
'ring': ring,
'popover': popover,
'popover-foreground': popoverFg,
'radius': null, // not a color — handled separately
// Extension (pass-through only)
...extensionTokens,
'link': link,
})
// Font tokens (not color, pass through as-is)
const fonts = input.fonts || {}
if (fonts.sans)
colorTokens['font-sans'] = fonts.sans
if (fonts.mono)
colorTokens['font-mono'] = fonts.mono
if (fonts.serif)
colorTokens['font-serif'] = fonts.serif
// Radius token
if (input.radius)
colorTokens.radius = input.radius
return colorTokens
}
// ─── Core contrast helper ───────────────────────────────────────────
/**
* Iteratively adjust lightness until the color meets a target contrast ratio
* against a reference color. Preserves hue and saturation.
*
* @param {object} color - HSL color to adjust
* @param {object} reference - HSL color to measure contrast against
* @param {number} target - Minimum contrast ratio (e.g. 4.5 for AA)
* @param {boolean} darken - Direction: true = make darker, false = make lighter
* @returns {object} Adjusted HSL color guaranteed to meet target (or at limit)
*/
function ensureContrast(color, reference, target, darken) {
// Add small epsilon to avoid floating-point near-misses at the boundary
const effectiveTarget = target + 0.02
// Coarse pass: step by 1 to find the region
let c = { ...color }
const coarseStep = darken ? -1 : 1
for (let i = 0; i < 100; i++) {
if (contrastRatio(c, reference) >= effectiveTarget)
return c
c = adjustLightness(c, coarseStep)
if (c.l <= 0 || c.l >= 100)
return c
}
return c
}
// ─── Derivation helpers ─────────────────────────────────────────────
/**
* Derive surface color: slightly offset from background.
*/
function deriveSurface(bg, isLight) {
return isLight
? adjustLightness(bg, -3)
: adjustLightness(bg, 5)
}
/**
* Derive muted background: between bg and fg, close to bg side.
*/
function deriveMuted(bg, fg, isLight) {
return isLight
? adjustLightness(bg, -4)
: adjustLightness(bg, 8)
}
/**
* Derive secondary text: mix fg/bg, then iterate to guarantee ≥ 4.5:1 against bg.
*/
function deriveSecondaryText(fg, bg, isLight) {
const seed = mixHsl(fg, bg, 0.35)
return ensureContrast(seed, bg, 4.5, isLight)
}
/**
* Derive border: must meet WCAG non-text contrast (≥ 3:1) against background.
* Starts from a subtle mix, then pushes until 3:1 is reached.
*/
function deriveBorder(bg, fg, isLight) {
const seed = isLight ? mixHsl(bg, fg, 0.1) : mixHsl(bg, fg, 0.12)
return ensureContrast(seed, bg, 3, isLight)
}
/**
* Derive accent: bg tinted with brand, subtle.
*/
function deriveAccent(bg, brand, isLight) {
return isLight
? mixHsl(bg, brand, 0.06)
: mixHsl(bg, brand, 0.1)
}
/**
* Derive error color (default red if not provided).
*/
function deriveError(isLight) {
return isLight
? { h: 0, s: 84.2, l: 60.2 }
: { h: 0, s: 62.8, l: 30.6 }
}
/**
* Derive link color from brand. Targets:
* 1. ≥ 4.5:1 against background (AA text — hard requirement)
* 2. ≥ 3:1 against foreground (color-only link distinguishability — best-effort)
*
* Sweeps H/S/L to find the best feasible link. If both constraints are
* mathematically impossible (e.g. very dark fg + very light bg with blue hue),
* prioritizes bg contrast and maximizes fg contrast within that constraint.
*/
function deriveLink(brand, bg, fg, isLight) {
const h = brand.h
const isBlueish = h >= 180 && h <= 290
const seedH = isBlueish ? brand.h : 212
const seedS = isBlueish ? Math.min(brand.s, 100) : 100
const seedL = isLight ? 38 : 68
// Sweep S and L. Two-tier strategy:
// Tier 1: find candidates meeting BOTH bg≥4.5 AND fg≥3, pick closest to seed.
// Tier 2: if none found, pick the L closest to seed that meets bg≥4.5.
// (fg shortfall will be honestly reported by validateContrast)
let tier1 = null
let tier1Dist = Infinity
let tier2 = null
let tier2Dist = Infinity
// Always sweep nearby hues — even blue brands may need a hue shift for contrast
const hueSet = new Set([seedH])
for (let dh = -15; dh <= 15; dh += 5) {
hueSet.add(((seedH + dh) % 360 + 360) % 360)
}
const hues = [...hueSet]
for (const sh of hues) {
for (let s = seedS; s >= 40; s -= 5) {
for (let l = 0; l <= 100; l++) {
const candidate = { h: sh, s, l }
const crBg = contrastRatio(candidate, bg)
if (crBg < 4.5)
continue
const dist = Math.abs(l - seedL) + Math.abs(s - seedS) * 0.1 + Math.abs(sh - seedH) * 0.2
// Tier 2: any bg-passing candidate
if (dist < tier2Dist) {
tier2Dist = dist
tier2 = candidate
}
// Tier 1: also meets fg≥3
const crFg = contrastRatio(candidate, fg)
if (crFg >= 3 && dist < tier1Dist) {
tier1Dist = dist
tier1 = candidate
}
}
}
}
return tier1 || tier2 || ensureContrast({ h: seedH, s: seedS, l: seedL }, bg, 4.5, isLight)
}
/**
* Derive primary foreground: white or dark text depending on brand lightness.
* Tries tinted white/dark first for warmth, falls back to pure white/black.
* Always picks whichever option has the best contrast.
*/
function derivePrimaryForeground(brand) {
const candidates = [
{ h: 210, s: 40, l: 98 }, // tinted white
{ h: 222.2, s: 47.4, l: 11.2 }, // tinted dark
{ h: 0, s: 0, l: 100 }, // pure white
{ h: 0, s: 0, l: 0 }, // pure black
]
let best = candidates[0]
let bestCr = 0
for (const c of candidates) {
const cr = contrastRatio(c, brand)
if (cr > bestCr) {
bestCr = cr
best = c
}
}
return best
}
// ─── Output formatting ──────────────────────────────────────────────
/**
* Convert named HSL objects to { tokenName: "H S% L%" } map.
*/
function toTokenMap(obj) {
const result = {}
for (const [key, val] of Object.entries(obj)) {
if (val === null)
continue
result[key] = hslToShadcn(val)
}
return result
}
// ─── Token metadata (for documentation / validation) ────────────────
/**
* All bridge token names that map to shadcn variables.
*/
export const BRIDGE_TOKENS = [
'background',
'foreground',
'muted',
'muted-foreground',
'secondary',
'secondary-foreground',
'accent',
'accent-foreground',
'primary',
'primary-foreground',
'destructive',
'destructive-foreground',
'border',
'ring',
'popover',
'popover-foreground',
]
/**
* All extension token names specific to markstream-vue.
* Signal & content tokens are optional — only emitted when the registry provides them.
* When absent, the library defaults in src/index.css apply.
*/
export const EXTENSION_TOKENS = [
'info',
'info-foreground',
'success',
'success-foreground',
'warning',
'warning-foreground',
'diff-added',
'diff-removed',
'highlight',
'highlight-foreground',
'link',
]
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,95 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "Theme Registry",
"description": "Curated design tokens extracted from DESIGN.md files for markstream-vue theme generation.",
"type": "array",
"items": {
"type": "object",
"required": ["id", "name", "source", "colors"],
"properties": {
"id": {
"type": "string",
"description": "Kebab-case identifier, used as CSS data-theme value and filename",
"pattern": "^[a-z0-9-]+$"
},
"name": {
"type": "string",
"description": "Display name (e.g. 'Claude', 'Linear')"
},
"source": {
"type": "string",
"description": "Origin DESIGN.md directory name in awesome-design-md"
},
"category": {
"type": "string",
"enum": ["ai", "devtool", "infra", "design", "fintech", "enterprise"],
"description": "Classification for documentation"
},
"native": {
"type": "string",
"enum": ["light", "dark"],
"description": "The scheme the source design is built around. The opposite scheme will be auto-generated."
},
"colors": {
"type": "object",
"required": ["background", "foreground", "brand"],
"properties": {
"background": { "type": "string", "description": "Page background" },
"foreground": { "type": "string", "description": "Primary text color" },
"brand": { "type": "string", "description": "Primary brand / accent / CTA color" },
"surface": { "type": "string", "description": "Card / elevated surface background" },
"secondaryText": { "type": "string", "description": "Secondary / muted text color" },
"border": { "type": "string", "description": "Default border color" },
"error": { "type": "string", "description": "Error / destructive color" },
"link": { "type": "string", "description": "Link text color" },
"brandForeground": { "type": "string", "description": "Text on brand/CTA buttons (overrides auto contrast pick)" },
"ring": { "type": "string", "description": "Focus ring color" },
"info": { "type": "string", "description": "Info / note semantic color" },
"success": { "type": "string", "description": "Success / tip semantic color" },
"warning": { "type": "string", "description": "Warning semantic color" },
"highlight": { "type": "string", "description": "Highlight / mark color" },
"diffAdded": { "type": "string", "description": "Diff added indicator color" },
"diffRemoved": { "type": "string", "description": "Diff removed indicator color" }
},
"additionalProperties": false
},
"radius": {
"type": "string",
"description": "Default component border-radius (CSS value, e.g. '0.5rem', '6px')"
},
"dark": {
"type": "object",
"description": "Explicit dark-scheme colors. When dark.background is present, used instead of auto-generating opposite scheme.",
"properties": {
"background": { "type": "string" },
"foreground": { "type": "string" },
"brand": { "type": "string" },
"surface": { "type": "string" },
"secondaryText": { "type": "string" },
"border": { "type": "string" },
"error": { "type": "string" },
"link": { "type": "string" },
"brandForeground": { "type": "string" },
"ring": { "type": "string" },
"info": { "type": "string" },
"success": { "type": "string" },
"warning": { "type": "string" },
"highlight": { "type": "string" },
"diffAdded": { "type": "string" },
"diffRemoved": { "type": "string" }
},
"additionalProperties": false
},
"fonts": {
"type": "object",
"properties": {
"sans": { "type": "string", "description": "Sans-serif font stack (CSS font-family value)" },
"mono": { "type": "string", "description": "Monospace font stack" },
"serif": { "type": "string", "description": "Serif font stack (for heading variants)" }
},
"additionalProperties": false
}
},
"additionalProperties": false
}
}