e0a5f57793
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.
319 lines
9.8 KiB
JavaScript
319 lines
9.8 KiB
JavaScript
#!/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)')
|
|
}
|