Files
boos e0a5f57793 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.
2026-06-22 11:32:57 +08:00

339 lines
11 KiB
JavaScript

/**
* 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)
}