learning_ai_common_plat/packages/design-tokens/scripts/generate.ts
saravanakumardb1 cc0bffea86 feat(packages): close ROADMAP TODOs #1, #2, #3 — density tier, react-auth fix, routePrefix
Three coordinated package changes addressing Wave 1 cross-repo TODOs
from the UI/UX roadmap (learning_ai_uxui_web/docs/ROADMAP_2026.md §10).

═══════════════════════════════════════════════════════════════════════
TODO #2 — @bytelyst/react-auth bump 0.1.8 → 0.2.0
═══════════════════════════════════════════════════════════════════════
  - Changes 'workspace:*' to 'workspace:^' for the @bytelyst/api-client
    dependency. On pnpm publish this resolves to a caret range (e.g.
    ^0.1.6) instead of '*', restoring installability for consumers.
    (The 0.1.6 tarball was published with a literal 'workspace:*'
    string — newer minor bump unblocks the showcase react-auth demo.)
  - 21 tests still passing.

═══════════════════════════════════════════════════════════════════════
TODO #3 — @bytelyst/dashboard-shell bump 0.1.7 → 0.2.0
═══════════════════════════════════════════════════════════════════════
  - Adds 'routePrefix?: string' prop to DashboardShellProps, SidebarProps,
    and TopBarProps. Threads through DashboardShell → Sidebar + TopBar.
  - Built-in /profile, /billing, /settings links now use the prefix:
      routePrefix="/app" → /app/profile, /app/billing, /app/settings
  - Defaults to '' (empty string) — fully back-compat with 0.1.x callers.
  - 2 new vitest cases covering both prefixed and default behavior;
    43 / 43 tests passing (+2 from 41).

═══════════════════════════════════════════════════════════════════════
TODO #1 — @bytelyst/design-tokens bump 0.1.8 → 0.2.0
═══════════════════════════════════════════════════════════════════════
  - Adds a density-aware spacing tier on top of the existing raw
    --ml-space-* tier:
        --bl-space-scale: 1                       (default :root)
        --bl-space-1..16: calc(--ml-space-N × --bl-space-scale)
  - Emits density selectors at the end of tokens.css:
        [data-density="compact"]    { --bl-space-scale: 0.875; }
        [data-density="comfortable"] { --bl-space-scale: 1; }
        [data-density="spacious"]   { --bl-space-scale: 1.125; }
  - Generator (scripts/generate.ts) emits both tiers automatically; the
    auto-generated per-product CSS files (lysnrai, mindlyst, etc.) gain
    a single blank-line diff from regeneration — no semantic change.
  - 11 / 11 token tests passing.

═══════════════════════════════════════════════════════════════════════
Decision doc — docs/ROADMAP_2026_DECISIONS.md
═══════════════════════════════════════════════════════════════════════
  - Records pragmatic defaults for TODO ledger items #9–#13 so
    implementation work doesn't block:
      #9  Storybook hosting → self-hosted on Gitea Pages (free)
      #10 useChat protocol  → adopt Vercel AI SDK shape, abstract transport
      #11 react-auth fold-in → defer to Wave 7
      #12 dashboard-shell merge → defer to Wave 7
      #13 mobile-native UI → out of scope (tokens-only sharing)
  - Each decision is reversible via RFC.

═══════════════════════════════════════════════════════════════════════
Publish flow
═══════════════════════════════════════════════════════════════════════
  These three packages now require a release. The existing publish
  workflow (.gitea/workflows/publish-packages.yml) has PACKAGE_FILTER
  pinned to @bytelyst/errors and won't pick them up automatically — a
  manual workflow_dispatch with a broader filter (or the existing
  publish-all-packages.yml on workflow_dispatch) is needed to ship
  0.2.0 to the Gitea npm registry.

Refs: learning_ai_uxui_web/docs/ROADMAP_2026.md §10 TODOs #1, #2, #3, #9–#13
2026-05-27 11:49:20 -07:00

880 lines
36 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* Token generator — reads bytelyst.tokens.json, outputs 4 platform formats:
* 1. CSS custom properties (tokens.css)
* 2. TypeScript constants (tokens.ts)
* 3. Kotlin object (MindLystTokens.kt) — for KMP shared module
* 4. Swift structs (MindLystTheme.swift) — for iOS SwiftUI
*
* Output conventions match the hand-written originals in learning_multimodal_memory_agents:
* - Kotlin: SCREAMING_SNAKE_CASE, Palette/Dark/Light/BrainGradient/Typography/Motion/Layout
* - Swift: Color(hex: UInt), dark/light prefixes, Gradient(colors:), MindLystMotion, Color ext
* - CSS: [data-theme], --ml-fs-*, --ml-elevation-*, --ml-motion-*
*
* Usage: tsx scripts/generate.ts
*/
/* eslint-disable no-console -- This generator is a CLI; console output confirms generated artifacts. */
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = dirname(fileURLToPath(import.meta.url));
const tokensPath = resolve(__dirname, '../tokens/bytelyst.tokens.json');
const outDir = resolve(__dirname, '../generated');
mkdirSync(outDir, { recursive: true });
const tokens = JSON.parse(readFileSync(tokensPath, 'utf-8'));
// ── Product CSS mapping ─────────────────────────────────────────────
const PRODUCT_CSS_MAP: Record<string, { prefix: string; colorsKey: string }> = {
mindlyst: { prefix: 'ml', colorsKey: 'brain' },
chronomind: { prefix: 'cm', colorsKey: 'chronomind' },
jarvisjr: { prefix: 'jj', colorsKey: 'jarvisjr' },
nomgap: { prefix: 'ng', colorsKey: 'nomgap' },
actiontrail: { prefix: 'at', colorsKey: 'actiontrail' },
flowmonk: { prefix: 'fm', colorsKey: 'flowmonk' },
notelett: { prefix: 'nl', colorsKey: 'notelett' },
localmemgpt: { prefix: 'lmg', colorsKey: 'localmemgpt' },
localllmlab: { prefix: 'llm', colorsKey: 'localllmlab' },
lysnrai: { prefix: 'lys', colorsKey: 'lysnrai' },
peakpulse: { prefix: 'pp', colorsKey: 'peakpulse' },
};
// ── Helpers ──────────────────────────────────────────────────────────
function camelToKebab(str: string): string {
return str.replace(/([a-z])([A-Z])/g, '$1-$2').toLowerCase();
}
function camelToScreamingSnake(str: string): string {
return str.replace(/([a-z])([A-Z])/g, '$1_$2').toUpperCase();
}
function hexToUInt(hex: string): string {
return `0x${hex.replace('#', '').toUpperCase()}`;
}
function capitalize(s: string): string {
return s.charAt(0).toUpperCase() + s.slice(1);
}
// ── 1. CSS ───────────────────────────────────────────────────────────
function generateCSS(): string {
const lines: string[] = [
'/* Auto-generated from bytelyst.tokens.json — do not edit manually */',
'',
':root,',
'[data-theme="dark"] {',
];
// Semantic colors (dark theme as default)
for (const [key, value] of Object.entries(tokens.color.semantic.dark)) {
lines.push(` --ml-${camelToKebab(key)}: ${value};`);
}
lines.push('');
// ByteLyst semantic aliases. These are the shared UI contract and map the
// historical MindLyst token names into a product-neutral design system.
lines.push(' --bl-bg-canvas: var(--ml-bg-canvas);');
lines.push(' --bl-bg-elevated: var(--ml-bg-elevated);');
lines.push(' --bl-surface-card: var(--ml-surface-card);');
lines.push(' --bl-surface-muted: var(--ml-surface-muted);');
lines.push(' --bl-surface-highlight: color-mix(in oklab, var(--ml-surface-muted) 82%, white);');
lines.push(' --bl-surface-overlay: color-mix(in oklab, var(--ml-bg-canvas) 88%, transparent);');
lines.push(
' --bl-input: color-mix(in oklab, var(--ml-surface-muted) 76%, var(--ml-bg-canvas));'
);
lines.push(' --bl-border: var(--ml-border-default);');
lines.push(' --bl-border-strong: var(--ml-border-strong);');
lines.push(
' --bl-border-subtle: color-mix(in oklab, var(--ml-border-default) 62%, transparent);'
);
lines.push(' --bl-text-primary: var(--ml-text-primary);');
lines.push(' --bl-text-secondary: var(--ml-text-secondary);');
lines.push(' --bl-text-tertiary: var(--ml-text-tertiary);');
lines.push(
' --bl-text-quiet: color-mix(in oklab, var(--ml-text-secondary) 78%, var(--ml-bg-canvas));'
);
lines.push(' --bl-accent: var(--ml-accent-primary);');
lines.push(' --bl-accent-foreground: var(--ml-bg-canvas);');
lines.push(
' --bl-accent-muted: color-mix(in oklab, var(--ml-accent-primary) 16%, transparent);'
);
lines.push(' --bl-info: var(--ml-accent-primary);');
lines.push(' --bl-info-muted: color-mix(in oklab, var(--ml-accent-primary) 14%, transparent);');
lines.push(' --bl-success: var(--ml-success);');
lines.push(' --bl-success-muted: color-mix(in oklab, var(--ml-success) 14%, transparent);');
lines.push(' --bl-warning: var(--ml-warning);');
lines.push(' --bl-warning-muted: color-mix(in oklab, var(--ml-warning) 14%, transparent);');
lines.push(' --bl-danger: var(--ml-danger);');
lines.push(' --bl-danger-muted: color-mix(in oklab, var(--ml-danger) 14%, transparent);');
lines.push(' --bl-danger-foreground: var(--ml-bg-canvas);');
lines.push(' --bl-focus-ring: var(--ml-focus-ring);');
lines.push(
' --bl-focus-ring-muted: color-mix(in oklab, var(--ml-accent-primary) 18%, transparent);'
);
lines.push(' --bl-overlay-scrim: var(--ml-overlay-scrim);');
lines.push('');
// Typography
for (const [key, value] of Object.entries(tokens.typography.fontFamily)) {
// Swap single quotes → double quotes for CSS
const cssVal = typeof value === 'string' ? value.replace(/'/g, '"') : value;
lines.push(` --ml-font-${key}: ${cssVal};`);
}
lines.push('');
// Font sizes (--ml-fs-* to match existing convention)
for (const [key, value] of Object.entries(tokens.typography.fontSize)) {
lines.push(` --ml-fs-${key}: ${value}px;`);
}
lines.push('');
// Spacing — raw (--ml-*) tier, fixed pixel values
for (const [key, value] of Object.entries(tokens.spacing)) {
lines.push(` --ml-space-${key}: ${value === 0 ? '0' : `${value}px`};`);
}
lines.push('');
// Density-aware spacing tier (--bl-*) — added in design-tokens 0.2.0.
// Each --bl-space-N = --ml-space-N × var(--bl-space-scale).
// Consumers override --bl-space-scale via [data-density="..."] on <html>
// (or any ancestor) to switch between compact / comfortable / spacious.
lines.push(' /* Density scale — overridden by [data-density] selectors below */');
lines.push(' --bl-space-scale: 1;');
for (const [key, value] of Object.entries(tokens.spacing)) {
if (value === 0) {
lines.push(` --bl-space-${key}: 0;`);
} else {
lines.push(` --bl-space-${key}: calc(var(--ml-space-${key}) * var(--bl-space-scale));`);
}
}
lines.push('');
// Radius
for (const [key, value] of Object.entries(tokens.radius)) {
lines.push(` --ml-radius-${key}: ${value}px;`);
}
lines.push(' --bl-radius-control: var(--ml-radius-xs);');
lines.push(' --bl-radius-surface: var(--ml-radius-sm);');
lines.push(' --bl-radius-card: var(--ml-radius-md);');
lines.push(' --bl-radius-panel: var(--ml-radius-lg);');
lines.push(' --bl-radius-pill: var(--ml-radius-pill);');
lines.push('');
// Elevation (--ml-elevation-* to match existing)
for (const [key, value] of Object.entries(tokens.elevation)) {
if (key === 'none') continue;
lines.push(` --ml-elevation-${key}: ${value};`);
}
lines.push(' --bl-shadow-sm: var(--ml-elevation-sm);');
lines.push(' --bl-shadow-md: var(--ml-elevation-md);');
lines.push(' --bl-shadow-lg: var(--ml-elevation-lg);');
lines.push('');
// Motion
for (const [key, value] of Object.entries(tokens.motion.duration)) {
if (key === 'instant') continue; // not used in CSS
lines.push(` --ml-motion-${key}: ${value}ms;`);
}
lines.push(` --ml-easing-standard: ${tokens.motion.easing.standard};`);
lines.push('}', '');
// Light theme overrides
lines.push('[data-theme="light"] {');
for (const [key, value] of Object.entries(tokens.color.semantic.light)) {
// Only emit overrides where light differs from dark
const darkVal = tokens.color.semantic.dark[key];
if (value !== darkVal) {
lines.push(` --ml-${camelToKebab(key)}: ${value};`);
}
}
lines.push('}', '');
// Density overrides — drive --bl-space-scale via [data-density="..."].
// Default (no attribute or `comfortable`) keeps scale = 1 from :root.
// Added in design-tokens 0.2.0.
lines.push('/* Density tier — overrides --bl-space-scale */');
lines.push('[data-density="compact"] { --bl-space-scale: 0.875; }');
lines.push('[data-density="comfortable"] { --bl-space-scale: 1; }');
lines.push('[data-density="spacious"] { --bl-space-scale: 1.125; }');
lines.push('');
return lines.join('\n');
}
// ── 2. TypeScript ────────────────────────────────────────────────────
function generateTS(): string {
return [
'// Auto-generated from bytelyst.tokens.json — do not edit manually',
'',
`export const tokens = ${JSON.stringify(tokens, null, 2)} as const;`,
'',
'export type Tokens = typeof tokens;',
'',
].join('\n');
}
// ── 3. Kotlin ────────────────────────────────────────────────────────
function generateKotlin(): string {
const lines: string[] = [
'// Auto-generated from bytelyst.tokens.json — do not edit manually',
'package com.mindlyst.shared.theme',
'',
'/**',
' * Cross-platform design tokens from bytelyst.tokens.json.',
' * Single source of truth consumed by both Android (Compose) and iOS (SwiftUI).',
' */',
'object MindLystTokens {',
'',
];
// ── Palette
lines.push(' // ── Color Palette ────────────────────────────────────────────────');
lines.push(' object Palette {');
for (const [key, value] of Object.entries(tokens.color.palette.neutral)) {
lines.push(
` const val NEUTRAL_${key} = 0xFF${(value as string).replace('#', '').toUpperCase()}`
);
}
lines.push('');
for (const [key, value] of Object.entries(tokens.color.palette.brand)) {
lines.push(
` const val ${key.toUpperCase()} = 0xFF${(value as string).replace('#', '').toUpperCase()}`
);
}
lines.push(' }', '');
// ── Dark semantic
lines.push(' // ── Semantic Colors (Dark Theme) ─────────────────────────────────');
lines.push(' object Dark {');
for (const [key, value] of Object.entries(tokens.color.semantic.dark)) {
if (typeof value === 'string' && value.startsWith('#')) {
lines.push(
` const val ${camelToScreamingSnake(key)} = 0xFF${value.replace('#', '').toUpperCase()}`
);
}
}
lines.push(' }', '');
// ── Light semantic
lines.push(' // ── Semantic Colors (Light Theme) ────────────────────────────────');
lines.push(' object Light {');
for (const [key, value] of Object.entries(tokens.color.semantic.light)) {
if (typeof value === 'string' && value.startsWith('#')) {
lines.push(
` const val ${camelToScreamingSnake(key)} = 0xFF${value.replace('#', '').toUpperCase()}`
);
}
}
lines.push(' }', '');
// ── Brain gradients
lines.push(' // ── Brain Identity Gradients ─────────────────────────────────────');
lines.push(' data class BrainGradient(val from: Long, val to: Long)');
lines.push('');
for (const [name, grad] of Object.entries(tokens.color.brain) as [
string,
{ from: string; to: string },
][]) {
lines.push(
` val BRAIN_${name.toUpperCase()} = BrainGradient(from = 0xFF${grad.from.replace('#', '').toUpperCase()}, to = 0xFF${grad.to.replace('#', '').toUpperCase()})`
);
}
lines.push('');
// ── Spacing
lines.push(' // ── Spacing (8pt grid) ───────────────────────────────────────────');
lines.push(' object Spacing {');
for (const [key, value] of Object.entries(tokens.spacing)) {
lines.push(` const val X${key} = ${value}`);
}
lines.push(' }', '');
// ── Radius
lines.push(' // ── Radius ───────────────────────────────────────────────────────');
lines.push(' object Radius {');
for (const [key, value] of Object.entries(tokens.radius)) {
lines.push(` const val ${key.toUpperCase()} = ${value}`);
}
lines.push(' }', '');
// ── Typography
lines.push(' // ── Typography ───────────────────────────────────────────────────');
lines.push(' object Typography {');
for (const [key, value] of Object.entries(tokens.typography.fontFamily)) {
// Extract just the primary font name (first in the list)
const fontName =
typeof value === 'string' ? value.split(',')[0].replace(/'/g, '').trim() : value;
lines.push(` const val FONT_${key.toUpperCase()} = "${fontName}"`);
}
lines.push('');
for (const [key, value] of Object.entries(tokens.typography.fontSize)) {
const sizeKey = key.toUpperCase().replace('-', '');
lines.push(` const val SIZE_${sizeKey} = ${value}`);
}
lines.push(' }', '');
// ── Motion
lines.push(' // ── Motion ───────────────────────────────────────────────────────');
lines.push(' object Motion {');
for (const [key, value] of Object.entries(tokens.motion.duration)) {
lines.push(` const val ${key.toUpperCase()} = ${value}`);
}
lines.push(' }', '');
// ── Layout
lines.push(' // ── Layout ───────────────────────────────────────────────────────');
lines.push(' object Layout {');
lines.push(` const val TOUCH_TARGET_MIN = ${tokens.layout.touchTargetMin}`);
lines.push(` const val MOBILE_GUTTER = ${tokens.layout.mobileGutter}`);
lines.push(` const val MAX_WIDTH = ${tokens.layout.maxContentWidth}`);
lines.push(' }');
lines.push('}', '');
return lines.join('\n');
}
// ── 4. Swift ─────────────────────────────────────────────────────────
function generateSwift(): string {
const lines: string[] = [
'// Auto-generated from bytelyst.tokens.json — do not edit manually',
'import SwiftUI',
'',
'// MARK: - MindLyst Design Tokens (from shared KMP MindLystTokens)',
'// These values mirror MindLystTokens.kt exactly.',
'',
'struct MindLystColors {',
];
// Dark colors
lines.push(' // Dark');
for (const [key, value] of Object.entries(tokens.color.semantic.dark)) {
if (typeof value === 'string' && value.startsWith('#')) {
lines.push(` static let dark${capitalize(key)} = Color(hex: ${hexToUInt(value)})`);
} else if (typeof value === 'string' && value.startsWith('rgba')) {
// border/overlay → special handling
if (key === 'borderDefault') {
lines.push(' static let darkBorder = Color.white.opacity(0.12)');
}
}
}
lines.push('');
// Light colors
lines.push(' // Light');
for (const [key, value] of Object.entries(tokens.color.semantic.light)) {
if (typeof value === 'string' && value.startsWith('#')) {
if (value === '#FFFFFF') {
lines.push(` static let light${capitalize(key)} = Color.white`);
} else {
lines.push(` static let light${capitalize(key)} = Color(hex: ${hexToUInt(value)})`);
}
}
}
lines.push('');
// Brain gradients
lines.push(' // Brain Gradients');
for (const [name, grad] of Object.entries(tokens.color.brain) as [
string,
{ from: string; to: string },
][]) {
lines.push(
` static let brain${capitalize(name)} = Gradient(colors: [Color(hex: ${hexToUInt(grad.from)}), Color(hex: ${hexToUInt(grad.to)})])`
);
}
lines.push('}', '');
// Spacing
lines.push('struct MindLystSpacing {');
for (const [key, value] of Object.entries(tokens.spacing)) {
const pad = key.length === 1 ? ' ' : '';
lines.push(` static let x${key}: ${pad}CGFloat = ${value}`);
}
lines.push('}', '');
// Radius
lines.push('struct MindLystRadius {');
for (const [key, value] of Object.entries(tokens.radius)) {
const pad = key.length < 4 ? ' '.repeat(4 - key.length) : '';
lines.push(` static let ${key}:${pad} CGFloat = ${value}`);
}
lines.push('}', '');
// Motion (durations in seconds)
lines.push('struct MindLystMotion {');
for (const [key, value] of Object.entries(tokens.motion.duration)) {
const seconds = (value as number) / 1000;
const pad = key.length < 7 ? ' '.repeat(7 - key.length) : '';
lines.push(` static let ${key}:${pad} Double = ${seconds.toFixed(2)}`);
}
lines.push('}', '');
// Color hex extension
lines.push('// MARK: - Color Hex Extension');
lines.push('');
lines.push('extension Color {');
lines.push(' init(hex: UInt, alpha: Double = 1.0) {');
lines.push(' self.init(');
lines.push(' .sRGB,');
lines.push(' red: Double((hex >> 16) & 0xFF) / 255.0,');
lines.push(' green: Double((hex >> 8) & 0xFF) / 255.0,');
lines.push(' blue: Double(hex & 0xFF) / 255.0,');
lines.push(' opacity: alpha');
lines.push(' )');
lines.push(' }');
lines.push('}', '');
return lines.join('\n');
}
// ── 5. Per-product CSS ──────────────────────────────────────────────
function generateProductCSS(productId: string, prefix: string, colorsKey: string): string {
const productColors = tokens.color[colorsKey];
if (!productColors) return `/* No palette found for ${productId} (color.${colorsKey}) */\n`;
const lines: string[] = [
`/* Auto-generated ${productId} tokens from bytelyst.tokens.json — do not edit manually */`,
'',
':root {',
];
// Semantic colors (dark as default) — shared across all products
for (const [key, value] of Object.entries(tokens.color.semantic.dark)) {
lines.push(` --${prefix}-${camelToKebab(key)}: ${value};`);
}
lines.push('');
// Product-specific colors
lines.push(` /* ${productId} product colors */`);
for (const [key, value] of Object.entries(productColors)) {
if (typeof value === 'string') {
lines.push(` --${prefix}-${camelToKebab(key)}: ${value};`);
} else if (typeof value === 'object' && value !== null && 'from' in value && 'to' in value) {
const grad = value as unknown as { from: string; to: string };
lines.push(` --${prefix}-${camelToKebab(key)}-from: ${grad.from};`);
lines.push(` --${prefix}-${camelToKebab(key)}-to: ${grad.to};`);
}
}
lines.push('');
// Typography
for (const [key, value] of Object.entries(tokens.typography.fontFamily)) {
const cssVal = typeof value === 'string' ? value.replace(/'/g, '"') : value;
lines.push(` --${prefix}-font-${key}: ${cssVal};`);
}
lines.push('');
// Font sizes
for (const [key, value] of Object.entries(tokens.typography.fontSize)) {
lines.push(` --${prefix}-fs-${key}: ${value}px;`);
}
lines.push('');
// Spacing
for (const [key, value] of Object.entries(tokens.spacing)) {
lines.push(` --${prefix}-space-${key}: ${value === 0 ? '0' : `${value}px`};`);
}
lines.push('');
// Radius
for (const [key, value] of Object.entries(tokens.radius)) {
lines.push(` --${prefix}-radius-${key}: ${value}px;`);
}
lines.push('');
// Elevation
for (const [key, value] of Object.entries(tokens.elevation)) {
if (key === 'none') continue;
lines.push(` --${prefix}-elevation-${key}: ${value};`);
}
lines.push('');
// Motion
for (const [key, value] of Object.entries(tokens.motion.duration)) {
if (key === 'instant') continue;
lines.push(` --${prefix}-motion-${key}: ${value}ms;`);
}
lines.push(` --${prefix}-easing-standard: ${tokens.motion.easing.standard};`);
lines.push('');
// @bytelyst/ui alias layer — maps --bl-* → product --{prefix}-* so shared
// components resolve the correct product-scoped tokens.
lines.push(` /* ── @bytelyst/ui alias layer (--bl-* → --${prefix}-*) ──────────────── */`);
lines.push(` --bl-bg-canvas: var(--${prefix}-bg-canvas);`);
lines.push(` --bl-bg-elevated: var(--${prefix}-bg-elevated);`);
lines.push(` --bl-surface-card: var(--${prefix}-surface-card);`);
lines.push(` --bl-surface-muted: var(--${prefix}-surface-muted);`);
lines.push(
` --bl-surface-highlight: color-mix(in oklab, var(--${prefix}-surface-muted) 82%, white);`
);
lines.push(
` --bl-surface-overlay: color-mix(in oklab, var(--${prefix}-bg-canvas) 88%, transparent);`
);
lines.push(
` --bl-input: color-mix(in oklab, var(--${prefix}-surface-muted) 76%, var(--${prefix}-bg-canvas));`
);
lines.push(` --bl-border: var(--${prefix}-border-default);`);
lines.push(` --bl-border-strong: var(--${prefix}-border-strong);`);
lines.push(
` --bl-border-subtle: color-mix(in oklab, var(--${prefix}-border-default) 62%, transparent);`
);
lines.push(` --bl-text-primary: var(--${prefix}-text-primary);`);
lines.push(` --bl-text-secondary: var(--${prefix}-text-secondary);`);
lines.push(` --bl-text-tertiary: var(--${prefix}-text-tertiary);`);
lines.push(
` --bl-text-quiet: color-mix(in oklab, var(--${prefix}-text-secondary) 78%, var(--${prefix}-bg-canvas));`
);
lines.push(` --bl-accent: var(--${prefix}-accent-primary);`);
lines.push(` --bl-accent-foreground: var(--${prefix}-bg-canvas);`);
lines.push(
` --bl-accent-muted: color-mix(in oklab, var(--${prefix}-accent-primary) 16%, transparent);`
);
lines.push(` --bl-info: var(--${prefix}-accent-primary);`);
lines.push(
` --bl-info-muted: color-mix(in oklab, var(--${prefix}-accent-primary) 14%, transparent);`
);
lines.push(` --bl-success: var(--${prefix}-success);`);
lines.push(
` --bl-success-muted: color-mix(in oklab, var(--${prefix}-success) 14%, transparent);`
);
lines.push(` --bl-warning: var(--${prefix}-warning);`);
lines.push(
` --bl-warning-muted: color-mix(in oklab, var(--${prefix}-warning) 14%, transparent);`
);
lines.push(` --bl-danger: var(--${prefix}-danger);`);
lines.push(` --bl-danger-muted: color-mix(in oklab, var(--${prefix}-danger) 14%, transparent);`);
lines.push(` --bl-danger-foreground: var(--${prefix}-bg-canvas);`);
lines.push(` --bl-focus-ring: var(--${prefix}-focus-ring);`);
lines.push(
` --bl-focus-ring-muted: color-mix(in oklab, var(--${prefix}-accent-primary) 18%, transparent);`
);
lines.push(` --bl-overlay-scrim: var(--${prefix}-overlay-scrim);`);
lines.push('}', '');
// Light theme overrides
lines.push('[data-theme="light"] {');
for (const [key, value] of Object.entries(tokens.color.semantic.light)) {
const darkVal = (tokens.color.semantic.dark as Record<string, unknown>)[key];
if (value !== darkVal) {
lines.push(` --${prefix}-${camelToKebab(key)}: ${value};`);
}
}
lines.push('}', '');
return lines.join('\n');
}
// ── Product native mapping (products with iOS/Android apps) ─────────
interface ProductNativeConfig {
colorsKey: string;
swiftEnum: string; // e.g. 'PeakPulseColors'
kotlinObject: string; // e.g. 'PeakPulseTokens'
kotlinPackage: string; // e.g. 'com.peakpulse.theme'
swiftFile: string; // e.g. 'PeakPulseTheme.swift'
kotlinFile: string; // e.g. 'PeakPulseTokens.kt'
}
const PRODUCT_NATIVE_MAP: Record<string, ProductNativeConfig> = {
chronomind: {
colorsKey: 'chronomind',
swiftEnum: 'CMColors',
kotlinObject: 'ChronoMindTokens',
kotlinPackage: 'com.chronomind.app.theme',
swiftFile: 'ChronoMindTheme.generated.swift',
kotlinFile: 'ChronoMindTokens.generated.kt',
},
jarvisjr: {
colorsKey: 'jarvisjr',
swiftEnum: 'JarvisJrColors',
kotlinObject: 'JarvisJrTokens',
kotlinPackage: 'com.jarvisjr.app.theme',
swiftFile: 'JarvisJrTheme.generated.swift',
kotlinFile: 'JarvisJrTokens.generated.kt',
},
peakpulse: {
colorsKey: 'peakpulse',
swiftEnum: 'PeakPulseColors',
kotlinObject: 'PeakPulseTokens',
kotlinPackage: 'com.peakpulse.theme',
swiftFile: 'PeakPulseTheme.generated.swift',
kotlinFile: 'PeakPulseTokens.generated.kt',
},
lysnrai: {
colorsKey: 'lysnrai',
swiftEnum: 'LysnrAIColors',
kotlinObject: 'LysnrAITokens',
kotlinPackage: 'com.saravana.lysnrai.theme',
swiftFile: 'LysnrAITheme.generated.swift',
kotlinFile: 'LysnrAITokens.generated.kt',
},
nomgap: {
colorsKey: 'nomgap',
swiftEnum: 'NomGapColors',
kotlinObject: 'NomGapTokens',
kotlinPackage: 'com.nomgap.theme',
swiftFile: 'NomGapTheme.generated.swift',
kotlinFile: 'NomGapTokens.generated.kt',
},
actiontrail: {
colorsKey: 'actiontrail',
swiftEnum: 'ActionTrailColors',
kotlinObject: 'ActionTrailTokens',
kotlinPackage: 'com.actiontrail.theme',
swiftFile: 'ActionTrailTheme.generated.swift',
kotlinFile: 'ActionTrailTokens.generated.kt',
},
flowmonk: {
colorsKey: 'flowmonk',
swiftEnum: 'FlowMonkColors',
kotlinObject: 'FlowMonkTokens',
kotlinPackage: 'com.flowmonk.theme',
swiftFile: 'FlowMonkTheme.generated.swift',
kotlinFile: 'FlowMonkTokens.generated.kt',
},
notelett: {
colorsKey: 'notelett',
swiftEnum: 'NoteLettColors',
kotlinObject: 'NoteLettTokens',
kotlinPackage: 'com.notelett.theme',
swiftFile: 'NoteLettTheme.generated.swift',
kotlinFile: 'NoteLettTokens.generated.kt',
},
localmemgpt: {
colorsKey: 'localmemgpt',
swiftEnum: 'LocalMemGPTColors',
kotlinObject: 'LocalMemGPTTokens',
kotlinPackage: 'com.localmemgpt.theme',
swiftFile: 'LocalMemGPTTheme.generated.swift',
kotlinFile: 'LocalMemGPTTokens.generated.kt',
},
localllmlab: {
colorsKey: 'localllmlab',
swiftEnum: 'LocalLLMLabColors',
kotlinObject: 'LocalLLMLabTokens',
kotlinPackage: 'com.localllmlab.theme',
swiftFile: 'LocalLLMLabTheme.generated.swift',
kotlinFile: 'LocalLLMLabTokens.generated.kt',
},
};
// ── 6. Per-product Swift ─────────────────────────────────────────────
function generateProductSwift(productId: string, config: ProductNativeConfig): string {
const productColors = tokens.color[config.colorsKey];
const lines: string[] = [
`// Auto-generated from bytelyst.tokens.json — do not edit manually.`,
`// Product: ${productId}`,
`// Regenerate: cd packages/design-tokens && tsx scripts/generate.ts`,
'',
'import SwiftUI',
'',
`enum ${config.swiftEnum} {`,
];
// Semantic dark colors
lines.push(' // MARK: - Semantic (Dark Theme)');
for (const [key, value] of Object.entries(tokens.color.semantic.dark)) {
if (typeof value === 'string' && value.startsWith('#')) {
lines.push(` static let ${key} = Color(hex: ${hexToUInt(value)})`);
}
}
lines.push('');
// Product-specific colors
if (productColors) {
lines.push(` // MARK: - ${capitalize(productId)} Product Colors`);
for (const [key, value] of Object.entries(productColors)) {
if (typeof value === 'string' && value.startsWith('#')) {
lines.push(` static let ${key} = Color(hex: ${hexToUInt(value)})`);
} else if (typeof value === 'object' && value !== null && 'from' in value) {
const grad = value as { from: string; to: string };
lines.push(
` static let ${key} = Gradient(colors: [Color(hex: ${hexToUInt(grad.from)}), Color(hex: ${hexToUInt(grad.to)})])`
);
}
}
lines.push('');
}
lines.push('}', '');
// Semantic light colors
lines.push(`enum ${config.swiftEnum}Light {`);
lines.push(' // MARK: - Semantic (Light Theme)');
for (const [key, value] of Object.entries(tokens.color.semantic.light)) {
if (typeof value === 'string' && value.startsWith('#')) {
lines.push(` static let ${key} = Color(hex: ${hexToUInt(value)})`);
}
}
lines.push('}', '');
// Spacing
lines.push(`enum ${config.swiftEnum.replace('Colors', '')}Spacing {`);
for (const [key, value] of Object.entries(tokens.spacing)) {
const pad = key.length === 1 ? ' ' : '';
lines.push(` static let x${key}: ${pad}CGFloat = ${value}`);
}
lines.push('}', '');
// Radius
lines.push(`enum ${config.swiftEnum.replace('Colors', '')}Radius {`);
for (const [key, value] of Object.entries(tokens.radius)) {
lines.push(` static let ${key}: CGFloat = ${value}`);
}
lines.push('}', '');
// Motion
lines.push(`enum ${config.swiftEnum.replace('Colors', '')}Motion {`);
for (const [key, value] of Object.entries(tokens.motion.duration)) {
const seconds = (value as number) / 1000;
lines.push(` static let ${key}: Double = ${seconds.toFixed(2)}`);
}
lines.push('}', '');
// Color hex extension (only include if not already in project)
lines.push('// MARK: - Color Hex Extension (import if not already defined)');
lines.push('');
lines.push('extension Color {');
lines.push(' init(hex: UInt, alpha: Double = 1.0) {');
lines.push(' self.init(');
lines.push(' .sRGB,');
lines.push(' red: Double((hex >> 16) & 0xFF) / 255.0,');
lines.push(' green: Double((hex >> 8) & 0xFF) / 255.0,');
lines.push(' blue: Double(hex & 0xFF) / 255.0,');
lines.push(' opacity: alpha');
lines.push(' )');
lines.push(' }');
lines.push('}', '');
return lines.join('\n');
}
// ── 7. Per-product Kotlin ────────────────────────────────────────────
function generateProductKotlin(productId: string, config: ProductNativeConfig): string {
const productColors = tokens.color[config.colorsKey];
const lines: string[] = [
'// Auto-generated from bytelyst.tokens.json — do not edit manually.',
`// Product: ${productId}`,
'// Regenerate: cd packages/design-tokens && tsx scripts/generate.ts',
`package ${config.kotlinPackage}`,
'',
`object ${config.kotlinObject} {`,
'',
];
// Semantic dark
lines.push(' // ── Semantic Colors (Dark Theme) ─────────────────────────────────');
lines.push(' object Dark {');
for (const [key, value] of Object.entries(tokens.color.semantic.dark)) {
if (typeof value === 'string' && value.startsWith('#')) {
lines.push(
` const val ${camelToScreamingSnake(key)} = 0xFF${value.replace('#', '').toUpperCase()}`
);
}
}
lines.push(' }', '');
// Semantic light
lines.push(' // ── Semantic Colors (Light Theme) ────────────────────────────────');
lines.push(' object Light {');
for (const [key, value] of Object.entries(tokens.color.semantic.light)) {
if (typeof value === 'string' && value.startsWith('#')) {
lines.push(
` const val ${camelToScreamingSnake(key)} = 0xFF${value.replace('#', '').toUpperCase()}`
);
}
}
lines.push(' }', '');
// Product-specific colors
if (productColors) {
lines.push(` // ── ${capitalize(productId)} Product Colors ───────────────────────────────`);
lines.push(' object Product {');
for (const [key, value] of Object.entries(productColors)) {
if (typeof value === 'string' && value.startsWith('#')) {
lines.push(
` const val ${camelToScreamingSnake(key)} = 0xFF${value.replace('#', '').toUpperCase()}`
);
} else if (typeof value === 'object' && value !== null && 'from' in value) {
const grad = value as { from: string; to: string };
lines.push(
` const val ${camelToScreamingSnake(key)}_FROM = 0xFF${grad.from.replace('#', '').toUpperCase()}`
);
lines.push(
` const val ${camelToScreamingSnake(key)}_TO = 0xFF${grad.to.replace('#', '').toUpperCase()}`
);
}
}
lines.push(' }', '');
}
// Spacing
lines.push(' // ── Spacing (8pt grid) ───────────────────────────────────────────');
lines.push(' object Spacing {');
for (const [key, value] of Object.entries(tokens.spacing)) {
lines.push(` const val X${key} = ${value}`);
}
lines.push(' }', '');
// Radius
lines.push(' // ── Radius ───────────────────────────────────────────────────────');
lines.push(' object Radius {');
for (const [key, value] of Object.entries(tokens.radius)) {
lines.push(` const val ${key.toUpperCase()} = ${value}`);
}
lines.push(' }', '');
// Typography
lines.push(' // ── Typography ───────────────────────────────────────────────────');
lines.push(' object Typography {');
for (const [key, value] of Object.entries(tokens.typography.fontFamily)) {
const fontName =
typeof value === 'string' ? value.split(',')[0].replace(/'/g, '').trim() : value;
lines.push(` const val FONT_${key.toUpperCase()} = "${fontName}"`);
}
lines.push(' }', '');
// Motion
lines.push(' // ── Motion ───────────────────────────────────────────────────────');
lines.push(' object Motion {');
for (const [key, value] of Object.entries(tokens.motion.duration)) {
lines.push(` const val ${key.toUpperCase()} = ${value}`);
}
lines.push(' }');
lines.push('}', '');
return lines.join('\n');
}
// ── Write all ────────────────────────────────────────────────────────
// Shared semantic tokens (backward compatible)
writeFileSync(resolve(outDir, 'tokens.css'), generateCSS());
writeFileSync(resolve(outDir, 'tokens.ts'), generateTS());
writeFileSync(resolve(outDir, 'MindLystTokens.kt'), generateKotlin());
writeFileSync(resolve(outDir, 'MindLystTheme.swift'), generateSwift());
// Per-product CSS
for (const [productId, config] of Object.entries(PRODUCT_CSS_MAP)) {
const css = generateProductCSS(productId, config.prefix, config.colorsKey);
writeFileSync(resolve(outDir, `${productId}.css`), css);
}
// Per-product Swift + Kotlin
const nativeDir = resolve(outDir, 'native');
mkdirSync(nativeDir, { recursive: true });
for (const [productId, config] of Object.entries(PRODUCT_NATIVE_MAP)) {
const swift = generateProductSwift(productId, config);
writeFileSync(resolve(nativeDir, config.swiftFile), swift);
const kotlin = generateProductKotlin(productId, config);
writeFileSync(resolve(nativeDir, config.kotlinFile), kotlin);
}
console.log(
`Generated 4 shared + ${Object.keys(PRODUCT_CSS_MAP).length} product CSS + ${Object.keys(PRODUCT_NATIVE_MAP).length * 2} native token files in generated/`
);