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