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
880 lines
36 KiB
TypeScript
880 lines
36 KiB
TypeScript
/**
|
||
* 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/`
|
||
);
|