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:
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user