/** * scaffold-theme / generate.test.ts (2026-05-27) * * Covers Fix #5: Shoelace imports are now conditional on the `useShoelace` * flag. Default OFF so projects without `@shoelace-style/shoelace` in their * package.json don't fail Vite resolution at boot. */ import { describe, it, expect } from 'vitest' import { generate } from '../generate.js' import type { ScaffoldThemeInput } from '../types.js' function fixture(overrides: Partial = {}): ScaffoldThemeInput { return { projectPath: '/test', appCode: 'TestV2', force: false, useShoelace: false, ...overrides, } as ScaffoldThemeInput } describe('scaffold-theme / generate — Fix #5 (Shoelace imports conditional)', () => { it('omits Shoelace @import lines by default (useShoelace not set)', () => { const files = generate(fixture()) const css = files.find(f => f.path.endsWith('index.css'))! expect(css.content).not.toMatch(/@shoelace-style\/shoelace/) expect(css.content).not.toMatch(/themes\/light\.css/) expect(css.content).not.toMatch(/themes\/dark\.css/) }) it('emits Shoelace @import lines when useShoelace: true', () => { const files = generate(fixture({ useShoelace: true })) const css = files.find(f => f.path.endsWith('index.css'))! expect(css.content).toMatch(/@import '@shoelace-style\/shoelace\/dist\/themes\/light\.css';/) expect(css.content).toMatch(/@import '@shoelace-style\/shoelace\/dist\/themes\/dark\.css' \(prefers-color-scheme: dark\);/) }) it('always emits Tailwind + SmartStack theme imports regardless of useShoelace', () => { for (const useShoelace of [true, false]) { const css = generate(fixture({ useShoelace })).find(f => f.path.endsWith('index.css'))! expect(css.content, `useShoelace=${useShoelace}`).toMatch(/@import 'tailwindcss';/) expect(css.content, `useShoelace=${useShoelace}`).toMatch(/@import '@atlashub\/smartstack\/theme\.css';/) } }) it('emits the accent ramp from the PRD theme slice when provided', () => { const files = generate(fixture({ prdSlice: { theme: { accent: '#ff0000' } }, })) const css = files.find(f => f.path.endsWith('index.css'))! expect(css.content).toMatch(/--color-accent-500: #ff0000;/) }) }) describe('scaffold-theme / generate — DataTable surface tokens (--table-*)', () => { // The owned src/components/ui/DataTable.tsx consumes these; without them the // table renders un-themed. Guards against silently dropping the token block. it('emits every --table-* token the DataTable component reads', () => { const css = generate(fixture()).find(f => f.path.endsWith('index.css'))! for (const tok of [ '--table-header-bg', '--table-header-text', '--table-zebra-bg', '--table-row-hover-bg', '--table-row-selected-bg', '--table-border-color', '--table-radius', '--table-cell-px', '--table-cell-py', ]) { expect(css.content, `missing ${tok}`).toContain(`${tok}:`) } }) it('resolves the table tokens through package tokens (dark mode automatic, no separate .dark block)', () => { const css = generate(fixture()).find(f => f.path.endsWith('index.css'))! expect(css.content).toMatch(/--table-header-bg: var\(--bg-secondary/) expect(css.content).toMatch(/--table-border-color: var\(--border-color/) // --table-radius follows the configured card radius expect(css.content).toMatch(/--table-radius: var\(--radius-card, 8px\)/) }) }) describe('scaffold-theme / generate — form-control radius (square vs rounded)', () => { it('emits --radius-input and --radius-button (default 8px) alongside --radius-card', () => { const css = generate(fixture()).find(f => f.path.endsWith('index.css'))! expect(css.content).toMatch(/--radius-input: 8px;/) expect(css.content).toMatch(/--radius-button: 8px;/) expect(css.content).toMatch(/--radius-card: 8px;/) }) it('radiusControl: 0 gives square form controls, independent of radiusCard', () => { const css = generate(fixture({ prdSlice: { theme: { radiusControl: 0, radiusCard: 12 } }, })).find(f => f.path.endsWith('index.css'))! expect(css.content).toMatch(/--radius-input: 0px;/) expect(css.content).toMatch(/--radius-button: 0px;/) expect(css.content).toMatch(/--radius-card: 12px;/) }) }) describe('scaffold-theme / generate — dataviz tokens (dashboards/charts)', () => { it('emits the 8-slot categorical palette in BOTH :root and .dark, platform token first', () => { const css = generate(fixture()).find(f => f.path.endsWith('index.css'))! for (let i = 1; i <= 8; i++) { // Each slot defined twice (light + dark). The platform theme runtime // (--dataviz-cat-i, written from the tenant's UI configuration) wins; the // accent-derived hex is only the fallback. const matches = css.content.match( new RegExp(`--dataviz-${i}: var\\(--dataviz-cat-${i}, #[0-9a-f]{6}\\);`, 'g'), ) ?? [] expect(matches.length, `--dataviz-${i}`).toBe(2) } }) it('emits chart chrome (grid/axis/tooltip) in both modes and KPI aliases once', () => { const css = generate(fixture()).find(f => f.path.endsWith('index.css'))! expect((css.content.match(/--chart-grid:/g) ?? []).length).toBe(2) expect((css.content.match(/--chart-axis:/g) ?? []).length).toBe(2) expect((css.content.match(/--chart-tooltip-bg:/g) ?? []).length).toBe(2) expect((css.content.match(/--chart-tooltip-text:/g) ?? []).length).toBe(2) // Chrome resolves through the live theme tokens before any static fallback expect(css.content).toMatch(/--chart-tooltip-bg: var\(--dataviz-surface-elevated, var\(--bg-card, #[0-9a-f]{6}\)\);/) expect(css.content).toMatch(/--chart-axis: var\(--text-muted, #[0-9a-f]{6}\);/) // KPI aliases follow the platform trend colors, then the status tokens expect(css.content).toMatch(/--kpi-value: var\(--text-primary\);/) expect(css.content).toMatch(/--kpi-label: var\(--text-muted\);/) expect(css.content).toMatch(/--kpi-trend-up: var\(--dataviz-trend-up, var\(--success-text\)\);/) expect(css.content).toMatch(/--kpi-trend-down: var\(--dataviz-trend-down, var\(--error-text\)\);/) }) it('honors a caller-provided dataviz palette, derives the rest from the accent', () => { const css = generate(fixture({ prdSlice: { theme: { accent: '#3b82f6', dataviz: ['#aabbcc', '#ddeeff'] } }, })).find(f => f.path.endsWith('index.css'))! expect(css.content).toMatch(/--dataviz-1: var\(--dataviz-cat-1, #aabbcc\);/) expect(css.content).toMatch(/--dataviz-2: var\(--dataviz-cat-2, #ddeeff\);/) // slot 3+ are derived (valid hex, not the provided ones) expect(css.content).toMatch(/--dataviz-3: var\(--dataviz-cat-3, #[0-9a-f]{6}\);/) }) it('never reuses a status token hex for a dataviz slot (R17 — decorative ≠ status)', () => { const css = generate(fixture()).find(f => f.path.endsWith('index.css'))! const dataviz = [...css.content.matchAll(/--dataviz-\d: var\(--dataviz-cat-\d, (#[0-9a-f]{6})\);/g)].map((m) => m[1]) // the generated palette must not collide with the semantic status hues for (const status of ['#10b981', '#f59e0b', '#ef4444']) { expect(dataviz).not.toContain(status) } }) }) describe('scaffold-theme / generate — BUG B (no premature comment close)', () => { // A CSS block comment whose BODY contains "*/" closes the comment early; the // remaining text becomes invalid CSS and Vite/PostCSS 500s with "Unknown word". // Strip valid (non-greedy) comments first — a comment whose body has an early // "*/" gets truncated there, leaving a dangling "*/" behind. Globs inside // strings (@source '.../dist/**/*.js') are consumed as pseudo-comments and // leave nothing dangling, so they don't false-positive. it('leaves no dangling "*/" after stripping valid comments (no early comment close)', () => { const css = generate(fixture()).find(f => f.path.endsWith('index.css'))! const stripped = css.content.replace(/\/\*[\s\S]*?\*\//g, '') expect(stripped, 'a dangling "*/" ⇒ a comment body contained "*/" → PostCSS 500').not.toContain('*/') }) it('does not emit the "--bg-*/" glob that historically broke the DataTable comment', () => { const css = generate(fixture()).find(f => f.path.endsWith('index.css'))! expect(css.content).not.toContain('--bg-*/') expect(css.content).not.toContain('--text-*/') }) }) describe('scaffold-theme / generate — iOS focus-zoom guard (font-scale coherence)', () => { it('emits the pointer:coarse fallback so text-sm fields never trigger the Safari auto-zoom', () => { const css = generate(fixture()).find(f => f.path.endsWith('index.css'))! expect(css.content).toMatch(/@media \(pointer: coarse\) \{/) expect(css.content).toMatch(/input\.text-sm, textarea\.text-sm, select\.text-sm \{\s*\n\s*font-size: 1rem;/) }) })