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.
390 lines
12 KiB
JavaScript
390 lines
12 KiB
JavaScript
/**
|
|
* 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',
|
|
]
|