diff --git a/adr/INDEX.md b/adr/INDEX.md index 3a0db5f..4b6947b 100644 --- a/adr/INDEX.md +++ b/adr/INDEX.md @@ -4,6 +4,7 @@ | # | Title | Highlights | |---|-------|------------| +| 044 | Duplicate Layer Name Disambiguation | | | 043 | Custom Color Format Configuration | | | 042 | Composition as a First-Class Type | | | 041 | Layout Positioning — Constraint-Based Naming | | diff --git a/packages/cli/CHANGELOG.md b/packages/cli/CHANGELOG.md index d4c626b..6d330cd 100644 --- a/packages/cli/CHANGELOG.md +++ b/packages/cli/CHANGELOG.md @@ -5,6 +5,15 @@ All notable changes to `@directededges/specs-cli` are documented here. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.14.0] - Unreleased + +### Added + +### Changed + +### Removed + + ## [0.13.0] - 2026-05-06 Adds configurable color output format (`config.format.color`) with nine options from hex strings to structured DTCG Color objects. Fixes EISDIR crash when outputDirectory targets a directory, and corrects config template URLs. diff --git a/packages/cli/package.json b/packages/cli/package.json index 8ac5449..ff41b16 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@directededges/specs-cli", - "version": "0.13.0", + "version": "0.14.0", "description": "Command-line interface for Specs design system operations", "type": "module", "main": "./dist/index.js", diff --git a/packages/cli/src/commands/ScanCommand.ts b/packages/cli/src/commands/ScanCommand.ts index 4d22963..01fc028 100644 --- a/packages/cli/src/commands/ScanCommand.ts +++ b/packages/cli/src/commands/ScanCommand.ts @@ -8,9 +8,12 @@ import { Command } from 'commander'; import fs from 'fs-extra'; import path from 'path'; import yaml from 'yaml'; -import { ComponentDiscovery, type ComponentInfo } from '../utilities/ComponentDiscovery.js'; +import { ComponentDiscovery, type ComponentInfo, type DevStatus } from '../utilities/ComponentDiscovery.js'; +import { ManifestParserV2, type ManifestRowV2 } from '../utilities/ManifestParserV2.js'; +import { isV1Manifest, migrateV1ToV2 } from '../utilities/ManifestMigrationV1ToV2.js'; + +const SCAN_FORMAT_VERSION = 2; -// Error codes from contracts/error-codes.md const ERROR_CODES = { SUCCESS: 0, GENERAL_ERROR: 1, @@ -24,6 +27,8 @@ interface ScanOptions { config?: string; source?: string; includeAll: boolean; + keepChecks: boolean; + resetChecks: boolean; variables?: string; verbose: boolean; } @@ -59,77 +64,154 @@ function loadConfig(configPath?: string): { configDir: string; config: MinimalCo }; } -interface AuditComponentInfo extends ComponentInfo { - included: boolean; +/** + * Default inclusion when no prior manifest exists (or --reset-checks). + * + * - If ANY component in the file has devStatus=READY_FOR_DEV, only those + * components are checked. Designers are signalling curation explicitly. + * - Otherwise (no devStatus signal anywhere), fall back to the legacy + * heuristic: include all COMPONENT_SETs and standalone COMPONENTs. + */ +export function deriveDefaultInclusion( + components: ComponentInfo[], + includeAll: boolean +): Map { + const result = new Map(); + const anyReady = components.some(c => c.devStatus === 'READY_FOR_DEV'); + + for (const c of components) { + if (includeAll) { + result.set(c.id, true); + continue; + } + if (anyReady) { + result.set(c.id, c.devStatus === 'READY_FOR_DEV'); + } else { + // Legacy heuristic: COMPONENT_SET and standalone COMPONENT both included. + result.set(c.id, c.type === 'COMPONENT' || c.type === 'COMPONENT_SET'); + } + } + return result; +} + +export interface MergeStats { + added: number; + removed: number; + flippedByFigma: number; + preserved: number; } /** - * Apply heuristics to determine if a component should be included by default - * - * Rules: - * - COMPONENT_SET: included (likely design system components) - * - Other COMPONENT: included - * - * Future heuristics (commented out until needed): - * - Use isAsset property if Figma REST API adds it - * - Documentation/Examples exclusion patterns + * Merge prior rows with current scan. + * + * Rules (default): + * - New rows: use deriveDefaultInclusion. + * - Existing rows where devStatus changed: Figma wins — use new devStatus's + * implied check state (READY_FOR_DEV → checked, NONE → unchecked). + * - Existing rows where devStatus unchanged: preserve prior checkbox. + * - Removed rows: dropped. + * + * `--keep-checks`: prior checkbox always wins for existing rows; devStatus + * column still updates. New rows still use deriveDefaultInclusion. */ -function applyDefaultInclusion(component: AuditComponentInfo): boolean { - // Rule 1: COMPONENT_SET are included by default (variant sets are design system components) - if (component.type === 'COMPONENT_SET') { - return true; +export function mergeRows( + current: ComponentInfo[], + prior: ManifestRowV2[], + defaults: Map, + keepChecks: boolean +): { rows: ManifestRowV2[]; stats: MergeStats } { + const priorById = new Map(prior.map(r => [r.id, r])); + const stats: MergeStats = { added: 0, removed: 0, flippedByFigma: 0, preserved: 0 }; + + const rows: ManifestRowV2[] = current.map(c => { + const prev = priorById.get(c.id); + if (!prev) { + stats.added++; + return { + id: c.id, + name: c.name, + type: c.type as 'COMPONENT' | 'COMPONENT_SET', + included: defaults.get(c.id) ?? false, + devStatus: c.devStatus + }; + } + + let included: boolean; + if (keepChecks) { + included = prev.included; + stats.preserved++; + } else if (prev.devStatus !== c.devStatus) { + included = c.devStatus === 'READY_FOR_DEV'; + stats.flippedByFigma++; + } else { + included = prev.included; + stats.preserved++; + } + + return { + id: c.id, + name: c.name, + type: c.type as 'COMPONENT' | 'COMPONENT_SET', + included, + devStatus: c.devStatus + }; + }); + + const currentIds = new Set(current.map(c => c.id)); + for (const prev of prior) { + if (!currentIds.has(prev.id)) stats.removed++; } - // Rule 2: Standalone COMPONENT are included by default - // Future: Could use isAsset property when available in REST API - // const iconPatterns = ['icon /', 'icons /', 'icon/', 'icons/', 'asset /', 'assets /']; - // if (node.isAsset || iconPatterns.some(pattern => name.toLowerCase().startsWith(pattern))) { - // return false; - // } + return { rows, stats }; +} + +function readPriorManifest(outputPath: string): ManifestRowV2[] | null { + if (!fs.existsSync(outputPath)) return null; + const content = fs.readFileSync(outputPath, 'utf-8'); - // Rule 3: Documentation/example exclusions (commented out for now) - // const docPatterns = ['documentation /', 'example ', 'examples /', 'demo /', 'test /']; - // if (docPatterns.some(pattern => name.toLowerCase().startsWith(pattern))) { - // return false; - // } + // ⬇️ ManifestMigrationV1ToV2 — DELETE WITH ManifestParser.ts when v1 is retired. + if (isV1Manifest(content)) { + return migrateV1ToV2(content); + } - return true; + if (ManifestParserV2.isV2(content)) { + return ManifestParserV2.parse(content).components; + } + return null; } -/** - * Generate markdown manifest with checkbox format - */ -function generateManifest( - components: AuditComponentInfo[], +function escapeCell(value: string): string { + return value.replace(/\|/g, '\\|'); +} + +function generateManifestV2( + rows: ManifestRowV2[], sourceFile: string, + fileLastModified: string | undefined, variablesFile?: string ): string { - const timestamp = new Date().toISOString(); - const lines: string[] = []; - - // Manifest header - lines.push(`# Component Manifest`); + lines.push('# Component Manifest'); lines.push(''); - lines.push(`**Generated:** ${timestamp} `); + lines.push(`**Scan format version:** ${SCAN_FORMAT_VERSION} `); + lines.push(`**Generated:** ${new Date().toISOString()} `); lines.push(`**File:** ${sourceFile}`); - if (variablesFile) { - lines.push(`**Variables:** ${variablesFile}`); - } + if (variablesFile) lines.push(`**Variables:** ${variablesFile}`); + if (fileLastModified) lines.push(`**File last modified:** ${fileLastModified}`); lines.push(''); lines.push('---'); lines.push(''); - lines.push('## Components'); lines.push(''); - - // Component list with checkboxes - for (const component of components) { - const checkbox = component.included ? '[x]' : '[ ]'; - lines.push(`- ${checkbox} ${component.name} (${component.id}, ${component.type})`); + lines.push('| ✓ | Name | ID | Type | Dev Status |'); + lines.push('|------|------|------|------|------------|'); + for (const row of rows) { + const checkbox = row.included ? '[x]' : '[ ]'; + lines.push( + `| ${checkbox} | ${escapeCell(row.name)} | ${row.id} | ${row.type} | ${row.devStatus} |` + ); } - - return lines.join('\n'); + return lines.join('\n') + '\n'; } export const Scan = new Command('scan') @@ -139,16 +221,22 @@ export const Scan = new Command('scan') .option('-o, --output ', 'Output manifest file path (default: {dataDirectory}/{alias}.manifest.md)') .option('--data-dir ', 'Override data directory for default manifest output path') .option('--config ', 'Path to config file (specs.config.yaml)') - .option('--include-all', 'Include all components by default (ignore heuristics)', false) + .option('--include-all', 'Include all components (overrides devStatus and heuristics)', false) + .option('--keep-checks', 'Preserve prior checkbox state for existing rows; ignore devStatus changes', false) + .option('--reset-checks', 'Ignore prior manifest and re-derive checks from devStatus / heuristics', false) .option('-v, --variables ', 'Variables file path (for reference in manifest)') .option('--verbose', 'Enable detailed logging', false) .action(async (fileArg: string | undefined, options: ScanOptions) => { try { + if (options.keepChecks && options.resetChecks) { + console.error('Error: --keep-checks and --reset-checks are mutually exclusive'); + process.exit(ERROR_CODES.INVALID_ARGS); + } + const { configDir, config } = loadConfig(options.config); const dataDir = options.dataDir || config.dataDirectory || config.sourceDirectory; const resolvedDir = path.resolve(configDir, dataDir || '.'); - // Resolve file: explicit arg wins, else derive from config sources let file: string; if (fileArg) { if (options.source) { @@ -193,7 +281,6 @@ export const Scan = new Command('scan') } } - // Resolve output path: explicit -o, or derive from input filename if (!options.output) { const baseName = path.basename(file, '.file.json'); options.output = path.join(resolvedDir, `${baseName}.manifest.md`); @@ -203,7 +290,6 @@ export const Scan = new Command('scan') console.error(`[CLI] Scanning file: ${file}`); } - // Validate file exists if (!fs.existsSync(file)) { console.error(`Error: File not found: ${file}`); if (!fileArg) { @@ -212,67 +298,75 @@ export const Scan = new Command('scan') process.exit(ERROR_CODES.FILE_ERROR); } - // Load the Figma file and discover components const discovery = await ComponentDiscovery.fromFile(file); - + if (options.verbose) { console.error(`[CLI] File loaded: ${discovery.getFileName()}`); } - // Find all components (automatically filters variant children) const componentInfoList = discovery.findAllComponents(); - + if (componentInfoList.length === 0) { console.error('Warning: No components found in file'); } if (options.verbose) { - console.error(`[CLI] Found ${componentInfoList.length} top-level components`); + const readyCount = componentInfoList.filter(c => c.devStatus === 'READY_FOR_DEV').length; + console.error(`[CLI] Found ${componentInfoList.length} top-level components (${readyCount} READY_FOR_DEV)`); + } + + // Sort by name for stable diffs + componentInfoList.sort((a, b) => a.name.localeCompare(b.name)); + + const outputPath = path.resolve(options.output!); + const prior = options.resetChecks ? null : readPriorManifest(outputPath); + const defaults = deriveDefaultInclusion(componentInfoList, options.includeAll); + + let rows: ManifestRowV2[]; + let stats: MergeStats | null = null; + if (prior && !options.includeAll) { + const merged = mergeRows(componentInfoList, prior, defaults, options.keepChecks); + rows = merged.rows; + stats = merged.stats; + } else { + rows = componentInfoList.map(c => ({ + id: c.id, + name: c.name, + type: c.type as 'COMPONENT' | 'COMPONENT_SET', + included: defaults.get(c.id) ?? false, + devStatus: c.devStatus as DevStatus + })); } - // Build component info list with heuristics - const components: AuditComponentInfo[] = componentInfoList.map(node => { - const info: AuditComponentInfo = { - id: node.id, - name: node.name, - type: node.type as 'COMPONENT' | 'COMPONENT_SET', - included: false - }; - - // Apply heuristics unless --include-all is set - info.included = options.includeAll || applyDefaultInclusion(info); - - return info; - }); - - // Sort by name for better readability - components.sort((a, b) => a.name.localeCompare(b.name)); - - const includedCount = components.filter(c => c.included).length; - const excludedCount = components.length - includedCount; - - // Generate markdown manifest - const manifest = generateManifest( - components, + const manifest = generateManifestV2( + rows, path.resolve(file), + discovery.getFileLastModified(), options.variables ? path.resolve(options.variables) : undefined ); - // Write manifest to output file - const outputPath = path.resolve(options.output!); await fs.ensureDir(path.dirname(outputPath)); await fs.writeFile(outputPath, manifest, 'utf-8'); - // Success message + const includedCount = rows.filter(r => r.included).length; + const excludedCount = rows.length - includedCount; + console.log(`✓ Scanned ${path.basename(file)}`); - console.log(`✓ Found ${components.length} components (${includedCount} selected, ${excludedCount} excluded)`); + console.log(`✓ Found ${rows.length} components (${includedCount} selected, ${excludedCount} excluded)`); + if (stats) { + const parts: string[] = []; + if (stats.added) parts.push(`${stats.added} added`); + if (stats.removed) parts.push(`${stats.removed} removed`); + if (stats.flippedByFigma) parts.push(`${stats.flippedByFigma} updated by devStatus`); + if (stats.preserved) parts.push(`${stats.preserved} preserved`); + if (parts.length) console.log(` Merge: ${parts.join(', ')}`); + } console.log(`✓ Saved to ${outputPath}`); console.log(''); console.log(`Next: Edit ${path.basename(outputPath)} to adjust selections, then run:`); console.log(` specs generate`); process.exit(ERROR_CODES.SUCCESS); - } catch (error) { const message = error instanceof Error ? error.message : String(error); console.error(`Error: ${message}`); diff --git a/packages/cli/src/utilities/ComponentDiscovery.ts b/packages/cli/src/utilities/ComponentDiscovery.ts index 9e0abfd..8e91925 100644 --- a/packages/cli/src/utilities/ComponentDiscovery.ts +++ b/packages/cli/src/utilities/ComponentDiscovery.ts @@ -23,8 +23,11 @@ interface RestApiNode { name: string; type: string; children?: RestApiNode[]; + devStatus?: { type?: string; description?: string }; } +export type DevStatus = 'READY_FOR_DEV' | 'NONE'; + /** * Minimal structure for REST API file data */ @@ -33,6 +36,7 @@ export interface RestApiFileData { components?: Record; componentSets?: Record; name?: string; + lastModified?: string; } /** @@ -45,6 +49,8 @@ export interface ComponentInfo { name: string; /** Node type (COMPONENT or COMPONENT_SET) */ type: string; + /** Dev-ready status from Figma. 'NONE' when the property is absent on the node. */ + devStatus: DevStatus; } /** @@ -118,11 +124,12 @@ export class ComponentDiscovery { components.push({ id: node.id, name: node.name, - type: node.type + type: node.type, + devStatus: node.devStatus?.type === 'READY_FOR_DEV' ? 'READY_FOR_DEV' : 'NONE' }); continue; } - + // For COMPONENTs, check if parent is a COMPONENT_SET if (node.type === 'COMPONENT') { const parentId = this._parentMap.get(node.id); @@ -133,11 +140,12 @@ export class ComponentDiscovery { continue; } } - + components.push({ id: node.id, name: node.name, - type: node.type + type: node.type, + devStatus: node.devStatus?.type === 'READY_FOR_DEV' ? 'READY_FOR_DEV' : 'NONE' }); } } @@ -151,4 +159,9 @@ export class ComponentDiscovery { getFileName(): string { return this._data.name || 'Untitled'; } + + /** File-level lastModified (ISO 8601) from the REST API payload, if present. */ + getFileLastModified(): string | undefined { + return this._data.lastModified; + } } diff --git a/packages/cli/src/utilities/ManifestMigrationV1ToV2.ts b/packages/cli/src/utilities/ManifestMigrationV1ToV2.ts new file mode 100644 index 0000000..ca27dc1 --- /dev/null +++ b/packages/cli/src/utilities/ManifestMigrationV1ToV2.ts @@ -0,0 +1,37 @@ +/** + * v1 → v2 manifest migration (ISOLATED LEGACY MODULE). + * + * DELETE-WHEN: all known users have re-scanned with the v2 emitter and there + * are no v1 manifests in the wild. Removal is mechanical: + * 1. Delete this file. + * 2. Delete `ManifestParser.ts` (only consumer is this module). + * 3. Delete the single `isV1Manifest`/`migrateV1ToV2` import + branch in + * `ScanCommand.ts` (search for "ManifestMigrationV1ToV2"). + * + * No other code path imports from here. Keep it that way. + */ + +import { ManifestParser } from './ManifestParser.js'; +import type { ManifestRowV2 } from './ManifestParserV2.js'; + +/** True if the content looks like a v1 manifest (checkbox-list, no scan format version header). */ +export function isV1Manifest(content: string): boolean { + if (/\*\*Scan format version:\*\*/m.test(content)) return false; + return /^-\s+\[[x ]\]\s+/m.test(content); +} + +/** + * Lift v1 rows into the v2 row shape. devStatus is unknown in v1 so we mark it + * as 'NONE' — the merge step in ScanCommand treats this as "no transition" so + * user checkbox state is preserved. + */ +export function migrateV1ToV2(content: string): ManifestRowV2[] { + const { components } = ManifestParser.parse(content); + return components.map(c => ({ + id: c.id, + name: c.name, + type: c.type, + included: c.included, + devStatus: 'NONE' + })); +} diff --git a/packages/cli/src/utilities/ManifestParserV2.ts b/packages/cli/src/utilities/ManifestParserV2.ts new file mode 100644 index 0000000..1fb7d4f --- /dev/null +++ b/packages/cli/src/utilities/ManifestParserV2.ts @@ -0,0 +1,84 @@ +/** + * Parses v2 manifest files emitted by `specs scan`. + * + * Format (table form): + * **Scan format version:** 2 + * **File:** path/to/file.json + * + * | ✓ | Name | ID | Type | Dev Status | + * |---|------|----|------|------------| + * | [x] | Button | 1:23 | COMPONENT_SET | READY_FOR_DEV | + * | [ ] | Card | 1:45 | COMPONENT | NONE | + */ + +import type { DevStatus } from './ComponentDiscovery.js'; + +export interface ManifestRowV2 { + id: string; + name: string; + type: 'COMPONENT' | 'COMPONENT_SET'; + included: boolean; + devStatus: DevStatus; +} + +export interface ManifestMetadataV2 { + scanFormatVersion: number; + file?: string; + variables?: string; + fileLastModified?: string; +} + +export interface ManifestResultV2 { + components: ManifestRowV2[]; + metadata: ManifestMetadataV2; +} + +const SCAN_FORMAT_VERSION_REGEX = /\*\*Scan format version:\*\*\s+(\d+)/m; +const FILE_HEADER_REGEX = /\*\*File:\*\*\s+(.+?)$/m; +const VARIABLES_HEADER_REGEX = /\*\*Variables:\*\*\s+(.+?)$/m; +const FILE_LAST_MODIFIED_REGEX = /\*\*File last modified:\*\*\s+(.+?)$/m; + +// | [x] | Name | id | TYPE | DEV_STATUS | +const ROW_REGEX = + /^\|\s*\[([x ])\]\s*\|\s*(.+?)\s*\|\s*(\d+:\d+)\s*\|\s*(COMPONENT_SET|COMPONENT)\s*\|\s*(READY_FOR_DEV|NONE)\s*\|/i; + +export class ManifestParserV2 { + /** Returns true when the content declares scan format version 2 (or higher). */ + static isV2(content: string): boolean { + const match = content.match(SCAN_FORMAT_VERSION_REGEX); + if (!match) return false; + return Number(match[1]) >= 2; + } + + static parse(content: string): ManifestResultV2 { + const versionMatch = content.match(SCAN_FORMAT_VERSION_REGEX); + const metadata: ManifestMetadataV2 = { + scanFormatVersion: versionMatch ? Number(versionMatch[1]) : 2 + }; + + const fileMatch = content.match(FILE_HEADER_REGEX); + if (fileMatch) metadata.file = fileMatch[1].trim(); + + const variablesMatch = content.match(VARIABLES_HEADER_REGEX); + if (variablesMatch) metadata.variables = variablesMatch[1].trim(); + + const lastModifiedMatch = content.match(FILE_LAST_MODIFIED_REGEX); + if (lastModifiedMatch) metadata.fileLastModified = lastModifiedMatch[1].trim(); + + const components: ManifestRowV2[] = []; + for (const line of content.split('\n')) { + const match = line.match(ROW_REGEX); + if (!match) continue; + const [, checkbox, name, id, type, devStatus] = match; + components.push({ + id, + name: name.trim(), + type: type.toUpperCase() as 'COMPONENT' | 'COMPONENT_SET', + included: checkbox.toLowerCase() === 'x', + devStatus: devStatus.toUpperCase() as DevStatus + }); + } + + return { components, metadata }; + } +} diff --git a/packages/cli/tests/integration/parity.cli.test.ts b/packages/cli/tests/integration/parity.cli.test.ts index fd50f45..2dfb39c 100644 --- a/packages/cli/tests/integration/parity.cli.test.ts +++ b/packages/cli/tests/integration/parity.cli.test.ts @@ -42,6 +42,10 @@ function normalizeManifest(content: string): string { .replace(/\*\*File:\*\*.*\n/, '**File:** \n'); } +function trimTrailing(s: string): string { + return s.replace(/\s+$/, ''); +} + describe('CLI parity', () => { it('matches the scan manifest format', async () => { const testDir = path.join(process.cwd(), 'tests', 'tmp', `cli-parity-${Date.now()}`); @@ -88,6 +92,7 @@ describe('CLI parity', () => { const expected = [ '# Component Manifest', '', + '**Scan format version:** 2 ', '**Generated:** ', '**File:** ', '', @@ -95,11 +100,13 @@ describe('CLI parity', () => { '', '## Components', '', - '- [x] Alert (2:1, COMPONENT)', - '- [x] Button Set (3:1, COMPONENT_SET)' + '| ✓ | Name | ID | Type | Dev Status |', + '|------|------|------|------|------------|', + '| [x] | Alert | 2:1 | COMPONENT | NONE |', + '| [x] | Button Set | 3:1 | COMPONENT_SET | NONE |' ].join('\n'); - expect(normalized).toBe(expected); + expect(trimTrailing(normalized)).toBe(expected); await fs.remove(testDir); }); diff --git a/packages/cli/tests/unit/commands/ScanCommand.test.ts b/packages/cli/tests/unit/commands/ScanCommand.test.ts index 814b0d6..64966c2 100644 --- a/packages/cli/tests/unit/commands/ScanCommand.test.ts +++ b/packages/cli/tests/unit/commands/ScanCommand.test.ts @@ -13,6 +13,8 @@ describe('ScanCommand', () => { expect(options).toContain('--output'); expect(options).toContain('--source'); expect(options).toContain('--include-all'); + expect(options).toContain('--keep-checks'); + expect(options).toContain('--reset-checks'); expect(options).toContain('--variables'); expect(options).toContain('--verbose'); }); diff --git a/packages/cli/tests/unit/commands/ScanCommandMerge.test.ts b/packages/cli/tests/unit/commands/ScanCommandMerge.test.ts new file mode 100644 index 0000000..1856af9 --- /dev/null +++ b/packages/cli/tests/unit/commands/ScanCommandMerge.test.ts @@ -0,0 +1,113 @@ +import { describe, it, expect } from 'vitest'; +import { deriveDefaultInclusion, mergeRows } from '../../../src/commands/ScanCommand.js'; +import type { ComponentInfo } from '../../../src/utilities/ComponentDiscovery.js'; +import type { ManifestRowV2 } from '../../../src/utilities/ManifestParserV2.js'; + +function ci(id: string, name: string, type: 'COMPONENT' | 'COMPONENT_SET', devStatus: 'READY_FOR_DEV' | 'NONE'): ComponentInfo { + return { id, name, type, devStatus }; +} + +function row(id: string, name: string, included: boolean, devStatus: 'READY_FOR_DEV' | 'NONE', type: 'COMPONENT' | 'COMPONENT_SET' = 'COMPONENT_SET'): ManifestRowV2 { + return { id, name, type, included, devStatus }; +} + +describe('deriveDefaultInclusion', () => { + it('checks only READY_FOR_DEV when any component has it', () => { + const components = [ + ci('1:1', 'A', 'COMPONENT_SET', 'READY_FOR_DEV'), + ci('1:2', 'B', 'COMPONENT_SET', 'NONE'), + ci('1:3', 'C', 'COMPONENT', 'NONE') + ]; + const result = deriveDefaultInclusion(components, false); + expect(result.get('1:1')).toBe(true); + expect(result.get('1:2')).toBe(false); + expect(result.get('1:3')).toBe(false); + }); + + it('falls back to legacy heuristic (all included) when no devStatus signal exists', () => { + const components = [ + ci('1:1', 'A', 'COMPONENT_SET', 'NONE'), + ci('1:2', 'B', 'COMPONENT', 'NONE') + ]; + const result = deriveDefaultInclusion(components, false); + expect(result.get('1:1')).toBe(true); + expect(result.get('1:2')).toBe(true); + }); + + it('--include-all forces every row checked regardless of devStatus', () => { + const components = [ + ci('1:1', 'A', 'COMPONENT_SET', 'READY_FOR_DEV'), + ci('1:2', 'B', 'COMPONENT_SET', 'NONE') + ]; + const result = deriveDefaultInclusion(components, true); + expect(result.get('1:1')).toBe(true); + expect(result.get('1:2')).toBe(true); + }); +}); + +describe('mergeRows', () => { + it('preserves prior checkbox when devStatus unchanged', () => { + const current = [ci('1:1', 'A', 'COMPONENT_SET', 'NONE')]; + const prior = [row('1:1', 'A', true, 'NONE')]; + const defaults = new Map([['1:1', false]]); + const { rows, stats } = mergeRows(current, prior, defaults, false); + expect(rows[0].included).toBe(true); + expect(stats.preserved).toBe(1); + expect(stats.flippedByFigma).toBe(0); + }); + + it('flips checkbox to match Figma when devStatus changes (NONE → READY_FOR_DEV)', () => { + const current = [ci('1:1', 'A', 'COMPONENT_SET', 'READY_FOR_DEV')]; + const prior = [row('1:1', 'A', false, 'NONE')]; + const defaults = new Map([['1:1', true]]); + const { rows, stats } = mergeRows(current, prior, defaults, false); + expect(rows[0].included).toBe(true); + expect(rows[0].devStatus).toBe('READY_FOR_DEV'); + expect(stats.flippedByFigma).toBe(1); + }); + + it('flips checkbox off when devStatus regresses (READY_FOR_DEV → NONE)', () => { + const current = [ci('1:1', 'A', 'COMPONENT_SET', 'NONE')]; + const prior = [row('1:1', 'A', true, 'READY_FOR_DEV')]; + const defaults = new Map([['1:1', false]]); + const { rows, stats } = mergeRows(current, prior, defaults, false); + expect(rows[0].included).toBe(false); + expect(stats.flippedByFigma).toBe(1); + }); + + it('--keep-checks preserves prior checkbox even when devStatus changes', () => { + const current = [ci('1:1', 'A', 'COMPONENT_SET', 'READY_FOR_DEV')]; + const prior = [row('1:1', 'A', false, 'NONE')]; + const defaults = new Map([['1:1', true]]); + const { rows, stats } = mergeRows(current, prior, defaults, true); + expect(rows[0].included).toBe(false); + expect(rows[0].devStatus).toBe('READY_FOR_DEV'); + expect(stats.preserved).toBe(1); + expect(stats.flippedByFigma).toBe(0); + }); + + it('uses defaults map for newly-added components', () => { + const current = [ + ci('1:1', 'A', 'COMPONENT_SET', 'NONE'), + ci('1:2', 'B', 'COMPONENT_SET', 'READY_FOR_DEV') + ]; + const prior = [row('1:1', 'A', true, 'NONE')]; + const defaults = new Map([['1:1', false], ['1:2', true]]); + const { rows, stats } = mergeRows(current, prior, defaults, false); + const newRow = rows.find(r => r.id === '1:2')!; + expect(newRow.included).toBe(true); + expect(stats.added).toBe(1); + }); + + it('counts removed components from prior', () => { + const current = [ci('1:1', 'A', 'COMPONENT_SET', 'NONE')]; + const prior = [ + row('1:1', 'A', true, 'NONE'), + row('9:9', 'Gone', true, 'NONE') + ]; + const defaults = new Map([['1:1', false]]); + const { rows, stats } = mergeRows(current, prior, defaults, false); + expect(rows).toHaveLength(1); + expect(stats.removed).toBe(1); + }); +}); diff --git a/packages/cli/tests/unit/utilities/ComponentDiscovery.test.ts b/packages/cli/tests/unit/utilities/ComponentDiscovery.test.ts index 3159e82..9336204 100644 --- a/packages/cli/tests/unit/utilities/ComponentDiscovery.test.ts +++ b/packages/cli/tests/unit/utilities/ComponentDiscovery.test.ts @@ -102,8 +102,8 @@ describe('ComponentDiscovery', () => { const components = discovery.findAllComponents(); expect(components).toHaveLength(2); - expect(components[0]).toEqual({ id: '2:1', name: 'Button', type: 'COMPONENT' }); - expect(components[1]).toEqual({ id: '2:2', name: 'Card', type: 'COMPONENT' }); + expect(components[0]).toEqual({ id: '2:1', name: 'Button', type: 'COMPONENT', devStatus: 'NONE' }); + expect(components[1]).toEqual({ id: '2:2', name: 'Card', type: 'COMPONENT', devStatus: 'NONE' }); }); it('should find COMPONENT_SET nodes', async () => { @@ -151,7 +151,53 @@ describe('ComponentDiscovery', () => { const components = discovery.findAllComponents(); expect(components).toHaveLength(1); - expect(components[0]).toEqual({ id: '3:1', name: 'Button Set', type: 'COMPONENT_SET' }); + expect(components[0]).toEqual({ id: '3:1', name: 'Button Set', type: 'COMPONENT_SET', devStatus: 'NONE' }); + }); + + it('should extract devStatus when present on component nodes', async () => { + const filePath = path.join(testDir, 'library.json'); + const data = { + name: 'Test Library', + lastModified: '2026-05-08T17:48:26Z', + document: { + id: '0:0', + name: 'Document', + type: 'DOCUMENT', + children: [ + { + id: '1:1', + name: 'Page', + type: 'CANVAS', + children: [ + { + id: '397:37', + name: 'Ready Set', + type: 'COMPONENT_SET', + devStatus: { type: 'READY_FOR_DEV', description: '' }, + children: [] + }, + { + id: '397:38', + name: 'Idle Set', + type: 'COMPONENT_SET', + children: [] + } + ] + } + ] + } + }; + + fs.writeJSONSync(filePath, data); + + const discovery = await ComponentDiscovery.fromFile(filePath); + const components = discovery.findAllComponents(); + + const ready = components.find(c => c.id === '397:37'); + const idle = components.find(c => c.id === '397:38'); + expect(ready?.devStatus).toBe('READY_FOR_DEV'); + expect(idle?.devStatus).toBe('NONE'); + expect(discovery.getFileLastModified()).toBe('2026-05-08T17:48:26Z'); }); it('should exclude variant children from results', async () => { diff --git a/packages/cli/tests/unit/utilities/ManifestMigrationV1ToV2.test.ts b/packages/cli/tests/unit/utilities/ManifestMigrationV1ToV2.test.ts new file mode 100644 index 0000000..7ef6b63 --- /dev/null +++ b/packages/cli/tests/unit/utilities/ManifestMigrationV1ToV2.test.ts @@ -0,0 +1,52 @@ +import { describe, it, expect } from 'vitest'; +import { isV1Manifest, migrateV1ToV2 } from '../../../src/utilities/ManifestMigrationV1ToV2.js'; + +const V1 = `# Component Manifest + +**Generated:** 2026-04-01T00:00:00Z +**File:** data/library.file.json + +## Components + +- [x] Button (1:23, COMPONENT_SET) +- [ ] Card (1:45, COMPONENT) +- [x] Icon (2:1, COMPONENT) +`; + +describe('ManifestMigrationV1ToV2', () => { + describe('isV1Manifest', () => { + it('returns true for legacy checkbox-list format', () => { + expect(isV1Manifest(V1)).toBe(true); + }); + + it('returns false when scan format version header is present', () => { + const v2 = '**Scan format version:** 2\n\n- [x] Old (1:1, COMPONENT)\n'; + expect(isV1Manifest(v2)).toBe(false); + }); + + it('returns false for content with no checkbox rows', () => { + expect(isV1Manifest('# Some doc\n\nJust prose.')).toBe(false); + }); + }); + + describe('migrateV1ToV2', () => { + it('lifts v1 rows into v2 row shape with devStatus=NONE', () => { + const rows = migrateV1ToV2(V1); + expect(rows).toHaveLength(3); + expect(rows[0]).toEqual({ + id: '1:23', + name: 'Button', + type: 'COMPONENT_SET', + included: true, + devStatus: 'NONE' + }); + expect(rows[1]).toMatchObject({ id: '1:45', included: false, devStatus: 'NONE' }); + expect(rows[2]).toMatchObject({ id: '2:1', name: 'Icon', included: true }); + }); + + it('preserves checkbox state across migration', () => { + const rows = migrateV1ToV2(V1); + expect(rows.filter(r => r.included).map(r => r.id)).toEqual(['1:23', '2:1']); + }); + }); +}); diff --git a/packages/cli/tests/unit/utilities/ManifestParserV2.test.ts b/packages/cli/tests/unit/utilities/ManifestParserV2.test.ts new file mode 100644 index 0000000..ca71205 --- /dev/null +++ b/packages/cli/tests/unit/utilities/ManifestParserV2.test.ts @@ -0,0 +1,66 @@ +import { describe, it, expect } from 'vitest'; +import { ManifestParserV2 } from '../../../src/utilities/ManifestParserV2.js'; + +const V2_FIXTURE = `# Component Manifest + +**Scan format version:** 2 +**Generated:** 2026-05-08T18:02:11Z +**File:** data/specs-testing.file.json +**Variables:** data/specs-testing.variables.json +**File last modified:** 2026-05-08T17:48:26Z + +--- + +## Components + +| ✓ | Name | ID | Type | Dev Status | +|------|------|------|------|------------| +| [x] | TEST PropBinding 1 | 397:37 | COMPONENT_SET | READY_FOR_DEV | +| [ ] | Button | 1:23 | COMPONENT_SET | NONE | +| [x] | Card | 1:45 | COMPONENT | NONE | +`; + +describe('ManifestParserV2', () => { + it('detects v2 from header', () => { + expect(ManifestParserV2.isV2(V2_FIXTURE)).toBe(true); + }); + + it('does not detect v1 (checkbox-list) as v2', () => { + const v1 = '# Component Manifest\n\n**File:** x.json\n\n- [x] Button (1:23, COMPONENT_SET)\n'; + expect(ManifestParserV2.isV2(v1)).toBe(false); + }); + + it('parses metadata fields', () => { + const { metadata } = ManifestParserV2.parse(V2_FIXTURE); + expect(metadata.scanFormatVersion).toBe(2); + expect(metadata.file).toBe('data/specs-testing.file.json'); + expect(metadata.variables).toBe('data/specs-testing.variables.json'); + expect(metadata.fileLastModified).toBe('2026-05-08T17:48:26Z'); + }); + + it('parses rows with checkbox, type and devStatus', () => { + const { components } = ManifestParserV2.parse(V2_FIXTURE); + expect(components).toHaveLength(3); + expect(components[0]).toEqual({ + id: '397:37', + name: 'TEST PropBinding 1', + type: 'COMPONENT_SET', + included: true, + devStatus: 'READY_FOR_DEV' + }); + expect(components[1].included).toBe(false); + expect(components[1].devStatus).toBe('NONE'); + expect(components[2]).toMatchObject({ id: '1:45', type: 'COMPONENT', included: true, devStatus: 'NONE' }); + }); + + it('ignores non-row lines (header separator, prose)', () => { + const { components } = ManifestParserV2.parse(V2_FIXTURE); + expect(components.every(c => /^\d+:\d+$/.test(c.id))).toBe(true); + }); + + it('returns empty components when no table rows present', () => { + const empty = '**Scan format version:** 2\n\n## Components\n\n| ✓ | Name | ID | Type | Dev Status |\n|---|---|---|---|---|\n'; + const { components } = ManifestParserV2.parse(empty); + expect(components).toEqual([]); + }); +}); diff --git a/packages/schema/CHANGELOG.md b/packages/schema/CHANGELOG.md index aea256e..be3dfb0 100644 --- a/packages/schema/CHANGELOG.md +++ b/packages/schema/CHANGELOG.md @@ -5,6 +5,15 @@ All notable changes to the Specs schema will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.21.0] - Unreleased + +### Added + +### Changed + +### Removed + + ## [0.20.0] - 2026-05-06 Adds configurable color output format (`Config.format.color`) supporting nine format options from hex strings to structured DTCG Color objects. Renames `ColorValue` to `ColorObject` for specificity and widens `ColorStyle`, `Shadow.color`, and `GradientStop.color` to accept formatted color strings alongside structured objects and token references. diff --git a/packages/schema/package.json b/packages/schema/package.json index 37ceff0..1797020 100644 --- a/packages/schema/package.json +++ b/packages/schema/package.json @@ -1,6 +1,6 @@ { "name": "@directededges/specs-schema", - "version": "0.20.0", + "version": "0.21.0", "description": "Specs UI Component Schema - TypeScript types and JSON schema definitions for component specifications", "license": "CC-BY-4.0", "author": "Nathan Curtis ", diff --git a/site/astro.config.mjs b/site/astro.config.mjs index be125e7..bd2b07d 100644 --- a/site/astro.config.mjs +++ b/site/astro.config.mjs @@ -16,6 +16,8 @@ export default defineConfig({ }, components: { SocialIcons: './src/components/SocialIcons.astro', + ThemeSelect: './src/components/ThemeSelect.astro', + Sidebar: './src/components/Sidebar.astro', }, customCss: ['./src/custom.css'], head: [ diff --git a/site/src/components/Sidebar.astro b/site/src/components/Sidebar.astro new file mode 100644 index 0000000..1e2ac8b --- /dev/null +++ b/site/src/components/Sidebar.astro @@ -0,0 +1,68 @@ +--- +import type { Props } from '@astrojs/starlight/props'; +import MobileMenuFooter from 'virtual:starlight/components/MobileMenuFooter'; +import SidebarPersister from '@astrojs/starlight/components/SidebarPersister.astro'; +import SidebarSublist from '@astrojs/starlight/components/SidebarSublist.astro'; + +const GITHUB_URL = 'https://github.com/DirectedEdges/specs'; +const FIGMA_PLUGIN_URL = 'https://www.figma.com/community/plugin/1549454283615386215/specs-2-formerly-anova'; +const GITHUB_PATH = 'M12 .3a12 12 0 0 0-3.8 23.38c.6.12.83-.26.83-.57L9 21.07c-3.34.72-4.04-1.61-4.04-1.61-.55-1.39-1.34-1.76-1.34-1.76-1.08-.74.09-.73.09-.73 1.2.09 1.83 1.24 1.83 1.24 1.08 1.83 2.81 1.3 3.5 1 .1-.78.42-1.31.76-1.61-2.67-.3-5.47-1.33-5.47-5.93 0-1.31.47-2.38 1.24-3.22-.14-.3-.54-1.52.1-3.18 0 0 1-.32 3.3 1.23a11.5 11.5 0 0 1 6 0c2.28-1.55 3.29-1.23 3.29-1.23.64 1.66.24 2.88.12 3.18a4.65 4.65 0 0 1 1.23 3.22c0 4.61-2.8 5.63-5.48 5.92.42.36.81 1.1.81 2.22l-.01 3.29c0 .31.2.69.82.57A12 12 0 0 0 12 .3Z'; +const FIGMA_PATH = 'M47.7206 95.4082C29.9906 95.4082 15.6176 109.776 15.6176 127.5C15.6176 145.224 29.9906 159.592 47.7206 159.592H80.6912V127.5V95.4082H47.7206ZM128.412 79.7959L129.279 79.7959C147.009 79.7959 161.382 65.4279 161.382 47.7041C161.382 29.9802 147.009 15.6122 129.279 15.6122H96.3088V79.7959L128.412 79.7959ZM155.448 87.602C168.429 79.0765 177 64.3908 177 47.7041C177 21.3578 155.635 0 129.279 0H96.3088H88.5H80.6912H47.7206C21.3652 0 0 21.3578 0 47.7041C0 64.3908 8.57068 79.0765 21.5515 87.602C8.57068 96.1276 0 110.813 0 127.5C0 144.187 8.57067 158.872 21.5515 167.398C8.57067 175.923 0 190.609 0 207.296C0 233.697 21.6358 255 47.9363 255C74.4764 255 96.3088 233.503 96.3088 206.862V175.204V167.398V162.796C104.785 170.505 116.05 175.204 128.412 175.204H129.279C155.635 175.204 177 153.846 177 127.5C177 110.813 168.429 96.1276 155.448 87.602ZM129.279 95.4082L128.412 95.4082C110.682 95.4082 96.3088 109.776 96.3088 127.5C96.3088 145.224 110.682 159.592 128.412 159.592H129.279C147.009 159.592 161.382 145.224 161.382 127.5C161.382 109.776 147.009 95.4082 129.279 95.4082ZM15.6176 207.296C15.6176 189.572 29.9906 175.204 47.7206 175.204H80.6912V206.862C80.6912 224.771 65.9608 239.388 47.9363 239.388C30.1515 239.388 15.6176 224.965 15.6176 207.296ZM80.6912 79.7959H47.7206C29.9906 79.7959 15.6176 65.4279 15.6176 47.7041C15.6176 29.9802 29.9906 15.6122 47.7206 15.6122H80.6912V79.7959Z'; + +const { sidebar } = Astro.props; +--- + + + + + + + +
+ +
+ + diff --git a/site/src/components/ThemeSelect.astro b/site/src/components/ThemeSelect.astro new file mode 100644 index 0000000..e0b8c30 --- /dev/null +++ b/site/src/components/ThemeSelect.astro @@ -0,0 +1,9 @@ +--- +// Force dark theme, hide the theme toggle. +--- + diff --git a/site/src/content/docs/cli/commands/scan.md b/site/src/content/docs/cli/commands/scan.md index 767bfbe..86db185 100644 --- a/site/src/content/docs/cli/commands/scan.md +++ b/site/src/content/docs/cli/commands/scan.md @@ -9,6 +9,117 @@ Scan a Figma file and generate a manifest of all components. specs scan [file] [options] ``` +## Format + +The manifest is a markdown file with a metadata header and a Components table: + +```markdown +# Component Manifest + +**Scan format version:** 2 +**Generated:** 2026-05-08T18:02:11Z +**File:** /absolute/path/to/data/library.file.json +**Variables:** /absolute/path/to/data/library.variables.json +**File last modified:** 2026-05-08T17:48:26Z + +--- + +## Components + +| ✓ | Name | ID | Type | Dev Status | +|------|------|------|------|------------| +| [x] | DS Accordion | 1234:5678 | COMPONENT_SET | READY_FOR_DEV | +| [x] | DS Alert | 1234:5679 | COMPONENT_SET | READY_FOR_DEV | +| [ ] | DS Avatar | 1234:5680 | COMPONENT_SET | NONE | +| [ ] | DS Button Copy | 1234:5681 | COMPONENT | NONE | +| [x] | DS Button | 1234:5682 | COMPONENT_SET | READY_FOR_DEV | +``` + +**Metadata:** +- `Scan format version` — manifest format version (currently `2`); used to detect and migrate older manifests automatically. +- `Generated` — Timestamp of manifest creation. +- `File` — Path to Figma file used. +- `Variables` — Path to variables file (if provided). +- `File last modified` — File-level `lastModified` timestamp from the Figma REST payload. + +**Row format:** +- `[x]` / `[ ]` — checked / unchecked. Edit by hand to curate. +- `Dev Status` — `READY_FOR_DEV` (designer-flagged in Figma Dev Mode) or `NONE` (unset). Read-only on each scan; changes drive the default merge behavior. + +## Authoring + +Edit the manifest to refine selections. The `Dev Status` column is informational — only the `✓` cell drives `generate`: + +```markdown +| ✓ | Name | ID | Type | Dev Status | +|------|------|------|------|------------| +| [x] | DS Button | 1234:1 | COMPONENT_SET | READY_FOR_DEV | +| [x] | DS Alert | 1234:2 | COMPONENT_SET | READY_FOR_DEV | +| [ ] | DS Button OLD | 1234:3 | COMPONENT_SET | NONE | +| [ ] | Example/Usage | 1234:4 | COMPONENT | NONE | +``` + +### Defaults + +When generating a fresh manifest (no prior file exists, or `--reset-checks` is set), `scan` decides which components to check using this rule: + +1. **If any component in the file has `devStatus: READY_FOR_DEV`** (set by designers in Figma's Dev Mode): only those components are checked. Everything else is unchecked. +2. **Otherwise** (no `devStatus` signal exists anywhere in the file): falls back to a heuristic that includes all `COMPONENT_SET` and standalone `COMPONENT` nodes. This preserves backward compatibility for libraries that haven't adopted Dev Mode status. + +`--include-all` overrides both — every row is checked. + +### Rescan + +If a manifest already exists at the output path, `scan` merges with it instead of overwriting: + +- **devStatus unchanged for a row** → prior checkbox state is preserved. +- **devStatus changed for a row** → Figma wins by default. The checkbox flips to match the new devStatus (`READY_FOR_DEV` → `[x]`, `NONE` → `[ ]`). Use `--keep-checks` to suppress this and preserve manual choices. +- **New components** discovered in the file → checked according to the default rule above. +- **Components removed** from the file → dropped from the manifest. The summary line reports the count. + +A summary line is printed after each merge, e.g. `Merge: 2 added, 1 removed, 5 updated by devStatus, 180 preserved`. + +## Examples + +### Basic scan + +```bash +# Zero-config: auto-resolves the only configured source +# Default output: {dataDirectory}/library.manifest.md +specs scan + +# Or pass an explicit file path +specs scan data/library.file.json -o components.md +``` + +### Multiple sources + +```bash +# When specs.config.yaml has multiple sources, pick one: +specs scan --source library +specs scan --source foundations +``` + +### With variables + +```bash +# Include variables path in manifest metadata +specs scan --variables data/library.variables.json +``` + +### Verbose output + +```bash +# See component count and file stats +specs scan --verbose + +# Output: +# ✓ Scanned library.file.json +# ✓ Found 164 components (12 selected, 152 excluded) +# Merge: 1 updated by devStatus, 163 preserved +# ✓ Saved to /absolute/path/to/data/library.manifest.md +``` + ## Arguments ### `[file]` (optional) @@ -60,7 +171,17 @@ specs scan --data-dir ./custom-data Path to config file. Used to resolve `dataDirectory` and `sources` for auto-selection and the default output path. ### `--include-all` -Include all components by default (ignore heuristics). +Include all components regardless of devStatus or heuristics. Overrides the default rule and bypasses the merge step entirely. + +### `--keep-checks` +Preserve the prior manifest's checkbox state for every existing row, even when a component's `devStatus` has changed since the last scan. The Dev Status column still updates so you can see Figma's signal — only the `✓` column is locked. Newly-discovered components still use the default inclusion rule. + +Use this when you've made deliberate manual curation choices that should survive Figma's signal flipping. + +### `--reset-checks` +Ignore the existing manifest entirely and re-derive every checkbox from current `devStatus` (or the heuristic fallback). Equivalent to deleting the manifest before running `scan`. + +`--keep-checks` and `--reset-checks` are mutually exclusive. ### `-v, --variables ` Variables JSON file path. @@ -78,96 +199,9 @@ Enable detailed logging. specs scan --verbose ``` -## Output Format - -The manifest is a markdown file with metadata and component list: - -```markdown -# Component Manifest - -**Generated:** 2026-01-17T10:30:00.000Z -**File:** /absolute/path/to/data/library.file.json -**Variables:** /absolute/path/to/data/library.variables.json - ---- - -## Components - -- [x] DS Accordion (1234:5678, COMPONENT_SET) -- [x] DS Alert (1234:5679, COMPONENT_SET) -- [x] DS Avatar (1234:5680, COMPONENT_SET) -- [ ] DS Button Copy (1234:5681, COMPONENT) -- [x] DS Button (1234:5682, COMPONENT_SET) -- [x] DS Card (1234:5683, COMPONENT_SET) -``` - -**Metadata:** -- `Generated` - Timestamp of manifest creation -- `File` - Path to Figma file used -- `Variables` - Path to variables file (if provided) - -**Component Format:** -- `[x]` - Included by default (`COMPONENT_SET` nodes) -- `[ ]` - Excluded (for curation) -- `(ID, TYPE)` - Figma node ID and type - -## Curation - -Edit the manifest to select which components to process: - -```markdown -## Components - - -- [x] DS Button (1234:1, COMPONENT_SET) -- [x] DS Alert (1234:2, COMPONENT_SET) - - -- [ ] DS Button OLD (1234:3, COMPONENT_SET) - - -- [ ] Example/Usage (1234:4, COMPONENT) -``` - -## Examples - -### Basic Scan - -```bash -# Zero-config: auto-resolves the only configured source -# Default output: {dataDirectory}/library.manifest.md -specs scan - -# Or pass an explicit file path -specs scan data/library.file.json -o components.md -``` - -### Multiple Sources - -```bash -# When specs.config.yaml has multiple sources, pick one: -specs scan --source library -specs scan --source foundations -``` - -### With Variables - -```bash -# Include variables path in manifest metadata -specs scan --variables data/library.variables.json -``` - -### Verbose Output +## Migration -```bash -# See component count and file stats -specs scan --verbose - -# Output: -# ✓ Scanned library.file.json -# ✓ Found 164 components -# ✓ Saved to /absolute/path/to/data/library.manifest.md -``` +Manifests produced by older versions of `scan` (checkbox-list format like `- [x] Name (id, TYPE)`) are detected automatically. On the next `scan`, prior checkbox state is preserved as-is, the new `Dev Status` column is populated from the current Figma payload, and the file is rewritten in the v2 table format. No manual migration step is required. --- diff --git a/site/src/content/docs/cli/getting-started.md b/site/src/content/docs/cli/getting-started.md index d22bf87..ce5c1fa 100644 --- a/site/src/content/docs/cli/getting-started.md +++ b/site/src/content/docs/cli/getting-started.md @@ -98,16 +98,20 @@ Scan your fetched data to build a manifest of available components: specs scan ``` -Then open the generated manifest (e.g., `data/library.manifest.md`) and check the components you want: +Then open the generated manifest (e.g., `data/library.manifest.md`) and adjust the `✓` column for the components you want: ```md -- [ ] _random Test experiment component -- [x] Button -- [x] Card -- [ ] InternalHelper -- [ ] Slot utility +| ✓ | Name | ID | Type | Dev Status | +|------|------|------|------|------------| +| [ ] | _random Test experiment | 12:1 | COMPONENT | NONE | +| [x] | Button | 12:2 | COMPONENT_SET | READY_FOR_DEV | +| [x] | Card | 12:3 | COMPONENT_SET | READY_FOR_DEV | +| [ ] | InternalHelper | 12:4 | COMPONENT | NONE | +| [ ] | Slot utility | 12:5 | COMPONENT | NONE | ``` +By default, `scan` checks only components marked **Ready for Dev** in Figma. If your file has no Dev Mode status set anywhere, it falls back to including every `COMPONENT_SET` and standalone `COMPONENT`. Re-running `scan` preserves your manual edits and only flips checkboxes when a component's `Dev Status` actually changes — pass `--keep-checks` to preserve them even then. See the [`scan`](/specs/cli/commands/scan/) reference for full merge behavior. + ## Step 5: Generate specs By default, the `generate` command creates specs for all selected components. diff --git a/site/src/content/docs/cli/workflows.md b/site/src/content/docs/cli/workflows.md index 01e8774..86377a9 100644 --- a/site/src/content/docs/cli/workflows.md +++ b/site/src/content/docs/cli/workflows.md @@ -29,7 +29,8 @@ If component names have special characters or duplicates, use node IDs: ```bash # Find the node ID from a manifest specs scan -o manifest.md -# Look for: - [x] DS Button/Icon (5507:123, COMPONENT_SET) +# Look in the table for the row whose Name matches: +# | [x] | DS Button/Icon | 5507:123 | COMPONENT_SET | READY_FOR_DEV | # Generate by ID specs generate data/library.file.json \ @@ -62,22 +63,25 @@ Open `data/design-system.manifest.md`: ```markdown # Component Manifest -**Generated:** 2026-01-17T20:45:00.000Z +**Scan format version:** 2 +**Generated:** 2026-05-08T20:45:00Z **File:** data/design-system.file.json **Variables:** data/design-system.variables.json +**File last modified:** 2026-05-08T17:48:26Z ## Components -- [x] DS Accordion (5507:24, COMPONENT_SET) -- [x] DS Alert (5507:26, COMPONENT_SET) -- [x] DS Avatar (5507:30, COMPONENT_SET) -- [x] DS Badge (5507:32, COMPONENT_SET) -- [ ] DS Divider OLD (5507:44, COMPONENT_SET) # Exclude deprecated -- [x] DS Dropdown (5507:46, COMPONENT_SET) -... +| ✓ | Name | ID | Type | Dev Status | +|------|------|------|------|------------| +| [x] | DS Accordion | 5507:24 | COMPONENT_SET | READY_FOR_DEV | +| [x] | DS Alert | 5507:26 | COMPONENT_SET | READY_FOR_DEV | +| [x] | DS Avatar | 5507:30 | COMPONENT_SET | READY_FOR_DEV | +| [x] | DS Badge | 5507:32 | COMPONENT_SET | READY_FOR_DEV | +| [ ] | DS Divider OLD | 5507:44 | COMPONENT_SET | NONE | +| [x] | DS Dropdown | 5507:46 | COMPONENT_SET | READY_FOR_DEV | ``` -Change `[x]` to `[ ]` for components to exclude. +Change `[x]` to `[ ]` (or vice versa) in the first column for components to exclude or include. The `Dev Status` column reflects what designers have flagged in Figma Dev Mode and is read-only on each scan. ### Step 3: Generate @@ -199,10 +203,10 @@ specs generate data/library.json \ ```bash # Count selected components -grep "^\- \[x\]" data/library.manifest.md | wc -l +grep -c "^| \[x\] |" data/library.manifest.md -# List selected component names -grep "^\- \[x\]" data/library.manifest.md | sed 's/- \[x\] \(.*\) (.*/\1/' +# List selected component names (second column of the table) +grep "^| \[x\] |" data/library.manifest.md | awk -F '\\|' '{ gsub(/^ +| +$/, "", $3); print $3 }' ``` --- @@ -218,25 +222,25 @@ git add data/library.manifest.md git commit -m "feat: add Modal to component manifest" ``` -### Document Manifest Curation +### Curate Through Figma Dev Mode -Add comments explaining why components are included/excluded: +The cleanest way to drive curation is to flag components as **Ready for Dev** in Figma. `scan` checks `READY_FOR_DEV` rows by default, so designers can signal what to include without anyone editing the manifest: -```markdown -## Components +```bash +# Designer marks new component Ready for Dev in Figma +specs fetch +specs scan +# Merge: 1 updated by devStatus, 163 preserved +``` - -- [x] DS Button (1234:1, COMPONENT_SET) -- [x] DS Link (1234:2, COMPONENT_SET) +When designers haven't (or won't) adopt Dev Mode status, fall back to manual curation. Manual `[x]` / `[ ]` edits are preserved across rescans unless `devStatus` itself changes for that row — pass `--keep-checks` to lock manual choices regardless. - -- [ ] DS Button OLD (1234:3, COMPONENT_SET) +### Document Manual Overrides - -- [ ] Icon / Menu (1234:4, COMPONENT) +For rows you've manually overridden against Figma's signal, leave a note above the table or alongside the row in your commit message. The table format doesn't accept inline comments, but git history makes the intent clear: - -- [ ] DS Tooltip NEW (1234:5, COMPONENT_SET) +```bash +git commit -m "manifest: keep DS Tooltip NEW unchecked — pending API redesign" ``` ## See Also diff --git a/site/src/custom.css b/site/src/custom.css index 3420f2a..d47954d 100644 --- a/site/src/custom.css +++ b/site/src/custom.css @@ -1,3 +1,65 @@ +/* ── Layout overrides ── */ + +/* 2× page padding and nav–content gutter at wide viewports */ +@media (min-width: 72rem) { + /* Content area padding */ + .content-panel { + padding-left: 3rem !important; + padding-right: 3rem !important; + } + /* Top nav bar padding */ + .header { + padding-left: 3rem !important; + padding-right: 3rem !important; + gap: 3rem !important; + } + /* Nav–content gutter */ + .main-pane { + --sl-nav-gap: 3rem; + } +} + +/* Remove distinct surface color and borders from nav/sidebar */ +.header, +nav.sidebar { + background-color: var(--sl-color-bg) !important; +} +.sidebar-pane { + background-color: var(--sl-color-bg) !important; +} +/* Remove all divider borders */ +.header { + border-bottom-color: transparent !important; +} +.sidebar-pane { + border-inline-end-color: transparent !important; +} +.content-panel + .content-panel { + border-top-color: transparent !important; +} +.sidebar-content ul ul { + border-inline-start-color: transparent !important; +} + +/* +4px spacing between left nav items */ +.sidebar-content li { + margin-top: calc(0.75rem + 4px); +} +.sidebar-content ul ul li { + margin-top: 0; +} + +/* +2px padding within each left nav item (default 0.2em → 8px) */ +.sidebar-content { + --sl-sidebar-item-padding-inline: calc(0.5rem + 2px); +} +.sidebar-content a { + padding-block: calc(0.2em + 5.2px); +} +.sidebar-content .group-label { + padding-block: calc(0.3em + 2px); +} + /* Make top-level sidebar links match the style of links inside accordions */ ul.top-level > li > a.large { font-size: var(--sl-text-sm); @@ -63,9 +125,9 @@ ul.top-level > li > a.large[aria-current='page']:focus { background-color: var(--sl-color-text-accent); } -/* Gap between header social links */ -.social-link + .social-link { - margin-left: 0.75rem; +/* Hide social links in header (moved to sidebar) */ +.social-icons { + display: none !important; } /* Site title in top nav bar — neutral instead of accent-colored */