Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 9 additions & 2 deletions .github/agents/CLI.release.agent.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<schema-version>` entry
2. Read the specs-from-figma CHANGELOG for the `<specs-from-figma-version>` entry
Expand Down
16 changes: 9 additions & 7 deletions .github/agents/Schema.release.agent.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
13 changes: 12 additions & 1 deletion packages/cli/src/commands/TransformCommand.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand All @@ -20,6 +21,7 @@ export const Transform = new Command('transform')
.argument('[transformers...]', 'Transformer names to run (default: contract)')
.option('-o, --output <path>', 'Path to the specs directory (input and output)')
.option('--config <path>', 'Path to config file (specs.config.yaml)')
.option('--components <keys...>', 'Only transform these component folders (default: all)')
.option('--verbose', 'Enable detailed logging', false)
.action(async (transformerNames: string[], options: TransformOptions) => {
try {
Expand Down Expand Up @@ -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');
Expand Down
75 changes: 71 additions & 4 deletions packages/cli/src/transforms/Contract.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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<string, unknown>)
: undefined;

// Main component
const mainLines = buildContractLines(prefix, (apiYaml.props ?? {}) as Record<string, unknown>, 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<string, unknown>, 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<string, unknown>;
const subVariants = (variantsYaml?.subcomponents ?? {}) as Record<string, unknown>;
for (const [subKey, subRaw] of Object.entries(subcomponents)) {
const sub = subRaw as Record<string, unknown>;
const subPrefix = `${prefix}${toPascalCase(subKey)}`;
const subProps = (sub.props ?? {}) as Record<string, unknown>;
const subLines = buildContractLines(subPrefix, subProps, omittedProps);
const subDir = path.join(outputDir, subKey);
const subVariantsYaml = subVariants[subKey] as Record<string, unknown> | 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');
}
Expand All @@ -33,6 +51,7 @@ function buildContractLines(
prefix: string,
props: Record<string, unknown>,
omittedProps: Set<string>,
slots: SlotInfo[],
): string[] {
const lines: string[] = [
'// Generated. Do not edit — regenerate with `specs transform`.',
Expand Down Expand Up @@ -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<keyof ${prefix}Slots, ${prefix}SlotVisibility>;`);
lines.push('');

return lines;
}

Expand Down
94 changes: 90 additions & 4 deletions packages/cli/src/transforms/Css.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -25,15 +26,17 @@ 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<string, unknown>;
for (const [subKey, subRaw] of Object.entries(subcomponents)) {
const subVariantsYaml = subRaw as Record<string, unknown>;
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');
}
Expand Down Expand Up @@ -61,12 +64,43 @@ function buildCssLines(
// ── Default styles ─────────────────────────────────────────────────────────
const defaultBlock = variantsYaml.default as Record<string, unknown> | undefined;
const defaultElements = (defaultBlock?.elements ?? {}) as Record<string, Record<string, unknown>>;
const variantList = (variantsYaml.variants ?? []) as Array<Record<string, unknown>>;

// 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<string>();
collectLayoutKeys(parseLayout(defaultBlock?.layout), defaultKeys);
const structuralKeys = new Set<string>();
// 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<string>();
{
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<string>();
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<string, unknown>;
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};`);
Expand Down Expand Up @@ -136,12 +170,31 @@ function buildCssLines(

const variantElements = (variant.elements ?? {}) as Record<string, Record<string, unknown>>;

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<string, string>();
if (variant.layout) {
const variantKeys = new Set<string>();
collectLayoutKeys(parseLayout(variant.layout), variantKeys);
for (const key of variantKeys) {
if (!structuralKeys.has(key)) continue;
const styles = (defaultElements[key]?.styles ?? {}) as Record<string, unknown>;
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<string, unknown>;
const styles = (variantElements[elemKey]?.styles ?? {}) as Record<string, unknown>;
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} {`);
Expand All @@ -155,6 +208,39 @@ function buildCssLines(
return lines;
}

function collectLayoutKeys(nodes: LayoutNode[], into: Set<string>): 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<string, Record<string, unknown>>,
into: Set<string>,
parent?: string,
): void {
const isAbsolute = (key: string) =>
((elements[key]?.styles ?? {}) as Record<string, unknown>).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}`
Expand Down
Loading