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