/** * scaffold-theme/generate.ts — Build the src/index.css file content from * the PRD theme slice (or SmartStack defaults when absent). * * Pure function — no I/O. The caller writes the returned files to disk. */ import type { GeneratedFile, ScaffoldThemeInput, ThemeTokens } from './types.js'; const DEFAULTS: Required = { accent: '#3b82f6', darkBg: '#161618', lightBg: '#ffffff', radiusCard: 8, radiusControl: 8, dataviz: [], }; const HEADER = `/* AUTO-GENERATED — re-run scaffold-theme to refresh. Add @customised at the top to opt out. */`; function shadeHex(hex: string, lightness: number): string { // Quick & dirty: blend hex toward white (lightness > 0) or black (< 0). // lightness in [-1, 1]. 0 = no change. const r = parseInt(hex.slice(1, 3), 16); const g = parseInt(hex.slice(3, 5), 16); const b = parseInt(hex.slice(5, 7), 16); const t = lightness > 0 ? 255 : 0; const k = Math.abs(lightness); const mix = (c: number) => Math.round(c * (1 - k) + t * k); const toHex = (n: number) => n.toString(16).padStart(2, '0'); return `#${toHex(mix(r))}${toHex(mix(g))}${toHex(mix(b))}`; } function hexToHsl(hex: string): [number, number, number] { const r = parseInt(hex.slice(1, 3), 16) / 255; const g = parseInt(hex.slice(3, 5), 16) / 255; const b = parseInt(hex.slice(5, 7), 16) / 255; const max = Math.max(r, g, b); const min = Math.min(r, g, b); const l = (max + min) / 2; const d = max - min; if (d === 0) return [0, 0, l]; const s = d / (1 - Math.abs(2 * l - 1)); let h: number; if (max === r) h = (((g - b) / d) % 6 + 6) % 6; else if (max === g) h = (b - r) / d + 2; else h = (r - g) / d + 4; return [h * 60, s, l]; } function hslToHex(h: number, s: number, l: number): string { h = ((h % 360) + 360) % 360; const c = (1 - Math.abs(2 * l - 1)) * s; const x = c * (1 - Math.abs(((h / 60) % 2) - 1)); const m = l - c / 2; let r = 0; let g = 0; let b = 0; if (h < 60) { r = c; g = x; } else if (h < 120) { r = x; g = c; } else if (h < 180) { g = c; b = x; } else if (h < 240) { g = x; b = c; } else if (h < 300) { r = x; b = c; } else { r = c; b = x; } const toHex = (n: number) => Math.round((n + m) * 255).toString(16).padStart(2, '0'); return `#${toHex(r)}${toHex(g)}${toHex(b)}`; } /** * 8-slot categorical palette: caller-provided hex first, remaining slots * derived by rotating the accent hue (45° steps) at a fixed saturation, with a * lighter lightness in dark mode for legibility on `--bg-card`. */ function categoricalPalette(accent: string, provided: string[], mode: 'light' | 'dark'): string[] { const [baseHue] = hexToHsl(accent); const s = 0.62; const l = mode === 'dark' ? 0.62 : 0.48; const out: string[] = []; for (let i = 0; i < 8; i++) { out.push(provided[i] ?? hslToHex(baseHue + i * 45, s, l)); } return out; } export function generate(spec: ScaffoldThemeInput): GeneratedFile[] { const tokens: Required = { ...DEFAULTS, ...(spec.prdSlice?.theme ?? {}), }; // Generate accent ramp (300, 400, 500, 600, 700) from the base. const a700 = shadeHex(tokens.accent, -0.3); const a600 = shadeHex(tokens.accent, -0.15); const a500 = tokens.accent; const a400 = shadeHex(tokens.accent, 0.15); const a300 = shadeHex(tokens.accent, 0.3); // Categorical dataviz palette (light + dark) + chart chrome. // // Each slot DEFERS to the platform theme first: the @atlashub/smartstack theme // runtime (ThemeContext.applyDataViz) writes --dataviz-cat-1..12, // --dataviz-trend-*, --dataviz-surface* on from the tenant's UI // configuration, so a theme change in the admin UI flows straight through to // the charts. The hue-rotated value derived from the accent is only the // FALLBACK, for apps that do not run the platform theme runtime. Same idea for // the chrome: grid/axis/tooltip resolve through the theme's own surface and // text tokens before falling back to a static neutral. const dvVars = (p: string[]): string => p.map((c, i) => ` --dataviz-${i + 1}: var(--dataviz-cat-${i + 1}, ${c});`).join('\n'); const datavizRootCss = ` /* ─── Data visualization (categorical palette + chart chrome) ───── Platform theme first (--dataviz-cat-*, written live by the SDK from the UI configuration), accent-derived fallback second. */ ${dvVars(categoricalPalette(tokens.accent, tokens.dataviz, 'light'))} --chart-grid: var(--border-subtle, var(--border-color, #e5e7eb)); --chart-axis: var(--text-muted, #6b7280); --chart-tooltip-bg: var(--dataviz-surface-elevated, var(--bg-card, #ffffff)); --chart-tooltip-text: var(--text-primary, #111827); /* KPI semantic aliases — platform trend colors first, status tokens second */ --kpi-value: var(--text-primary); --kpi-label: var(--text-muted); --kpi-trend-up: var(--dataviz-trend-up, var(--success-text)); --kpi-trend-down: var(--dataviz-trend-down, var(--error-text));`; const datavizDarkCss = ` /* ─── Data visualization (dark) ─────────────────────────────────── */ ${dvVars(categoricalPalette(tokens.accent, tokens.dataviz, 'dark'))} --chart-grid: var(--border-subtle, var(--border-color, #2e2e35)); --chart-axis: var(--text-muted, #9ca3af); --chart-tooltip-bg: var(--dataviz-surface-elevated, var(--bg-card, #1e1e22)); --chart-tooltip-text: var(--text-primary, #f5f5f7);`; // Shoelace imports are conditional (Fix #5, 2026-05-27). Projects that // don't depend on `@shoelace-style/shoelace` in their package.json fail // Vite resolution at boot with `Can't resolve '@shoelace-style/shoelace/…'`. // The default is OFF; ba-develop / users opt in by passing useShoelace:true. const shoelaceImports = spec.useShoelace ? `@import '@shoelace-style/shoelace/dist/themes/light.css'; @import '@shoelace-style/shoelace/dist/themes/dark.css' (prefers-color-scheme: dark); ` : '' const css = `${HEADER} @import 'tailwindcss'; @import '@atlashub/smartstack/theme.css'; @source '../node_modules/@atlashub/smartstack/dist/**/*.js'; ${shoelaceImports} :root { /* ─── Brand accent ramp (derived from --color-accent-500) ────────── */ --color-accent-300: ${a300}; --color-accent-400: ${a400}; --color-accent-500: ${a500}; --color-accent-600: ${a600}; --color-accent-700: ${a700}; /* ─── App backgrounds ───────────────────────────────────────────── */ --bg-app: ${tokens.lightBg}; --bg-card: #ffffff; --bg-muted: #f5f5f7; /* ─── Status palette ────────────────────────────────────────────── */ --success-bg: #ecfdf5; --success-border: #10b981; --success-text: #065f46; --warning-bg: #fffbeb; --warning-border: #f59e0b; --warning-text: #92400e; --error-bg: #fef2f2; --error-border: #ef4444; --error-text: #991b1b; --info-bg: #eff6ff; --info-border: #3b82f6; --info-text: #1e40af; ${datavizRootCss} /* ─── Geometry ──────────────────────────────────────────────────── */ --radius-card: ${tokens.radiusCard}px; /* Form-control radius (inputs, buttons, datepicker, dropdowns). 0 = square corners, 8 = rounded. DateInput / EnumSelect / MultiSelect read these. */ --radius-input: ${tokens.radiusControl}px; --radius-button: ${tokens.radiusControl}px; /* ─── Component surfaces — DataTable (read by src/components/ui/DataTable.tsx) ── Defined here so the local table component picks up the theme. They resolve through the package's --bg-…, --text-… and --border-color tokens, so dark mode is automatic (the package redefines those under .dark; the var() indirection is resolved at use-time on the element). Override any of them above @customised. (Do NOT write a bare "* + /" glob here — it closes the CSS comment early and 500s PostCSS; use the "…" ellipsis instead.) */ --table-header-bg: var(--bg-secondary, #f4f4f5); --table-header-text: var(--text-secondary, var(--text-muted, #52525b)); --table-zebra-bg: var(--bg-secondary, #f4f4f5); --table-row-hover-bg: var(--bg-hover, var(--bg-tertiary, #e4e4e7)); --table-row-selected-bg: var(--accent-bg, var(--bg-muted, #eef2ff)); --table-border-color: var(--border-color, #e4e4e7); --table-radius: var(--radius-card, ${tokens.radiusCard}px); --table-cell-px: 1rem; --table-cell-py: 0.75rem; /* ─── Elevation & motion (plan UI 3.3) ──────────────────────────── One shadow scale + one motion scale for every scaffolded surface — primitives read these instead of raw Tailwind shadow-… / duration-… classes (ui-polish R30 flags strays), so a theme can soften or flatten the whole app in one place. Dark mode redefines the shadows (light shadows vanish on dark surfaces — alpha must rise). */ --shadow-card: 0 1px 2px rgb(0 0 0 / 0.05), 0 1px 3px rgb(0 0 0 / 0.06); --shadow-overlay: 0 10px 25px -5px rgb(0 0 0 / 0.15), 0 8px 10px -6px rgb(0 0 0 / 0.10); --motion-fast: 120ms; --motion-normal: 200ms; --motion-ease: cubic-bezier(0.2, 0, 0, 1); /* ─── Page layout (responsive; rem so they scale with zoom) ──────── */ /* Focused-page max measure: forms / wizards. Below it the column is fluid. */ --content-max-narrow: 42rem; /* Horizontal gutter so full-width content clears the edge chrome (doc launcher on the right, sidebar collapse handle on the left). */ --content-gutter-x: 3rem; /* ─── Shoelace overrides (sl-color-primary mapped to brand accent) ── */ --sl-color-primary-50: ${shadeHex(a500, 0.8)}; --sl-color-primary-100: ${shadeHex(a500, 0.65)}; --sl-color-primary-200: ${shadeHex(a500, 0.5)}; --sl-color-primary-300: ${a300}; --sl-color-primary-400: ${a400}; --sl-color-primary-500: ${a500}; --sl-color-primary-600: ${a600}; --sl-color-primary-700: ${a700}; --sl-color-primary-800: ${shadeHex(a500, -0.45)}; --sl-color-primary-900: ${shadeHex(a500, -0.6)}; --sl-color-primary-950: ${shadeHex(a500, -0.75)}; } .dark { --bg-app: ${tokens.darkBg}; --bg-card: #1e1e22; --bg-muted: #26262b; --success-bg: #052e2b; --success-border: #10b981; --success-text: #34d399; --warning-bg: #2a1f0a; --warning-border: #f59e0b; --warning-text: #fbbf24; --error-bg: #2a0a0a; --error-border: #ef4444; --error-text: #fca5a5; --info-bg: #0a1a2e; --info-border: #3b82f6; --info-text: #93c5fd; ${datavizDarkCss} --shadow-card: 0 1px 2px rgb(0 0 0 / 0.4), 0 1px 3px rgb(0 0 0 / 0.35); --shadow-overlay: 0 10px 25px -5px rgb(0 0 0 / 0.55), 0 8px 10px -6px rgb(0 0 0 / 0.4); } html, body { background-color: var(--bg-app); color-scheme: light dark; } /* iOS focus-zoom guard: Safari auto-zooms into any focused field whose font-size is below 16px. The UI scale is text-sm (14px) on desktop; on touch devices the typed surface falls back to 1rem so focusing a filter or form field never triggers the viewport zoom. */ @media (pointer: coarse) { input.text-sm, textarea.text-sm, select.text-sm { font-size: 1rem; } } `; return [{ path: 'src/index.css', content: css }]; }