diff --git a/.github/agents/CLI.release.agent.md b/.github/agents/CLI.release.agent.md index 8201b84..192ecdb 100644 --- a/.github/agents/CLI.release.agent.md +++ b/.github/agents/CLI.release.agent.md @@ -41,8 +41,15 @@ All commands in this agent run from the **CLI package directory**: `packages/cli 2. **Verify CHANGELOG**: Read `packages/cli/CHANGELOG.md`. Confirm: - An entry exists for this version (e.g., `## [0.6.0]`) - The entry has a date (use today if missing) - - A **Summary** line exists at the top of the version entry (immediately after the heading), providing a high-level overview of this release - - The entry has content under Added/Changed/Removed/Fixed + - A **Summary paragraph** exists at the top of the version entry (immediately after the heading). This is written last, after all bullets are done. It must: + - Open with the most important user-facing capability in this release + - Answer "So what?" — what can users now do that they couldn't before? + - Avoid implementation vocabulary (registries, transformer pipelines, ref swaps, upstream) in favor of plain language + - Be 2–4 sentences; every sentence should carry meaning a user cares about + - If missing or too technical, draft a replacement and update the file before continuing + - Individual bullets lead with a **bold plain sentence** that describes what the change *enables* — not just what was added. "A new command for analyzing props across a catalog" not "New command for on-demand analysis passes over component specs." If bullets read like API docs, rewrite them before continuing. + - **Empty sections are removed** — if `### Changed`, `### Removed`, or `### Fixed` has no bullets, delete that section heading entirely. Do not leave placeholder empty sections. + - The entry has content under at least one of Added/Changed/Fixed - A **Dependency updates** subsection summarizes what changed in upstream packages. To write this: 1. Read the specs-schema CHANGELOG (`packages/schema/CHANGELOG.md`) for the `` entry 2. Read the specs-from-figma CHANGELOG for the `` entry diff --git a/.github/agents/Schema.release.agent.md b/.github/agents/Schema.release.agent.md index 6454e2f..34d1c3a 100644 --- a/.github/agents/Schema.release.agent.md +++ b/.github/agents/Schema.release.agent.md @@ -36,13 +36,15 @@ All commands in this agent run from the **schema package directory**: `packages/ 2. **Verify CHANGELOG**: Read `packages/schema/CHANGELOG.md`. Confirm: - An entry exists for this version (e.g., `## [0.16.0]`) - The entry has an appended date (e.g., `## [0.16.0] - 2026-04-05`). If missing, use today's date. - - A **Summary** line exists at the top of the version entry (immediately after the heading), providing a 3–4 sentence high-level overview of the release changes. - - If missing, draft one by reading the entry's Added/Changed/Removed/Fixed sections and add it. - - When summarizing: - - Combine related points into a single summary sentence where possible - - Favor summarizing the most impactful changes, such as features at a higher-level in the spec and/or with more individual changelog items - - - The entry groups content under Added/Changed/Removed/Fixed + - A **Summary paragraph** exists at the top of the version entry (immediately after the heading). This is written last, after all bullets are done. It must: + - Open with the most important user-facing capability in this release + - Answer "So what?" — what can consumers of this schema now express or do that they couldn't before? + - Avoid implementation vocabulary (type aliases, discriminated unions, internal identifiers) in favor of plain language about what the types *enable* + - Be 2–4 sentences; every sentence should carry meaning a user cares about + - If missing or too technical, draft a replacement and update the file before continuing + - Individual bullets lead with a **bold plain sentence** that describes what the property or type *enables* — not just what was added. If bullets read like type declaration docs, rewrite them before continuing. + - **Empty sections are removed** — if `### Changed`, `### Removed`, or `### Fixed` has no bullets, delete that section heading entirely. + - The entry groups content under at least one of Added/Changed/Removed/Fixed If incomplete, STOP and report what's missing. diff --git a/packages/cli/src/commands/TransformCommand.ts b/packages/cli/src/commands/TransformCommand.ts index 67789ff..3326300 100644 --- a/packages/cli/src/commands/TransformCommand.ts +++ b/packages/cli/src/commands/TransformCommand.ts @@ -12,6 +12,7 @@ const ERROR_CODES = { SUCCESS: 0, INVALID_ARGS: 2, FILE_ERROR: 3, GENERAL_ERROR: interface TransformOptions { output?: string; config?: string; + components?: string[]; verbose: boolean; } @@ -20,6 +21,7 @@ export const Transform = new Command('transform') .argument('[transformers...]', 'Transformer names to run (default: contract)') .option('-o, --output ', 'Path to the specs directory (input and output)') .option('--config ', 'Path to config file (specs.config.yaml)') + .option('--components ', 'Only transform these component folders (default: all)') .option('--verbose', 'Enable detailed logging', false) .action(async (transformerNames: string[], options: TransformOptions) => { try { @@ -67,11 +69,20 @@ export const Transform = new Command('transform') // Discover component subfolders — each must contain api.yaml const entries = await fs.readdir(outputPath, { withFileTypes: true }); - const componentDirs = entries + let componentDirs = entries .filter(e => e.isDirectory()) .map(e => e.name) .filter(name => fs.existsSync(path.join(outputPath, name, 'api.yaml'))); + if (options.components && options.components.length > 0) { + const requested = new Set(options.components); + const missing = options.components.filter(c => !componentDirs.includes(c)); + for (const m of missing) { + console.warn(`Warning: component "${m}" not found in ${outputPath} — skipping`); + } + componentDirs = componentDirs.filter(name => requested.has(name)); + } + if (componentDirs.length === 0) { console.error(`Error: no component directories with api.yaml found in ${outputPath}`); console.error('Tip: run `specs generate --split-components --split-concerns --use-subfolders` first'); diff --git a/packages/cli/src/transforms/Contract.ts b/packages/cli/src/transforms/Contract.ts index ff1240f..820a352 100644 --- a/packages/cli/src/transforms/Contract.ts +++ b/packages/cli/src/transforms/Contract.ts @@ -1,7 +1,9 @@ import fs from 'fs-extra'; import path from 'path'; +import yaml from 'yaml'; import type { Transformer, TransformerContext } from '../Types/Transformer.js'; import { buildOmittedProps } from './states.js'; +import { analyzeVariants, type SlotInfo } from './react/variantAnalysis.js'; export class ContractTransformer implements Transformer { readonly name = 'contract'; @@ -11,18 +13,34 @@ export class ContractTransformer implements Transformer { const prefix = toPascalCase(componentKey); const omittedProps = buildOmittedProps(context.processingStates ?? {}); + // variants.yaml is optional input — without it, contracts omit slot rules. + const variantsPath = path.join(outputDir, 'variants.yaml'); + const variantsYaml = fs.existsSync(variantsPath) + ? (yaml.parse(await fs.readFile(variantsPath, 'utf-8')) as Record) + : undefined; + // Main component - const mainLines = buildContractLines(prefix, (apiYaml.props ?? {}) as Record, omittedProps); - await fs.writeFile(path.join(outputDir, 'contract.ts'), mainLines.join('\n'), 'utf-8'); + const slots = variantsYaml + ? analyzeVariants(apiYaml, variantsYaml, context.processingStates ?? {}).slots + : []; + const mainLines = buildContractLines(prefix, (apiYaml.props ?? {}) as Record, omittedProps, slots); + const generatedDir = path.join(outputDir, 'generated'); + await fs.ensureDir(generatedDir); + await fs.writeFile(path.join(generatedDir, 'contract.ts'), mainLines.join('\n'), 'utf-8'); // Subcomponents — each gets its own subfolder/contract.ts const subcomponents = (apiYaml.subcomponents ?? {}) as Record; + const subVariants = (variantsYaml?.subcomponents ?? {}) as Record; for (const [subKey, subRaw] of Object.entries(subcomponents)) { const sub = subRaw as Record; const subPrefix = `${prefix}${toPascalCase(subKey)}`; const subProps = (sub.props ?? {}) as Record; - const subLines = buildContractLines(subPrefix, subProps, omittedProps); - const subDir = path.join(outputDir, subKey); + const subVariantsYaml = subVariants[subKey] as Record | undefined; + const subSlots = subVariantsYaml + ? analyzeVariants(sub, subVariantsYaml, context.processingStates ?? {}).slots + : []; + const subLines = buildContractLines(subPrefix, subProps, omittedProps, subSlots); + const subDir = path.join(outputDir, subKey, 'generated'); await fs.ensureDir(subDir); await fs.writeFile(path.join(subDir, 'contract.ts'), subLines.join('\n'), 'utf-8'); } @@ -33,6 +51,7 @@ function buildContractLines( prefix: string, props: Record, omittedProps: Set, + slots: SlotInfo[], ): string[] { const lines: string[] = [ '// Generated. Do not edit — regenerate with `specs transform`.', @@ -97,6 +116,54 @@ function buildContractLines( lines.push(''); } + if (slots.length > 0) { + lines.push(...buildSlotLines(prefix, slots)); + } + + return lines; +} + +/** + * Emits the Slots interface + SlotVisibility rules. Slots are content + * injection points: anatomy `slot` elements (unknown content) and bound `text` + * elements (string content). A slot is required when its rule is `always`. + */ +function buildSlotLines(prefix: string, slots: SlotInfo[]): string[] { + const lines: string[] = []; + + lines.push(`export interface ${prefix}Slots {`); + for (const slot of slots) { + const optional = slot.rule.kind === 'always' ? '' : '?'; + const tsType = slot.slotType === 'text' ? 'string' : 'unknown'; + lines.push(` ${slot.elementKey}${optional}: ${tsType};`); + } + lines.push('}'); + lines.push(''); + + lines.push(`export type ${prefix}SlotVisibility =`); + lines.push(` | { kind: 'always' }`); + lines.push(` | { kind: 'whenTrue'; prop: keyof ${prefix}Props }`); + lines.push(` | { kind: 'whenNotNull'; prop: keyof ${prefix}Props }`); + lines.push(` | { kind: 'whenValue'; prop: keyof ${prefix}Props; value: string };`); + lines.push(''); + + lines.push(`export const ${prefix}SlotRules = {`); + for (const slot of slots) { + const rule = slot.rule; + let value: string; + if (rule.kind === 'always') { + value = `{ kind: 'always' }`; + } else if (rule.kind === 'whenValue') { + value = `{ kind: 'whenValue', prop: '${rule.prop}', value: ${JSON.stringify(rule.value)} }`; + } else { + value = `{ kind: '${rule.kind}', prop: '${rule.prop}' }`; + } + const comment = slot.warning ? ` // ${slot.warning}` : ''; + lines.push(` ${slot.elementKey}: ${value},${comment}`); + } + lines.push(`} satisfies Record;`); + lines.push(''); + return lines; } diff --git a/packages/cli/src/transforms/Css.ts b/packages/cli/src/transforms/Css.ts index 08dab63..f76c5aa 100644 --- a/packages/cli/src/transforms/Css.ts +++ b/packages/cli/src/transforms/Css.ts @@ -7,6 +7,7 @@ import { layoutToCSS } from './css/layoutToCSS.js'; import { toKebab } from './css/values.js'; import { CONCEPT_TABLE, buildStateLookup } from './states.js'; import { resolveRules } from './css/rules/index.js'; +import { parseLayout, type LayoutNode } from './react/variantAnalysis.js'; export class CssTransformer implements Transformer { readonly name = 'css'; @@ -25,7 +26,9 @@ export class CssTransformer implements Transformer { const componentClass = toKebab(componentKey); const lines = buildCssLines(componentClass, variantsYaml, tokensFormat, context); - await fs.writeFile(path.join(outputDir, 'styles.css'), lines.join('\n'), 'utf-8'); + const generatedDir = path.join(outputDir, 'generated'); + await fs.ensureDir(generatedDir); + await fs.writeFile(path.join(generatedDir, 'styles.css'), lines.join('\n'), 'utf-8'); // Subcomponents — each gets styles.css in its own subfolder const subcomponents = (variantsYaml.subcomponents ?? {}) as Record; @@ -33,7 +36,7 @@ export class CssTransformer implements Transformer { const subVariantsYaml = subRaw as Record; const subClass = toKebab(subKey); const subLines = buildCssLines(subClass, subVariantsYaml, tokensFormat, context); - const subDir = path.join(outputDir, subKey); + const subDir = path.join(outputDir, subKey, 'generated'); await fs.ensureDir(subDir); await fs.writeFile(path.join(subDir, 'styles.css'), subLines.join('\n'), 'utf-8'); } @@ -61,12 +64,43 @@ function buildCssLines( // ── Default styles ───────────────────────────────────────────────────────── const defaultBlock = variantsYaml.default as Record | undefined; const defaultElements = (defaultBlock?.elements ?? {}) as Record>; + const variantList = (variantsYaml.variants ?? []) as Array>; + + // Elements absent from the default layout but added by variant layouts are + // hidden at base and un-hidden under each including variant's selector. + const defaultKeys = new Set(); + collectLayoutKeys(parseLayout(defaultBlock?.layout), defaultKeys); + const structuralKeys = new Set(); + // Parents of absolutely-positioned elements must establish a containing + // block, or inset: 0 resolves against the viewport. Their non-absolute + // siblings must also be positioned: layout order is Figma children order + // (first = back-most, last on top), and only positioned siblings paint in + // DOM order — an absolute element would otherwise jump above static ones. + const needsRelative = new Set(); + { + const layouts = [parseLayout(defaultBlock?.layout), ...variantList.map(v => parseLayout(v.layout))]; + for (const layout of layouts) { + collectStackingFixes(layout, defaultElements, needsRelative); + if (layout !== layouts[0]) { + const keys = new Set(); + collectLayoutKeys(layout, keys); + for (const k of keys) if (!defaultKeys.has(k)) structuralKeys.add(k); + } + } + } for (const [elemKey, elem] of Object.entries(defaultElements)) { const selector = elemSelector(componentClass, elemKey); const styles = (elem.styles ?? {}) as Record; const decls = [...layoutToCSS(styles, tokensFormat), ...styleToCSS(styles, tokensFormat)]; + if (needsRelative.has(elemKey) && !decls.some(d => d.startsWith('position:'))) { + decls.push('position: relative'); + } + if (structuralKeys.has(elemKey)) { + decls.push('display: none'); + } + if (decls.length > 0) { lines.push(`${selector} {`); for (const d of decls) lines.push(` ${d};`); @@ -136,12 +170,31 @@ function buildCssLines( const variantElements = (variant.elements ?? {}) as Record>; - for (const [elemKey, elem] of Object.entries(variantElements)) { + // Structural layout changes: a variant layout that adds an element + // un-hides it; one that drops a default element hides it. + const displayDecls = new Map(); + if (variant.layout) { + const variantKeys = new Set(); + collectLayoutKeys(parseLayout(variant.layout), variantKeys); + for (const key of variantKeys) { + if (!structuralKeys.has(key)) continue; + const styles = (defaultElements[key]?.styles ?? {}) as Record; + displayDecls.set(key, `display: ${styles.layoutMode ? 'flex' : 'block'}`); + } + for (const key of defaultKeys) { + if (key !== 'root' && !variantKeys.has(key)) displayDecls.set(key, 'display: none'); + } + } + + const elemKeys = new Set([...Object.keys(variantElements), ...displayDecls.keys()]); + for (const elemKey of elemKeys) { const elemSuffix = elemKey === 'root' ? '' : ` ${elemSelector(componentClass, elemKey)}`; const selector = rootSelectors.map(s => `${s}${elemSuffix}`).join(',\n'); - const styles = (elem.styles ?? {}) as Record; + const styles = (variantElements[elemKey]?.styles ?? {}) as Record; const decls = [...layoutToCSS(styles, tokensFormat), ...styleToCSS(styles, tokensFormat)]; + const display = displayDecls.get(elemKey); + if (display && !decls.some(d => d.startsWith('display:'))) decls.push(display); if (decls.length > 0) { lines.push(`${selector} {`); @@ -155,6 +208,39 @@ function buildCssLines( return lines; } +function collectLayoutKeys(nodes: LayoutNode[], into: Set): void { + for (const node of nodes) { + into.add(node.key); + collectLayoutKeys(node.children, into); + } +} + +/** + * Mark elements that need `position: relative` for correct stacking: the + * layout parent of any absolutely-positioned element (containing block), and + * that element's non-absolute siblings (so painting follows layout order — + * last on top — instead of absolute elements covering static siblings). + */ +function collectStackingFixes( + nodes: LayoutNode[], + elements: Record>, + into: Set, + parent?: string, +): void { + const isAbsolute = (key: string) => + ((elements[key]?.styles ?? {}) as Record).position === 'ABSOLUTE'; + + if (nodes.some(n => isAbsolute(n.key))) { + if (parent) into.add(parent); + for (const n of nodes) { + if (!isAbsolute(n.key)) into.add(n.key); + } + } + for (const node of nodes) { + collectStackingFixes(node.children, elements, into, node.key); + } +} + function elemSelector(componentClass: string, elemKey: string): string { return elemKey === 'root' ? `.${componentClass}` diff --git a/packages/cli/src/transforms/React.ts b/packages/cli/src/transforms/React.ts new file mode 100644 index 0000000..2c7013f --- /dev/null +++ b/packages/cli/src/transforms/React.ts @@ -0,0 +1,352 @@ +import fs from 'fs-extra'; +import path from 'path'; +import yaml from 'yaml'; +import type { Transformer, TransformerContext } from '../Types/Transformer.js'; +import { toKebab } from './css/values.js'; +import { CONCEPT_TABLE } from './states.js'; +import { analyzeVariants, type LayoutNode, type VariantAnalysis } from './react/variantAnalysis.js'; + +/** + * Emits `generated/react/scaffold.tsx` — a functioning React component that + * imports the generated contract and styles.css, renders the merged layout + * tree with BEM classes, sets variant data attributes, and gates slot/element + * rendering on the visibility rules and inferred structural conditions. + * + * Also seeds the authored workspace ONCE: `src/react/{Component}.tsx` (a copy + * of the generated scaffold with rewritten imports), plus empty + * `{Component}.extensions.css` (non-scriptable styling) and + * `{Component}.proposed.css` (styling proposed for promotion into the spec). + * Files already present in src/ are never touched — they are human-owned. + * + * Browser-driven states (hover/active/focus) are CSS-only; application states + * (disabled, selected, …) surface as aria attributes matching the selectors + * the css transformer emits. + * + * Instance-typed elements render as placeholders this wave (plan 010, wave 2). + */ +export class ReactTransformer implements Transformer { + readonly name = 'react'; + + async run(apiYaml: Record, context: TransformerContext): Promise { + const { outputDir, componentKey } = context; + + const variantsPath = path.join(outputDir, 'variants.yaml'); + if (!fs.existsSync(variantsPath)) { + console.warn(` [react] skipping ${componentKey}: no variants.yaml found`); + return; + } + const variantsYaml = yaml.parse(await fs.readFile(variantsPath, 'utf-8')) as Record; + + const analysis = analyzeVariants(apiYaml, variantsYaml, context.processingStates ?? {}); + + const generatedReactDir = path.join(outputDir, 'generated', 'react'); + await fs.ensureDir(generatedReactDir); + const lines = buildScaffoldLines(componentKey, apiYaml, variantsYaml, analysis, context, { + contract: '../contract', + css: ['../styles.css'], + header: '// Generated. Do not edit — regenerate with `specs transform`.', + }); + await fs.writeFile(path.join(generatedReactDir, 'scaffold.tsx'), lines.join('\n'), 'utf-8'); + + await this.seedAuthoredWorkspace(outputDir, componentKey, apiYaml, variantsYaml, analysis, context); + + // Subcomponents — each gets its own scaffold seeded under / + const subcomponents = (apiYaml.subcomponents ?? {}) as Record; + const subVariantsAll = (variantsYaml.subcomponents ?? {}) as Record; + for (const [subKey, subRaw] of Object.entries(subcomponents)) { + const subApi = subRaw as Record; + const subVariantsYaml = (subVariantsAll[subKey] ?? {}) as Record; + const subAnalysis = analyzeVariants(subApi, subVariantsYaml, context.processingStates ?? {}); + const subDir = path.join(outputDir, subKey); + const subContext: TransformerContext = { ...context, outputDir: subDir, componentKey: subKey }; + const subGeneratedReactDir = path.join(subDir, 'generated', 'react'); + await fs.ensureDir(subGeneratedReactDir); + const subLines = buildScaffoldLines(subKey, subApi, subVariantsYaml, subAnalysis, subContext, { + contract: '../contract', + css: ['../styles.css'], + header: '// Generated. Do not edit — regenerate with `specs transform`.', + }); + await fs.writeFile(path.join(subGeneratedReactDir, 'scaffold.tsx'), subLines.join('\n'), 'utf-8'); + await this.seedAuthoredWorkspace(subDir, subKey, subApi, subVariantsYaml, subAnalysis, subContext); + } + } + + /** Create src/react/ authored files when absent; never overwrite. */ + private async seedAuthoredWorkspace( + outputDir: string, + componentKey: string, + apiYaml: Record, + variantsYaml: Record, + analysis: VariantAnalysis, + context: TransformerContext, + ): Promise { + const srcReactDir = path.join(outputDir, 'src', 'react'); + await fs.ensureDir(srcReactDir); + const prefix = toPascalCase(componentKey); + + const componentPath = path.join(srcReactDir, `${prefix}.tsx`); + if (!fs.existsSync(componentPath)) { + const lines = buildScaffoldLines(componentKey, apiYaml, variantsYaml, analysis, context, { + contract: '../../generated/contract', + css: ['../../generated/styles.css', `./${prefix}.proposed.css`, `./${prefix}.extensions.css`], + header: '// Authored component — seeded once by `specs transform`, never overwritten.\n' + + '// The always-current generated reference lives at ../../generated/react/scaffold.tsx.', + }); + await fs.writeFile(componentPath, lines.join('\n'), 'utf-8'); + } + + const extensionsPath = path.join(srcReactDir, `${prefix}.extensions.css`); + if (!fs.existsSync(extensionsPath)) { + await fs.writeFile(extensionsPath, + '/* Authored extensions — styling the spec cannot express. Never overwritten. */\n', 'utf-8'); + } + + const proposedPath = path.join(srcReactDir, `${prefix}.proposed.css`); + if (!fs.existsSync(proposedPath)) { + await fs.writeFile(proposedPath, + '/* Authored proposals — styling that could be promoted into the spec. Never overwritten. */\n', 'utf-8'); + } + } +} + +interface ScaffoldImports { + contract: string; + css: string[]; + header: string; +} + +function buildScaffoldLines( + componentKey: string, + apiYaml: Record, + variantsYaml: Record, + analysis: VariantAnalysis, + context: TransformerContext, + imports: ScaffoldImports, +): string[] { + const prefix = toPascalCase(componentKey); + const componentClass = toKebab(componentKey); + const anatomy = (apiYaml.anatomy ?? {}) as Record>; + const props = (apiYaml.props ?? {}) as Record; + const defaultBlock = (variantsYaml.default ?? {}) as Record; + const defaultElements = (defaultBlock.elements ?? {}) as Record>; + + const hasDefaults = Object.values(props).some(p => 'default' in (p as Record)); + + // Slot-typed elements bring a ReactNode prop into the scaffold's props. + const nodeSlotProps = analysis.slots + .filter(s => s.slotType === 'slot' && s.prop) + .map(s => s.prop as string); + + const lines: string[] = [ + imports.header, + "import * as React from 'react';", + ...imports.css.map(p => `import '${p}';`), + hasDefaults + ? `import { ${prefix}Defaults, type ${prefix}Props } from '${imports.contract}';` + : `import { type ${prefix}Props } from '${imports.contract}';`, + '', + `export interface ${prefix}ScaffoldProps extends ${prefix}Props {`, + ...nodeSlotProps.map(p => ` ${p}?: React.ReactNode;`), + '}', + '', + '// Explicit `undefined` props must not override defaults.', + 'function definedProps(obj: T): Partial {', + ' return Object.fromEntries(Object.entries(obj).filter(([, v]) => v !== undefined)) as Partial;', + '}', + '', + `export function ${prefix}(props: ${prefix}ScaffoldProps) {`, + hasDefaults + ? ` const p = { ...${prefix}Defaults, ...definedProps(props) } as ${prefix}ScaffoldProps;` + : ` const p = definedProps(props) as ${prefix}ScaffoldProps;`, + ' return (', + ]; + + const ctx: RenderContext = { + componentClass, + anatomy, + props, + defaultElements, + analysis, + processingStates: context.processingStates ?? {}, + }; + + for (const node of analysis.layout) { + lines.push(...renderNode(node, ctx, 2, true)); + } + + lines.push(' );'); + lines.push('}'); + lines.push(''); + return lines; +} + +interface RenderContext { + componentClass: string; + anatomy: Record>; + props: Record; + defaultElements: Record>; + analysis: VariantAnalysis; + processingStates: NonNullable; +} + +function renderNode(node: LayoutNode, ctx: RenderContext, depth: number, isRoot: boolean): string[] { + const pad = ' '.repeat(depth); + const elemType = (ctx.anatomy[node.key]?.type as string) ?? 'container'; + const tag = elemType === 'text' ? 'span' : 'div'; + const className = isRoot ? ctx.componentClass : `${ctx.componentClass}__${toKebab(node.key)}`; + + // Render condition: visibility rule (styles.visible) AND'd with inferred + // structural-presence conditions from variant layouts. + const condition = buildCondition(node, ctx); + + const attrs = isRoot ? rootAttrs(ctx) : []; + const content = elementContent(node.key, elemType, ctx); + const children = node.children.flatMap(c => renderNode(c, ctx, depth + (condition ? 2 : 1), false)); + + const open = attrs.length > 0 + ? [`<${tag}`, ...attrs.map(a => ` ${a}`), '>'] + : [`<${tag} className="${className}">`]; + const body: string[] = []; + + if (attrs.length > 0) { + body.push(...open.map(l => pad + (condition ? ' ' : '') + l)); + } else { + body.push(pad + (condition ? ' ' : '') + open[0]); + } + + const innerPad = pad + (condition ? ' ' : '') + ' '; + if (content) body.push(innerPad + content); + body.push(...children); + body.push(pad + (condition ? ' ' : '') + ``); + + if (condition) { + return [ + `${pad}{${condition} && (`, + ...body, + `${pad})}`, + ]; + } + return body; +} + +/** Combined render condition for an element, or undefined to render always. */ +function buildCondition(node: LayoutNode, ctx: RenderContext): string | undefined { + const parts: string[] = []; + + const rule = ctx.analysis.visibility.get(node.key); + if (rule) { + if (rule.kind === 'whenTrue') parts.push(`p.${rule.prop}`); + else if (rule.kind === 'whenNotNull') parts.push(`p.${rule.prop} != null`); + else if (rule.kind === 'whenValue') parts.push(`p.${rule.prop} === ${JSON.stringify(rule.value)}`); + } + + if (node.conditions && node.conditions.length > 0) { + const ors = node.conditions.map(c => { + const ands = Object.entries(c).map(([k, v]) => { + if (v === true) return `p.${k}`; + if (v === false) return `!p.${k}`; + return `p.${k} === ${JSON.stringify(v)}`; + }); + return ands.length > 1 ? `(${ands.join(' && ')})` : ands[0]; + }); + parts.push(ors.length > 1 ? `(${ors.join(' || ')})` : ors[0]); + } + + if (parts.length === 0) return undefined; + return parts.join(' && '); +} + +/** Root element attributes: className, variant data attributes, state aria attributes. */ +function rootAttrs(ctx: RenderContext): string[] { + const attrs: string[] = [`className="${ctx.componentClass}"`]; + + // Variant props → data attributes, mirroring the css transformer's selectors: + // boolean → presence attribute when true; enum/string → value attribute. + for (const propName of ctx.analysis.dataAttrProps) { + const prop = (ctx.props[propName] ?? {}) as Record; + const kebab = toKebab(propName); + if (prop.type === 'boolean') { + attrs.push(`{...(p.${propName} ? { 'data-${kebab}': '' } : {})}`); + } else { + attrs.push(`data-${kebab}={p.${propName}}`); + } + } + + // Application states → aria attributes matching the css transformer's + // CONCEPT_TABLE selectors. Concepts sharing an aria attribute (e.g. + // checked/indeterminate → aria-checked) chain into one ternary. + const byAria = new Map>(); + for (const [concept, entry] of Object.entries(ctx.processingStates)) { + const conceptDef = CONCEPT_TABLE[concept]; + if (!conceptDef || conceptDef.contract === 'omit') continue; // browser-driven + if (!(entry.prop in ctx.props)) continue; + const aria = parseAriaSelector(conceptDef.selector); + if (!aria) continue; + const cond = entry.value !== undefined + ? `p.${entry.prop} === ${JSON.stringify(entry.value)}` + : `p.${entry.prop}`; + const list = byAria.get(aria.attr) ?? []; + list.push({ cond, ariaValue: aria.value }); + byAria.set(aria.attr, list); + } + for (const [attr, entries] of byAria) { + const chain = entries.reduce( + (acc, e) => `${e.cond} ? '${e.ariaValue}' : ${acc}`, + 'undefined', + ); + attrs.push(`${attr}={${chain}}`); + } + + return attrs; +} + +/** Extract the first aria-* attribute and value from a concept selector. */ +function parseAriaSelector(selector: string): { attr: string; value: string } | undefined { + const match = selector.match(/\[(aria-[a-z-]+)="([^"]+)"\]/); + return match ? { attr: match[1], value: match[2] } : undefined; +} + +/** Inner content expression for an element, or undefined when it only nests children. */ +function elementContent(elemKey: string, elemType: string, ctx: RenderContext): string | undefined { + const elem = (ctx.defaultElements[elemKey] ?? {}) as Record; + + if (elemType === 'slot') { + const prop = bindingProp(elem.children); + return prop ? `{p.${prop}}` : undefined; + } + if (elemType === 'text') { + const prop = bindingProp(elem.content); + if (prop) return `{p.${prop}}`; + if (typeof elem.content === 'string') return escapeJsxText(elem.content); + return undefined; + } + if (elemType === 'instance') { + const ref = instanceRef(elem.instanceOf); + return `{/* instance: ${ref ?? 'unresolved'} — placeholder until instance slots land */}`; + } + return undefined; +} + +function bindingProp(value: unknown): string | undefined { + if (!value || typeof value !== 'object') return undefined; + const binding = (value as Record).$binding; + if (typeof binding !== 'string') return undefined; + return binding.match(/^#\/props\/(.+)$/)?.[1]; +} + +function instanceRef(value: unknown): string | undefined { + if (typeof value === 'string') return value; + if (value && typeof value === 'object') { + const ref = (value as Record).$ref; + if (typeof ref === 'string') return ref.replace(/^#\/subcomponents\//, ''); + } + return undefined; +} + +function escapeJsxText(text: string): string { + return text.replace(/[{}<>]/g, c => `{'${c}'}`); +} + +function toPascalCase(str: string): string { + return str.charAt(0).toUpperCase() + str.slice(1); +} diff --git a/packages/cli/src/transforms/Stories.ts b/packages/cli/src/transforms/Stories.ts new file mode 100644 index 0000000..b24cd6f --- /dev/null +++ b/packages/cli/src/transforms/Stories.ts @@ -0,0 +1,136 @@ +import fs from 'fs-extra'; +import path from 'path'; +import yaml from 'yaml'; +import type { Transformer, TransformerContext } from '../Types/Transformer.js'; +import { analyzeVariants } from './react/variantAnalysis.js'; + +/** + * Emits `generated/react/stories.tsx` — one Storybook CSF page per component, + * with a Default story plus one story per variant whose configuration is + * expressible as Props (variants driven purely by browser states — hover, + * pressed — have no prop to set and are skipped; their styling shows via real + * interaction). + * + * Stories import the AUTHORED scaffold (src/react/scaffold.tsx, seeded by the + * react transformer) so Storybook reflects human edits, not the regenerated + * reference. + */ +export class StoriesTransformer implements Transformer { + readonly name = 'stories'; + + async run(apiYaml: Record, context: TransformerContext): Promise { + const { outputDir, componentKey } = context; + + const variantsPath = path.join(outputDir, 'variants.yaml'); + if (!fs.existsSync(variantsPath)) { + console.warn(` [stories] skipping ${componentKey}: no variants.yaml found`); + return; + } + const variantsYaml = yaml.parse(await fs.readFile(variantsPath, 'utf-8')) as Record; + + await this.writeStories(outputDir, componentKey, apiYaml, variantsYaml, context); + + const subcomponents = (apiYaml.subcomponents ?? {}) as Record; + const subVariantsAll = (variantsYaml.subcomponents ?? {}) as Record; + for (const [subKey, subRaw] of Object.entries(subcomponents)) { + const subApi = subRaw as Record; + const subVariantsYaml = (subVariantsAll[subKey] ?? {}) as Record; + await this.writeStories(path.join(outputDir, subKey), subKey, subApi, subVariantsYaml, context); + } + } + + private async writeStories( + outputDir: string, + componentKey: string, + apiYaml: Record, + variantsYaml: Record, + context: TransformerContext, + ): Promise { + const analysis = analyzeVariants(apiYaml, variantsYaml, context.processingStates ?? {}); + const lines = buildStoriesLines(componentKey, apiYaml, analysis); + const generatedReactDir = path.join(outputDir, 'generated', 'react'); + await fs.ensureDir(generatedReactDir); + await fs.writeFile(path.join(generatedReactDir, 'stories.tsx'), lines.join('\n'), 'utf-8'); + } +} + +function buildStoriesLines( + componentKey: string, + apiYaml: Record, + analysis: ReturnType, +): string[] { + const prefix = toPascalCase(componentKey); + const props = (apiYaml.props ?? {}) as Record; + const title = (apiYaml.title as string) ?? prefix; + const hasDefaults = Object.values(props).some(p => 'default' in (p as Record)); + + // Meta args: defaults + first example value for each string/slot prop so + // text slots render visible content out of the box. + const exampleArgs: Array<[string, string]> = []; + for (const [key, raw] of Object.entries(props)) { + const prop = raw as Record; + if (prop.type === 'string' && Array.isArray(prop.examples) && prop.examples.length > 0) { + exampleArgs.push([key, JSON.stringify(prop.examples[0])]); + } else if (prop.type === 'slot') { + exampleArgs.push([key, `'Slot content'`]); + } + } + + const lines: string[] = [ + '// Generated. Do not edit — regenerate with `specs transform`.', + "import type { Meta, StoryObj } from '@storybook/react';", + `import { ${prefix} } from '../../src/react/${prefix}';`, + ...(hasDefaults ? [`import { ${prefix}Defaults } from '../contract';`] : []), + '', + 'const meta = {', + ` title: 'Components/${title.replace(/'/g, "\\'")}',`, + ` component: ${prefix},`, + ' args: {', + ...(hasDefaults ? [` ...${prefix}Defaults,`] : []), + ...exampleArgs.map(([k, v]) => ` ${k}: ${v},`), + ' },', + `} satisfies Meta;`, + '', + 'export default meta;', + 'type Story = StoryObj;', + '', + 'export const Default: Story = {};', + '', + ]; + + const usedNames = new Set(['Default']); + for (const { configuration } of analysis.propVariants) { + const name = uniqueName(storyName(configuration), usedNames); + const args = Object.entries(configuration) + .map(([k, v]) => `${k}: ${JSON.stringify(v)}`) + .join(', '); + lines.push(`export const ${name}: Story = { args: { ${args} } };`); + } + lines.push(''); + + return lines; +} + +/** `{ size: 'xS' }` → `SizeXS`; `{ elevated: true }` → `Elevated`; `{ disabled: false }` → `NotDisabled`. */ +function storyName(configuration: Record): string { + const parts: string[] = []; + for (const [k, v] of Object.entries(configuration)) { + if (v === true) parts.push(toPascalCase(k)); + else if (v === false) parts.push(`Not${toPascalCase(k)}`); + else parts.push(`${toPascalCase(k)}${toPascalCase(String(v))}`); + } + const name = parts.join('').replace(/[^A-Za-z0-9_$]/g, ''); + return /^[A-Za-z_$]/.test(name) ? name : `V${name}`; +} + +function uniqueName(base: string, used: Set): string { + let name = base; + let i = 2; + while (used.has(name)) name = `${base}${i++}`; + used.add(name); + return name; +} + +function toPascalCase(str: string): string { + return str.charAt(0).toUpperCase() + str.slice(1); +} diff --git a/packages/cli/src/transforms/index.ts b/packages/cli/src/transforms/index.ts index 8f359e3..1689cbb 100644 --- a/packages/cli/src/transforms/index.ts +++ b/packages/cli/src/transforms/index.ts @@ -1,10 +1,14 @@ import type { Transformer } from '../Types/Transformer.js'; import { ContractTransformer } from './Contract.js'; import { CssTransformer } from './Css.js'; +import { ReactTransformer } from './React.js'; +import { StoriesTransformer } from './Stories.js'; const ALL_TRANSFORMERS: Transformer[] = [ new ContractTransformer(), new CssTransformer(), + new ReactTransformer(), + new StoriesTransformer(), ]; const BY_NAME = new Map(ALL_TRANSFORMERS.map(t => [t.name, t])); diff --git a/packages/cli/src/transforms/react/variantAnalysis.ts b/packages/cli/src/transforms/react/variantAnalysis.ts new file mode 100644 index 0000000..8df36eb --- /dev/null +++ b/packages/cli/src/transforms/react/variantAnalysis.ts @@ -0,0 +1,319 @@ +import { buildStateLookup, buildOmittedProps, type ProcessingStates } from '../states.js'; + +/** + * Shared analysis of api.yaml + variants.yaml used by the contract (slots), + * react, and stories transformers. + * + * Terminology: + * - "slot" — a consumer-facing content injection point: anatomy elements typed + * `slot` (arbitrary children) or `text` with a content binding (string). + * Instance-typed elements are NOT slots yet (deferred — see plan 010). + * - "structural presence" — an element absent from the default layout that one + * or more variant layouts add. Its rendering condition is inferred from the + * variant configurations that include it. + */ + +export type SlotRule = + | { kind: 'always' } + | { kind: 'whenTrue'; prop: string } + | { kind: 'whenNotNull'; prop: string } + | { kind: 'whenValue'; prop: string; value: string }; + +export interface SlotInfo { + elementKey: string; + /** `slot` → unknown/ReactNode content; `text` → string content. */ + slotType: 'slot' | 'text'; + /** The prop the slot's content binds to (e.g. `children`, `label`). */ + prop?: string; + rule: SlotRule; + /** Set when inference was ambiguous and the rule fell back to `always`. */ + warning?: string; +} + +export interface LayoutNode { + key: string; + children: LayoutNode[]; + /** + * Render condition inferred from variant configurations (state-classified + * props stripped). Empty array → render always. Each entry is a + * conjunction (prop → value); entries are OR'd together. + */ + conditions?: Array>; +} + +export interface VariantAnalysis { + slots: SlotInfo[]; + /** Merged layout tree: default layout + variant-added elements with conditions. */ + layout: LayoutNode[]; + /** Per-element visibility rule (every element with a styles.visible binding). */ + visibility: Map; + /** Non-state props that appear in variant configurations → rendered as data attributes. */ + dataAttrProps: string[]; + /** Variant entries whose configurations are expressible as Props (no omitted state props). */ + propVariants: Array<{ configuration: Record }>; +} + +interface RawVariant { + configuration?: Record; + layout?: unknown; + elements?: Record>; +} + +export function analyzeVariants( + apiYaml: Record, + variantsYaml: Record, + processingStates: ProcessingStates, +): VariantAnalysis { + const anatomy = (apiYaml.anatomy ?? {}) as Record>; + const props = (apiYaml.props ?? {}) as Record; + const defaultBlock = (variantsYaml.default ?? {}) as Record; + const defaultElements = (defaultBlock.elements ?? {}) as Record>; + const variants = (variantsYaml.variants ?? []) as RawVariant[]; + const { classifiedProps } = buildStateLookup(processingStates); + // Omitted props are browser-driven (hover, pressed, …) — not in Props, so + // not usable in render conditions or story args. Classified-but-kept props + // (disabled, selected, …) stay in Props; they're styled via aria selectors + // rather than data attributes. + const omittedProps = buildOmittedProps(processingStates); + + const defaultLayout = parseLayout(defaultBlock.layout); + const defaultKeys = new Set(); + collectKeys(defaultLayout, defaultKeys); + + // ── Visibility rules from styles.visible bindings ────────────────────────── + const visibility = new Map(); + for (const [elemKey, elem] of Object.entries(defaultElements)) { + const styles = (elem.styles ?? {}) as Record; + const rule = visibleToRule(styles.visible, props); + if (rule) visibility.set(elemKey, rule); + } + + // ── Structural presence: elements added by variant layouts ──────────────── + // For each element not in the default layout, collect the configurations of + // variants whose layout includes it (state-classified props stripped — the + // browser drives those, not the application). + const added = new Map>; parent: string | null; index: number }>(); + for (const variant of variants) { + if (!variant.layout) continue; + const config = stripStateProps(variant.configuration ?? {}, omittedProps); + const tree = parseLayout(variant.layout); + walkWithParent(tree, null, (node, parent, index) => { + if (defaultKeys.has(node.key)) return; + const entry = added.get(node.key); + if (entry) { + entry.conditions.push(config); + } else { + added.set(node.key, { conditions: [config], parent, index }); + } + }); + } + + // ── Merged layout: default + added elements with inferred conditions ────── + const layout = cloneLayout(defaultLayout); + for (const [key, info] of added) { + // An empty condition means a state-only variant adds the element — the + // application can't gate it, so it renders unconditionally. + const conditions = info.conditions.some(c => Object.keys(c).length === 0) + ? [] + : dedupeConditions(info.conditions); + const node: LayoutNode = { key, children: [], conditions }; + insertIntoLayout(layout, node, info.parent, info.index); + } + + // ── Slots ────────────────────────────────────────────────────────────────── + const slots: SlotInfo[] = []; + for (const [elemKey, elem] of Object.entries(anatomy)) { + const type = elem.type as string; + if (type !== 'slot' && type !== 'text') continue; + + const defaultElem = defaultElements[elemKey] ?? {}; + const prop = + bindingProp((defaultElem as Record).children) ?? + bindingProp((defaultElem as Record).content); + + if (type === 'text' && !prop) continue; // static text — not a slot + + let rule = visibility.get(elemKey); + let warning: string | undefined; + if (!rule) { + if (defaultKeys.has(elemKey)) { + rule = { kind: 'always' }; + } else { + const inferred = inferRule(added.get(elemKey)?.conditions ?? [], props); + rule = inferred.rule; + warning = inferred.warning; + } + } + slots.push({ elementKey: elemKey, slotType: type, prop, rule, warning }); + } + + // ── Data-attribute props (Css transformer convention) ───────────────────── + const dataAttrProps = new Set(); + const propVariants: Array<{ configuration: Record }> = []; + for (const variant of variants) { + const config = variant.configuration ?? {}; + const entries = Object.entries(config); + if (entries.length === 0) continue; + let expressible = true; + for (const [k] of entries) { + if (omittedProps.has(k)) expressible = false; + if (!classifiedProps.has(k)) dataAttrProps.add(k); + } + if (expressible) propVariants.push({ configuration: config }); + } + + return { slots, layout, visibility, dataAttrProps: [...dataAttrProps].sort(), propVariants }; +} + +/** Resolve a styles.visible value to a SlotRule, or undefined when not a binding. */ +function visibleToRule(visible: unknown, props: Record): SlotRule | undefined { + if (!visible || typeof visible !== 'object') return undefined; + const binding = bindingProp(visible); + if (!binding) return undefined; + const prop = (props[binding] ?? {}) as Record; + if (prop.type === 'boolean') return { kind: 'whenTrue', prop: binding }; + return { kind: 'whenNotNull', prop: binding }; +} + +/** Extract the prop name from a `{ $binding: '#/props/x' }` value. */ +function bindingProp(value: unknown): string | undefined { + if (!value || typeof value !== 'object') return undefined; + const binding = (value as Record).$binding; + if (typeof binding !== 'string') return undefined; + const match = binding.match(/^#\/props\/(.+)$/); + return match?.[1]; +} + +/** + * Infer a SlotRule from the variant configurations that structurally include + * an element. Single shared (prop, value) pair → whenTrue/whenValue. + * Empty config present (state-only variant) → always. + * Divergent configs → ambiguous: always + warning. + */ +function inferRule( + conditions: Array>, + props: Record, +): { rule: SlotRule; warning?: string } { + if (conditions.length === 0 || conditions.some(c => Object.keys(c).length === 0)) { + return { rule: { kind: 'always' } }; + } + const first = Object.entries(conditions[0]); + if (first.length === 1 && conditions.every(c => { + const entries = Object.entries(c); + return entries.length === 1 && entries[0][0] === first[0][0] && entries[0][1] === first[0][1]; + })) { + const [propName, value] = first[0]; + const prop = (props[propName] ?? {}) as Record; + if (prop.type === 'boolean' && value === true) { + return { rule: { kind: 'whenTrue', prop: propName } }; + } + return { rule: { kind: 'whenValue', prop: propName, value: String(value) } }; + } + return { + rule: { kind: 'always' }, + warning: `ambiguous structural presence — included by configurations: ${JSON.stringify(conditions)}`, + }; +} + +function stripStateProps( + config: Record, + classifiedProps: Set, +): Record { + const out: Record = {}; + for (const [k, v] of Object.entries(config)) { + if (!classifiedProps.has(k)) out[k] = v; + } + return out; +} + +/** + * Dedupe OR'd conditions and drop subsumed ones: `{a}` already covers + * `{a, b}`, so the compound term is redundant. + */ +function dedupeConditions(conditions: Array>): Array> { + const seen = new Set(); + const unique: Array> = []; + for (const c of conditions) { + const key = JSON.stringify(Object.entries(c).sort(([a], [b]) => a.localeCompare(b))); + if (!seen.has(key)) { + seen.add(key); + unique.push(c); + } + } + return unique.filter(c => !unique.some(o => + o !== c && + Object.keys(o).length < Object.keys(c).length && + Object.entries(o).every(([k, v]) => c[k] === v) + )); +} + +// ── Layout tree helpers ─────────────────────────────────────────────────────── + +/** + * Parse the YAML layout shape into LayoutNodes. Layout is a list whose items + * are either a string (leaf element) or a single-key object mapping an element + * to its children list. + */ +export function parseLayout(layout: unknown): LayoutNode[] { + if (!Array.isArray(layout)) return []; + const nodes: LayoutNode[] = []; + for (const item of layout) { + if (typeof item === 'string') { + nodes.push({ key: item, children: [] }); + } else if (item && typeof item === 'object') { + for (const [key, children] of Object.entries(item as Record)) { + nodes.push({ key, children: parseLayout(children) }); + } + } + } + return nodes; +} + +function collectKeys(nodes: LayoutNode[], into: Set): void { + for (const node of nodes) { + into.add(node.key); + collectKeys(node.children, into); + } +} + +function walkWithParent( + nodes: LayoutNode[], + parent: string | null, + visit: (node: LayoutNode, parent: string | null, index: number) => void, +): void { + nodes.forEach((node, index) => { + visit(node, parent, index); + walkWithParent(node.children, node.key, visit); + }); +} + +function cloneLayout(nodes: LayoutNode[]): LayoutNode[] { + return nodes.map(n => ({ key: n.key, children: cloneLayout(n.children) })); +} + +function insertIntoLayout( + layout: LayoutNode[], + node: LayoutNode, + parent: string | null, + index: number, +): void { + if (parent === null) { + layout.splice(Math.min(index, layout.length), 0, node); + return; + } + const target = findNode(layout, parent); + if (target) { + target.children.splice(Math.min(index, target.children.length), 0, node); + } else { + layout.push(node); // parent itself isn't in the merged tree — append at root + } +} + +function findNode(nodes: LayoutNode[], key: string): LayoutNode | undefined { + for (const node of nodes) { + if (node.key === key) return node; + const found = findNode(node.children, key); + if (found) return found; + } + return undefined; +} diff --git a/packages/cli/tests/unit/transforms/Contract.test.ts b/packages/cli/tests/unit/transforms/Contract.test.ts index f446c4b..557445a 100644 --- a/packages/cli/tests/unit/transforms/Contract.test.ts +++ b/packages/cli/tests/unit/transforms/Contract.test.ts @@ -13,7 +13,7 @@ function makeContext(dir: string, componentKey = 'dsButton', processingStates?: async function run(dir: string, apiYaml: Record, componentKey = 'dsButton', processingStates?: ProcessingStates) { await transformer.run(apiYaml, makeContext(dir, componentKey, processingStates)); - return fs.readFile(path.join(dir, 'contract.ts'), 'utf-8'); + return fs.readFile(path.join(dir, 'generated', 'contract.ts'), 'utf-8'); } describe('ContractTransformer', () => { @@ -201,7 +201,7 @@ describe('ContractTransformer', () => { describe('subcomponent contracts', () => { async function readSub(dir: string, subKey: string) { - return fs.readFile(path.join(dir, subKey, 'contract.ts'), 'utf-8'); + return fs.readFile(path.join(dir, 'generated', subKey, 'contract.ts'), 'utf-8'); } it('emits contract.ts in a subfolder for each subcomponent', async () => { @@ -209,7 +209,7 @@ describe('ContractTransformer', () => { { subcomponents: { group: { props: {} } } }, makeContext(tmpDir, 'dsActionList'), ); - expect(fs.existsSync(path.join(tmpDir, 'group', 'contract.ts'))).toBe(true); + expect(fs.existsSync(path.join(tmpDir, 'generated', 'group', 'contract.ts'))).toBe(true); }); it('prefixes subcomponent interface with both component and subcomponent names', async () => { diff --git a/packages/cli/tests/unit/transforms/Css.test.ts b/packages/cli/tests/unit/transforms/Css.test.ts index 1097c6d..cb84c1a 100644 --- a/packages/cli/tests/unit/transforms/Css.test.ts +++ b/packages/cli/tests/unit/transforms/Css.test.ts @@ -20,7 +20,7 @@ async function writeVariants(dir: string, data: Record) { async function run(dir: string, variantsData: Record, componentKey = 'dsButton', tokensFormat = 'TOKEN', processingStates?: ProcessingStates, transformerOptions?: Record) { await writeVariants(dir, variantsData); await transformer.run({}, { ...makeContext(dir, componentKey, tokensFormat, processingStates), transformerOptions }); - return fs.readFile(path.join(dir, 'styles.css'), 'utf-8'); + return fs.readFile(path.join(dir, 'generated', 'styles.css'), 'utf-8'); } // Minimal helpers to build spec-format style objects @@ -47,7 +47,7 @@ describe('CssTransformer', () => { const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); await transformer.run({}, makeContext(tmpDir)); expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('no variants.yaml')); - expect(fs.existsSync(path.join(tmpDir, 'styles.css'))).toBe(false); + expect(fs.existsSync(path.join(tmpDir, 'generated', 'styles.css'))).toBe(false); warnSpy.mockRestore(); }); @@ -411,7 +411,7 @@ describe('CssTransformer', () => { async function runAndReadSub(dir: string, variantsData: Record, subKey: string, componentKey = 'dsActionList') { await writeVariants(dir, variantsData); await transformer.run({}, makeContext(dir, componentKey)); - return fs.readFile(path.join(dir, subKey, 'styles.css'), 'utf-8'); + return fs.readFile(path.join(dir, 'generated', subKey, 'styles.css'), 'utf-8'); } it('emits styles.css in a subfolder for each subcomponent', async () => { @@ -424,7 +424,7 @@ describe('CssTransformer', () => { }, }); await transformer.run({}, makeContext(tmpDir, 'dsActionList')); - expect(fs.existsSync(path.join(tmpDir, 'group', 'styles.css'))).toBe(true); + expect(fs.existsSync(path.join(tmpDir, 'generated', 'group', 'styles.css'))).toBe(true); }); it('scopes subcomponent BEM selectors to the subcomponent key, not the parent', async () => { @@ -477,10 +477,10 @@ describe('CssTransformer', () => { }, }); await transformer.run({}, makeContext(tmpDir, 'dsActionList')); - expect(fs.existsSync(path.join(tmpDir, 'group', 'styles.css'))).toBe(true); - expect(fs.existsSync(path.join(tmpDir, 'header', 'styles.css'))).toBe(true); - const groupOut = await fs.readFile(path.join(tmpDir, 'group', 'styles.css'), 'utf-8'); - const headerOut = await fs.readFile(path.join(tmpDir, 'header', 'styles.css'), 'utf-8'); + expect(fs.existsSync(path.join(tmpDir, 'generated', 'group', 'styles.css'))).toBe(true); + expect(fs.existsSync(path.join(tmpDir, 'generated', 'header', 'styles.css'))).toBe(true); + const groupOut = await fs.readFile(path.join(tmpDir, 'generated', 'group', 'styles.css'), 'utf-8'); + const headerOut = await fs.readFile(path.join(tmpDir, 'generated', 'header', 'styles.css'), 'utf-8'); expect(groupOut).toContain('flex-direction: column'); expect(headerOut).toContain('flex-direction: row'); }); @@ -621,9 +621,86 @@ describe('CssTransformer', () => { ...makeContext(tmpDir, 'dsActionList'), transformerOptions: rules, }); - const subOut = await fs.readFile(path.join(tmpDir, 'item', 'styles.css'), 'utf-8'); + const subOut = await fs.readFile(path.join(tmpDir, 'generated', 'item', 'styles.css'), 'utf-8'); expect(subOut).toContain('box-shadow: inset 0 0 0 2px var(--color-border-selected)'); expect(subOut).toContain('border-color: transparent'); }); }); + + describe('structural layout presence', () => { + const structuralVariants = { + default: { + layout: [{ root: ['label'] }], + elements: { + root: { styles: { layoutMode: 'HORIZONTAL' } }, + label: { styles: {} }, + background: { + styles: { position: 'ABSOLUTE', top: 0, bottom: 0, start: 0, end: 0, backgroundColor: tokenRef('Color/Primary') }, + }, + }, + }, + variants: [ + { + configuration: { appearance: 'overlay' }, + layout: [{ root: ['background', 'label'] }], + }, + ], + }; + + it('adds position: relative to the layout parent of an absolute element', async () => { + const out = await run(tmpDir, structuralVariants); + const rootBlock = out.match(/\.ds-button \{[^}]*\}/)?.[0]; + expect(rootBlock).toContain('position: relative'); + }); + + it('positions non-absolute siblings so painting follows layout order (last on top)', async () => { + const out = await run(tmpDir, structuralVariants); + const labelBlock = out.match(/\.ds-button__label \{[^}]*\}/)?.[0]; + expect(labelBlock).toContain('position: relative'); + }); + + it('does not position siblings when no absolute element shares the parent', async () => { + const out = await run(tmpDir, { + default: { + layout: [{ root: ['icon', 'label'] }], + elements: { + root: { styles: {} }, + icon: { styles: { layoutSizingHorizontal: 'HUG' } }, + label: { styles: { layoutSizingHorizontal: 'HUG' } }, + }, + }, + variants: [], + }); + expect(out.match(/\.ds-button__label \{[^}]*\}/)?.[0]).not.toContain('position: relative'); + }); + + it('hides elements absent from the default layout and un-hides them under including variants', async () => { + const out = await run(tmpDir, structuralVariants); + const baseBlock = out.match(/\.ds-button__background \{[^}]*\}/)?.[0]; + expect(baseBlock).toContain('display: none'); + expect(out).toContain('.ds-button[data-appearance="overlay"] .ds-button__background {'); + const overlayBlock = out.match(/\.ds-button\[data-appearance="overlay"\] \.ds-button__background \{[^}]*\}/)?.[0]; + expect(overlayBlock).toContain('display: block'); + }); + + it('hides default elements dropped by a variant layout', async () => { + const out = await run(tmpDir, { + default: { + layout: [{ root: ['icon', 'label'] }], + elements: { root: { styles: {} }, icon: { styles: {} }, label: { styles: {} } }, + }, + variants: [ + { configuration: { compact: true }, layout: [{ root: ['label'] }] }, + ], + }); + const compactIcon = out.match(/\.ds-button\[data-compact\] \.ds-button__icon \{[^}]*\}/)?.[0]; + expect(compactIcon).toContain('display: none'); + }); + + it('does not hide elements present in the default layout', async () => { + const out = await run(tmpDir, structuralVariants); + const labelBlock = out.match(/\.ds-button__label \{[^}]*\}/)?.[0]; + expect(labelBlock ?? '').not.toContain('display: none'); + }); + }); }); diff --git a/packages/cli/tests/unit/transforms/variantAnalysis.test.ts b/packages/cli/tests/unit/transforms/variantAnalysis.test.ts new file mode 100644 index 0000000..76f20ec --- /dev/null +++ b/packages/cli/tests/unit/transforms/variantAnalysis.test.ts @@ -0,0 +1,184 @@ +import { describe, it, expect } from 'vitest'; +import { analyzeVariants } from '../../../src/transforms/react/variantAnalysis.js'; + +const STATES = { + hover: { prop: 'state', value: 'hover' }, + active: { prop: 'state', value: 'pressed' }, + disabled: { prop: 'disabled' }, +}; + +function api(overrides: Record = {}) { + return { + anatomy: { + root: { type: 'container' }, + label: { type: 'text' }, + icon: { type: 'container' }, + children: { type: 'slot' }, + caption: { type: 'text' }, + ...((overrides.anatomy as Record) ?? {}), + }, + props: { + label: { type: 'string' }, + iconVisible: { type: 'boolean', default: false }, + hint: { type: 'string', nullable: true, default: null }, + appearance: { type: 'string', enum: ['filled', 'outline'], default: 'filled' }, + elevated: { type: 'boolean', default: false }, + disabled: { type: 'boolean', default: false }, + state: { type: 'string', enum: ['rest', 'hover', 'pressed'], default: 'rest' }, + children: { type: 'slot' }, + ...((overrides.props as Record) ?? {}), + }, + }; +} + +function variants(overrides: Record = {}) { + return { + default: { + layout: [{ root: ['icon', 'label', 'children'] }], + elements: { + root: { styles: {} }, + icon: { styles: { visible: { $binding: '#/props/iconVisible' } } }, + label: { content: { $binding: '#/props/label' }, styles: {} }, + children: { children: { $binding: '#/props/children' }, styles: {} }, + }, + ...((overrides.default as Record) ?? {}), + }, + variants: (overrides.variants as unknown[]) ?? [], + }; +} + +describe('analyzeVariants', () => { + it('resolves whenTrue visibility from a boolean prop binding', () => { + const result = analyzeVariants(api(), variants(), STATES); + expect(result.visibility.get('icon')).toEqual({ kind: 'whenTrue', prop: 'iconVisible' }); + }); + + it('resolves whenNotNull visibility from a nullable string prop binding', () => { + const v = variants(); + (v.default.elements as Record>).icon = { + styles: { visible: { $binding: '#/props/hint' } }, + }; + const result = analyzeVariants(api(), v, STATES); + expect(result.visibility.get('icon')).toEqual({ kind: 'whenNotNull', prop: 'hint' }); + }); + + it('collects slot-typed and bound-text elements as slots, skipping unbound text', () => { + const v = variants(); + (v.default.elements as Record>).caption = { content: 'static', styles: {} }; + const result = analyzeVariants(api(), v, STATES); + const keys = result.slots.map(s => s.elementKey); + expect(keys).toContain('label'); + expect(keys).toContain('children'); + expect(keys).not.toContain('caption'); // static text — not a slot + expect(result.slots.find(s => s.elementKey === 'label')).toMatchObject({ + slotType: 'text', + prop: 'label', + rule: { kind: 'always' }, + }); + }); + + it('infers whenValue for an element added by a single enum configuration', () => { + const result = analyzeVariants(api({ anatomy: { extra: { type: 'container' } } }), variants({ + variants: [ + { configuration: { appearance: 'outline' }, layout: [{ root: ['extra', 'icon', 'label', 'children'] }] }, + ], + }), STATES); + const extra = findLayout(result.layout, 'extra'); + expect(extra?.conditions).toEqual([{ appearance: 'outline' }]); + }); + + it('infers whenTrue slot rule for a slot added by a boolean configuration', () => { + const apiYaml = api({ anatomy: { badge: { type: 'slot' } } }); + const v = variants({ + variants: [ + { configuration: { elevated: true }, layout: [{ root: ['icon', 'label', 'children', 'badge'] }] }, + ], + }); + (v.default.elements as Record>).badge = { + children: { $binding: '#/props/children' }, + }; + const result = analyzeVariants(apiYaml, v, STATES); + const badge = result.slots.find(s => s.elementKey === 'badge'); + expect(badge?.rule).toEqual({ kind: 'whenTrue', prop: 'elevated' }); + }); + + it('falls back to always with a warning when structural presence is ambiguous', () => { + const apiYaml = api({ anatomy: { badge: { type: 'slot' } } }); + const v = variants({ + variants: [ + { configuration: { elevated: true }, layout: [{ root: ['icon', 'label', 'children', 'badge'] }] }, + { configuration: { appearance: 'outline' }, layout: [{ root: ['icon', 'label', 'children', 'badge'] }] }, + ], + }); + const result = analyzeVariants(apiYaml, v, STATES); + const badge = result.slots.find(s => s.elementKey === 'badge'); + expect(badge?.rule).toEqual({ kind: 'always' }); + expect(badge?.warning).toContain('ambiguous'); + }); + + it('renders always when a state-only variant adds the element (browser-driven)', () => { + const result = analyzeVariants(api({ anatomy: { glow: { type: 'container' } } }), variants({ + variants: [ + { configuration: { state: 'hover' }, layout: [{ root: ['glow', 'icon', 'label', 'children'] }] }, + ], + }), STATES); + const glow = findLayout(result.layout, 'glow'); + expect(glow?.conditions).toEqual([]); + }); + + it('strips browser-driven props from conditions but keeps contract-kept ones', () => { + const result = analyzeVariants(api({ anatomy: { veil: { type: 'container' } } }), variants({ + variants: [ + { configuration: { disabled: true, state: 'hover' }, layout: [{ root: ['veil', 'icon', 'label', 'children'] }] }, + ], + }), STATES); + const veil = findLayout(result.layout, 'veil'); + expect(veil?.conditions).toEqual([{ disabled: true }]); + }); + + it('drops subsumed OR conditions', () => { + const result = analyzeVariants(api({ anatomy: { extra: { type: 'container' } } }), variants({ + variants: [ + { configuration: { appearance: 'outline' }, layout: [{ root: ['extra', 'icon', 'label', 'children'] }] }, + { configuration: { appearance: 'outline', elevated: true }, layout: [{ root: ['extra', 'icon', 'label', 'children'] }] }, + ], + }), STATES); + const extra = findLayout(result.layout, 'extra'); + expect(extra?.conditions).toEqual([{ appearance: 'outline' }]); + }); + + it('marks variants with omitted props inexpressible and keeps disabled expressible', () => { + const result = analyzeVariants(api(), variants({ + variants: [ + { configuration: { state: 'hover' }, elements: {} }, + { configuration: { disabled: true }, elements: {} }, + { configuration: { appearance: 'outline' }, elements: {} }, + ], + }), STATES); + expect(result.propVariants.map(v => v.configuration)).toEqual([ + { disabled: true }, + { appearance: 'outline' }, + ]); + }); + + it('excludes state-classified props from data attributes', () => { + const result = analyzeVariants(api(), variants({ + variants: [ + { configuration: { state: 'hover' }, elements: {} }, + { configuration: { disabled: true }, elements: {} }, + { configuration: { appearance: 'outline', elevated: true }, elements: {} }, + ], + }), STATES); + expect(result.dataAttrProps).toEqual(['appearance', 'elevated']); + }); +}); + +function findLayout(nodes: Array<{ key: string; children: unknown[]; conditions?: unknown }>, key: string): + { key: string; conditions?: unknown } | undefined { + for (const node of nodes) { + if (node.key === key) return node; + const found = findLayout(node.children as Array<{ key: string; children: unknown[] }>, key); + if (found) return found; + } + return undefined; +}