@sun-asterisk/sungen 2.5.0 → 2.5.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (131) hide show
  1. package/README.md +88 -7
  2. package/dist/cli/commands/add.d.ts.map +1 -1
  3. package/dist/cli/commands/add.js +109 -9
  4. package/dist/cli/commands/add.js.map +1 -1
  5. package/dist/cli/commands/figma.d.ts +11 -0
  6. package/dist/cli/commands/figma.d.ts.map +1 -0
  7. package/dist/cli/commands/figma.js +178 -0
  8. package/dist/cli/commands/figma.js.map +1 -0
  9. package/dist/cli/index.js +4 -2
  10. package/dist/cli/index.js.map +1 -1
  11. package/dist/orchestrator/ai-rules-updater.d.ts.map +1 -1
  12. package/dist/orchestrator/ai-rules-updater.js +2 -0
  13. package/dist/orchestrator/ai-rules-updater.js.map +1 -1
  14. package/dist/orchestrator/figma/figma-scaffolder-helpers.d.ts +33 -0
  15. package/dist/orchestrator/figma/figma-scaffolder-helpers.d.ts.map +1 -0
  16. package/dist/orchestrator/figma/figma-scaffolder-helpers.js +135 -0
  17. package/dist/orchestrator/figma/figma-scaffolder-helpers.js.map +1 -0
  18. package/dist/orchestrator/figma/figma-scaffolder-types.d.ts +25 -0
  19. package/dist/orchestrator/figma/figma-scaffolder-types.d.ts.map +1 -0
  20. package/dist/orchestrator/figma/figma-scaffolder-types.js +7 -0
  21. package/dist/orchestrator/figma/figma-scaffolder-types.js.map +1 -0
  22. package/dist/orchestrator/figma/figma-scaffolder.d.ts +23 -0
  23. package/dist/orchestrator/figma/figma-scaffolder.d.ts.map +1 -0
  24. package/dist/orchestrator/figma/figma-scaffolder.js +212 -0
  25. package/dist/orchestrator/figma/figma-scaffolder.js.map +1 -0
  26. package/dist/orchestrator/figma/node-path-collapser.d.ts +16 -0
  27. package/dist/orchestrator/figma/node-path-collapser.d.ts.map +1 -0
  28. package/dist/orchestrator/figma/node-path-collapser.js +37 -0
  29. package/dist/orchestrator/figma/node-path-collapser.js.map +1 -0
  30. package/dist/orchestrator/figma/spec-figma-renderer.d.ts +44 -0
  31. package/dist/orchestrator/figma/spec-figma-renderer.d.ts.map +1 -0
  32. package/dist/orchestrator/figma/spec-figma-renderer.js +45 -0
  33. package/dist/orchestrator/figma/spec-figma-renderer.js.map +1 -0
  34. package/dist/orchestrator/figma/spec-figma-section-renderers.d.ts +23 -0
  35. package/dist/orchestrator/figma/spec-figma-section-renderers.d.ts.map +1 -0
  36. package/dist/orchestrator/figma/spec-figma-section-renderers.js +47 -0
  37. package/dist/orchestrator/figma/spec-figma-section-renderers.js.map +1 -0
  38. package/dist/orchestrator/templates/ai-instructions/claude-cmd-add-screen.md +56 -11
  39. package/dist/orchestrator/templates/ai-instructions/claude-cmd-create-test.md +30 -17
  40. package/dist/orchestrator/templates/ai-instructions/claude-cmd-review.md +4 -3
  41. package/dist/orchestrator/templates/ai-instructions/claude-cmd-run-test.md +33 -1
  42. package/dist/orchestrator/templates/ai-instructions/claude-config.md +1 -0
  43. package/dist/orchestrator/templates/ai-instructions/claude-skill-figma-source.md +151 -0
  44. package/dist/orchestrator/templates/ai-instructions/claude-skill-tc-generation.md +39 -20
  45. package/dist/orchestrator/templates/ai-instructions/claude-skill-tc-review.md +2 -0
  46. package/dist/orchestrator/templates/ai-instructions/copilot-cmd-add-screen.md +53 -9
  47. package/dist/orchestrator/templates/ai-instructions/copilot-cmd-create-test.md +21 -16
  48. package/dist/orchestrator/templates/ai-instructions/copilot-cmd-delivery.md +2 -2
  49. package/dist/orchestrator/templates/ai-instructions/copilot-cmd-review.md +4 -3
  50. package/dist/orchestrator/templates/ai-instructions/copilot-cmd-run-test.md +36 -4
  51. package/dist/orchestrator/templates/ai-instructions/copilot-config.md +1 -0
  52. package/dist/orchestrator/templates/ai-instructions/copilot-skill-figma-source.md +151 -0
  53. package/dist/orchestrator/templates/ai-instructions/github-skill-sungen-figma-source.md +151 -0
  54. package/dist/orchestrator/templates/ai-instructions/github-skill-sungen-selector-fix.md +61 -0
  55. package/dist/orchestrator/templates/ai-instructions/github-skill-sungen-tc-generation.md +51 -25
  56. package/dist/orchestrator/templates/ai-instructions/github-skill-sungen-tc-review.md +20 -0
  57. package/dist/tools/figma/figma-auth.d.ts +36 -0
  58. package/dist/tools/figma/figma-auth.d.ts.map +1 -0
  59. package/dist/tools/figma/figma-auth.js +182 -0
  60. package/dist/tools/figma/figma-auth.js.map +1 -0
  61. package/dist/tools/figma/figma-cache.d.ts +45 -0
  62. package/dist/tools/figma/figma-cache.d.ts.map +1 -0
  63. package/dist/tools/figma/figma-cache.js +191 -0
  64. package/dist/tools/figma/figma-cache.js.map +1 -0
  65. package/dist/tools/figma/figma-client-types.d.ts +112 -0
  66. package/dist/tools/figma/figma-client-types.d.ts.map +1 -0
  67. package/dist/tools/figma/figma-client-types.js +7 -0
  68. package/dist/tools/figma/figma-client-types.js.map +1 -0
  69. package/dist/tools/figma/figma-errors.d.ts +49 -0
  70. package/dist/tools/figma/figma-errors.d.ts.map +1 -0
  71. package/dist/tools/figma/figma-errors.js +105 -0
  72. package/dist/tools/figma/figma-errors.js.map +1 -0
  73. package/dist/tools/figma/figma-image-downloader.d.ts +25 -0
  74. package/dist/tools/figma/figma-image-downloader.d.ts.map +1 -0
  75. package/dist/tools/figma/figma-image-downloader.js +128 -0
  76. package/dist/tools/figma/figma-image-downloader.js.map +1 -0
  77. package/dist/tools/figma/figma-node-filter.d.ts +26 -0
  78. package/dist/tools/figma/figma-node-filter.d.ts.map +1 -0
  79. package/dist/tools/figma/figma-node-filter.js +164 -0
  80. package/dist/tools/figma/figma-node-filter.js.map +1 -0
  81. package/dist/tools/figma/figma-rest-client.d.ts +24 -0
  82. package/dist/tools/figma/figma-rest-client.d.ts.map +1 -0
  83. package/dist/tools/figma/figma-rest-client.js +154 -0
  84. package/dist/tools/figma/figma-rest-client.js.map +1 -0
  85. package/dist/tools/figma/figma-url-parser.d.ts +18 -0
  86. package/dist/tools/figma/figma-url-parser.d.ts.map +1 -0
  87. package/dist/tools/figma/figma-url-parser.js +51 -0
  88. package/dist/tools/figma/figma-url-parser.js.map +1 -0
  89. package/dist/utils/exec-file-no-throw.d.ts +20 -0
  90. package/dist/utils/exec-file-no-throw.d.ts.map +1 -0
  91. package/dist/utils/exec-file-no-throw.js +36 -0
  92. package/dist/utils/exec-file-no-throw.js.map +1 -0
  93. package/package.json +1 -1
  94. package/src/cli/commands/add.ts +80 -9
  95. package/src/cli/commands/figma.ts +162 -0
  96. package/src/cli/index.ts +4 -2
  97. package/src/orchestrator/ai-rules-updater.ts +2 -0
  98. package/src/orchestrator/figma/figma-scaffolder-helpers.ts +126 -0
  99. package/src/orchestrator/figma/figma-scaffolder-types.ts +26 -0
  100. package/src/orchestrator/figma/figma-scaffolder.ts +209 -0
  101. package/src/orchestrator/figma/node-path-collapser.ts +38 -0
  102. package/src/orchestrator/figma/spec-figma-renderer.ts +80 -0
  103. package/src/orchestrator/figma/spec-figma-section-renderers.ts +46 -0
  104. package/src/orchestrator/templates/ai-instructions/claude-cmd-add-screen.md +56 -11
  105. package/src/orchestrator/templates/ai-instructions/claude-cmd-create-test.md +30 -17
  106. package/src/orchestrator/templates/ai-instructions/claude-cmd-review.md +4 -3
  107. package/src/orchestrator/templates/ai-instructions/claude-cmd-run-test.md +33 -1
  108. package/src/orchestrator/templates/ai-instructions/claude-config.md +1 -0
  109. package/src/orchestrator/templates/ai-instructions/claude-skill-figma-source.md +151 -0
  110. package/src/orchestrator/templates/ai-instructions/claude-skill-tc-generation.md +39 -20
  111. package/src/orchestrator/templates/ai-instructions/claude-skill-tc-review.md +2 -0
  112. package/src/orchestrator/templates/ai-instructions/copilot-cmd-add-screen.md +53 -9
  113. package/src/orchestrator/templates/ai-instructions/copilot-cmd-create-test.md +21 -16
  114. package/src/orchestrator/templates/ai-instructions/copilot-cmd-delivery.md +2 -2
  115. package/src/orchestrator/templates/ai-instructions/copilot-cmd-review.md +4 -3
  116. package/src/orchestrator/templates/ai-instructions/copilot-cmd-run-test.md +36 -4
  117. package/src/orchestrator/templates/ai-instructions/copilot-config.md +1 -0
  118. package/src/orchestrator/templates/ai-instructions/copilot-skill-figma-source.md +151 -0
  119. package/src/orchestrator/templates/ai-instructions/github-skill-sungen-figma-source.md +151 -0
  120. package/src/orchestrator/templates/ai-instructions/github-skill-sungen-selector-fix.md +61 -0
  121. package/src/orchestrator/templates/ai-instructions/github-skill-sungen-tc-generation.md +51 -25
  122. package/src/orchestrator/templates/ai-instructions/github-skill-sungen-tc-review.md +20 -0
  123. package/src/tools/figma/figma-auth.ts +161 -0
  124. package/src/tools/figma/figma-cache.ts +184 -0
  125. package/src/tools/figma/figma-client-types.ts +125 -0
  126. package/src/tools/figma/figma-errors.ts +127 -0
  127. package/src/tools/figma/figma-image-downloader.ts +112 -0
  128. package/src/tools/figma/figma-node-filter.ts +198 -0
  129. package/src/tools/figma/figma-rest-client.ts +183 -0
  130. package/src/tools/figma/figma-url-parser.ts +55 -0
  131. package/src/utils/exec-file-no-throw.ts +45 -0
@@ -41,6 +41,7 @@ export const AI_RULES_FILE_MAPPING: [string, string][] = [
41
41
  ['claude-skill-capture-figma.md', '.claude/skills/sungen-capture-figma/SKILL.md'],
42
42
  ['claude-skill-capture-local.md', '.claude/skills/sungen-capture-local/SKILL.md'],
43
43
  ['claude-skill-capture-live.md', '.claude/skills/sungen-capture-live/SKILL.md'],
44
+ ['claude-skill-figma-source.md', '.claude/skills/sungen-figma-source/SKILL.md'],
44
45
 
45
46
  // Skills — GitHub Copilot
46
47
  ['github-skill-sungen-gherkin-syntax.md', '.github/skills/sungen-gherkin-syntax/SKILL.md'],
@@ -55,6 +56,7 @@ export const AI_RULES_FILE_MAPPING: [string, string][] = [
55
56
  ['github-skill-sungen-capture-figma.md', '.github/skills/sungen-capture-figma/SKILL.md'],
56
57
  ['github-skill-sungen-capture-local.md', '.github/skills/sungen-capture-local/SKILL.md'],
57
58
  ['github-skill-sungen-capture-live.md', '.github/skills/sungen-capture-live/SKILL.md'],
59
+ ['github-skill-sungen-figma-source.md', '.github/skills/sungen-figma-source/SKILL.md'],
58
60
  ];
59
61
 
60
62
  export class AIRulesUpdater {
@@ -0,0 +1,126 @@
1
+ /**
2
+ * Internal helpers for figma-scaffolder.ts.
3
+ * Extracted to keep the main orchestration file under 200 LOC.
4
+ */
5
+
6
+ import * as fs from 'node:fs';
7
+ import * as path from 'node:path';
8
+ import type { Ora } from 'ora';
9
+ import { downloadToPath } from '../../tools/figma/figma-image-downloader';
10
+ import * as FigmaCache from '../../tools/figma/figma-cache';
11
+
12
+ // ---------------------------------------------------------------------------
13
+ // Name utilities
14
+ // ---------------------------------------------------------------------------
15
+
16
+ /** Sanitize a Figma frame/variant name to a safe filename (lowercase, dashes). */
17
+ export function sanitizeName(name: string): string {
18
+ return name
19
+ .toLowerCase()
20
+ .replace(/[^a-z0-9]+/g, '-')
21
+ .replace(/^-+|-+$/g, '');
22
+ }
23
+
24
+ // ---------------------------------------------------------------------------
25
+ // Version / stub helpers
26
+ // ---------------------------------------------------------------------------
27
+
28
+ /** Read generator version from root package.json. Falls back to "sungen". */
29
+ export function readGeneratorVersion(cwd: string): string {
30
+ try {
31
+ const pkgPath = path.join(cwd, 'package.json');
32
+ if (fs.existsSync(pkgPath)) {
33
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8')) as { version?: string };
34
+ if (pkg.version) return `sungen v${pkg.version}`;
35
+ }
36
+ } catch {
37
+ // fall through
38
+ }
39
+ return 'sungen';
40
+ }
41
+
42
+ /** Minimal spec.md stub pointing users to spec_figma.md. */
43
+ export function minimalSpecMd(screenName: string): string {
44
+ return `# ${screenName} Screen Specification
45
+
46
+ <!-- This file is the human-authored source of truth. -->
47
+ <!-- spec_figma.md contains auto-generated Figma data — copy useful sections here. -->
48
+
49
+ ## Overview
50
+ - **URL Path:** /${screenName}
51
+ - **Auth Required:** no
52
+ - **Platform:** web
53
+
54
+ ## Sections
55
+
56
+ <!-- Add sections, fields, validation rules, and business rules here. -->
57
+ `;
58
+ }
59
+
60
+ // ---------------------------------------------------------------------------
61
+ // Image download orchestration
62
+ // ---------------------------------------------------------------------------
63
+
64
+ export interface DownloadImagesOptions {
65
+ cwd: string;
66
+ fileKey: string;
67
+ versionId: string;
68
+ uiDir: string;
69
+ imageNodeIds: string[];
70
+ mainNodeId: string;
71
+ variantNames: string[];
72
+ /** Pre-warmed cache entries (nodeId → bytes). */
73
+ cachedImages: Map<string, Buffer>;
74
+ /** URL map from getImageUrls response (nodeId → signed URL). */
75
+ imageUrls: Record<string, string | null>;
76
+ frameBaseName: string;
77
+ scaleKind: `${number}x`;
78
+ spinner: Ora;
79
+ }
80
+
81
+ /**
82
+ * Download (or copy from cache) images for each node ID.
83
+ * Returns relative paths (e.g. "ui/login-frame.png") for each successfully written file.
84
+ */
85
+ export async function downloadImages(opts: DownloadImagesOptions): Promise<string[]> {
86
+ const imagePaths: string[] = [];
87
+
88
+ for (let i = 0; i < opts.imageNodeIds.length; i++) {
89
+ const nodeId = opts.imageNodeIds[i];
90
+ const isMain = nodeId === opts.mainNodeId;
91
+ const nameSuffix = isMain
92
+ ? opts.frameBaseName
93
+ : sanitizeName(opts.variantNames[i - 1] ?? `variant-${i}`);
94
+ const destPath = path.join(opts.uiDir, `${nameSuffix}.png`);
95
+ const relPath = `ui/${nameSuffix}.png`;
96
+
97
+ // Cache hit path
98
+ if (opts.cachedImages.has(nodeId)) {
99
+ opts.spinner.start(`Writing cached image: ${nameSuffix}.png`);
100
+ fs.writeFileSync(destPath, opts.cachedImages.get(nodeId)!);
101
+ imagePaths.push(relPath);
102
+ opts.spinner.succeed(` ui/${nameSuffix}.png (cached)`);
103
+ continue;
104
+ }
105
+
106
+ const url = opts.imageUrls[nodeId];
107
+ if (!url) {
108
+ opts.spinner.warn(` No image URL for node ${nodeId} — skipping`);
109
+ continue;
110
+ }
111
+
112
+ opts.spinner.start(`Downloading ui/${nameSuffix}.png…`);
113
+ try {
114
+ await downloadToPath(url, destPath);
115
+ // Cache downloaded bytes for future runs
116
+ const bytes = fs.readFileSync(destPath);
117
+ FigmaCache.put(opts.cwd, opts.fileKey, opts.versionId, nodeId, opts.scaleKind, bytes);
118
+ imagePaths.push(relPath);
119
+ opts.spinner.succeed(` ui/${nameSuffix}.png`);
120
+ } catch {
121
+ opts.spinner.warn(` Failed to download ui/${nameSuffix}.png — skipping`);
122
+ }
123
+ }
124
+
125
+ return imagePaths;
126
+ }
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Shared input/output types for FigmaScaffolder.
3
+ * Kept separate to avoid growing figma-client-types.ts beyond its scope.
4
+ */
5
+
6
+ export interface FigmaScaffolderOptions {
7
+ /** Screen name (e.g. "login", "dashboard"). */
8
+ screenName: string;
9
+ /** Full Figma share URL. */
10
+ figmaUrl: string;
11
+ /** Absolute or cwd-relative working directory. Default: process.cwd(). */
12
+ cwd?: string;
13
+ /** When true, bypass cache and re-fetch from Figma API. Default: false. */
14
+ refresh?: boolean;
15
+ /** Render scale for PNG export. Default: 2. */
16
+ scale?: number;
17
+ }
18
+
19
+ export interface FigmaScaffolderResult {
20
+ /** Absolute path to the written spec_figma.md. */
21
+ specFigmaPath: string;
22
+ /** Absolute paths to downloaded PNG files. */
23
+ imagePaths: string[];
24
+ /** True when spec.md was newly created (was not already present). */
25
+ specMdCreated: boolean;
26
+ }
@@ -0,0 +1,209 @@
1
+ /**
2
+ * FigmaScaffolder: orchestrates Figma URL → spec_figma.md + ui/*.png.
3
+ *
4
+ * Flow: parseFigmaUrl → assertSafeToUse → loadPat → cache check
5
+ * → getFileNodes → filterFigmaNode → getImageUrls → downloadImages
6
+ * → renderSpecFigma → write files
7
+ *
8
+ * Idempotent: re-running overwrites spec_figma.md and PNGs.
9
+ * spec.md: written only if absent (never overwritten).
10
+ * Cache: keyed by file+version+node; bypassed when refresh=true.
11
+ *
12
+ * Helpers (image download, name utils, stubs) live in figma-scaffolder-helpers.ts.
13
+ */
14
+
15
+ import * as fs from 'node:fs';
16
+ import * as path from 'node:path';
17
+ import ora from 'ora';
18
+
19
+ import { parseFigmaUrl } from '../../tools/figma/figma-url-parser';
20
+ import { assertSafeToUse, loadPat } from '../../tools/figma/figma-auth';
21
+ import { getFileNodes, getImageUrls } from '../../tools/figma/figma-rest-client';
22
+ import { filterFigmaNode, extractTextLabels, extractVariants } from '../../tools/figma/figma-node-filter';
23
+ import * as FigmaCache from '../../tools/figma/figma-cache';
24
+ import { renderSpecFigma } from './spec-figma-renderer';
25
+ import {
26
+ sanitizeName,
27
+ readGeneratorVersion,
28
+ minimalSpecMd,
29
+ downloadImages,
30
+ } from './figma-scaffolder-helpers';
31
+ import type { FigmaScaffolderOptions, FigmaScaffolderResult } from './figma-scaffolder-types';
32
+
33
+ // Max variant images to download in addition to the main frame.
34
+ const MAX_VARIANT_IMAGES = 5;
35
+
36
+ export class FigmaScaffolder {
37
+ /**
38
+ * Orchestrate the full Figma → screen files flow.
39
+ *
40
+ * @throws Error with remediation instructions on auth / parse / network failure.
41
+ */
42
+ static async run(options: FigmaScaffolderOptions): Promise<FigmaScaffolderResult> {
43
+ const cwd = options.cwd ?? process.cwd();
44
+ const refresh = options.refresh ?? false;
45
+ const scale = options.scale ?? 2;
46
+ const spinner = ora({ stream: process.stderr });
47
+
48
+ // 1. Parse and validate Figma URL
49
+ const ref = parseFigmaUrl(options.figmaUrl);
50
+ if (!ref) {
51
+ throw new Error(
52
+ `Invalid Figma URL: "${options.figmaUrl}"\n` +
53
+ 'Expected format: https://www.figma.com/design/<KEY>/<name>?node-id=1-23',
54
+ );
55
+ }
56
+ if (!ref.nodeId) {
57
+ throw new Error(
58
+ 'Figma URL must include a node-id query parameter.\n' +
59
+ 'Select a specific frame in Figma, right-click → "Copy link to selection".',
60
+ );
61
+ }
62
+
63
+ // 2. Security scan + PAT load
64
+ spinner.start('Checking Figma auth…');
65
+ await assertSafeToUse(cwd);
66
+
67
+ const pat = loadPat(cwd);
68
+ if (!pat) {
69
+ spinner.fail('Figma PAT not found.');
70
+ throw new Error('Figma PAT is not configured.\nRun: sungen figma auth set');
71
+ }
72
+ spinner.succeed('Figma auth OK');
73
+
74
+ // 3. Ensure screen directories exist
75
+ const screenDir = path.join(cwd, 'qa', 'screens', options.screenName);
76
+ const requirementsDir = path.join(screenDir, 'requirements');
77
+ const uiDir = path.join(requirementsDir, 'ui');
78
+ fs.mkdirSync(uiDir, { recursive: true });
79
+
80
+ // 4. Resolve node: prefer raw-JSON cache to spare Starter-tier REST quota.
81
+ // On Figma Starter, `/v1/files/:key/nodes` counts against a tiny daily
82
+ // bucket (observed Retry-After up to 4+ days). If we already have a
83
+ // cached raw response, reuse it and skip the call entirely.
84
+ let versionId: string;
85
+ let nodeDocument: unknown;
86
+ let fromCache = false;
87
+
88
+ if (!refresh) {
89
+ const cachedVersion = FigmaCache.findLatestCachedVersion(cwd, ref.fileKey, ref.nodeId);
90
+ if (cachedVersion) {
91
+ const cachedRaw = FigmaCache.get(cwd, ref.fileKey, cachedVersion, ref.nodeId, 'raw');
92
+ if (cachedRaw) {
93
+ try {
94
+ nodeDocument = JSON.parse(cachedRaw.toString('utf8'));
95
+ versionId = cachedVersion;
96
+ fromCache = true;
97
+ spinner.info(`Reusing cached Figma node (version ${versionId}) — pass --refresh to re-fetch`);
98
+ } catch {
99
+ // Corrupt cache → fall through to API fetch
100
+ }
101
+ }
102
+ }
103
+ }
104
+
105
+ if (!fromCache) {
106
+ spinner.start(`Fetching Figma node ${ref.nodeId}…`);
107
+ let nodesResponse: Awaited<ReturnType<typeof getFileNodes>>;
108
+ try {
109
+ nodesResponse = await getFileNodes(pat, ref.fileKey, [ref.nodeId]);
110
+ } catch (err) {
111
+ spinner.fail('Failed to fetch Figma node.');
112
+ throw err;
113
+ }
114
+
115
+ versionId = nodesResponse.version;
116
+ const nodeEntry = nodesResponse.nodes[ref.nodeId];
117
+ if (!nodeEntry) {
118
+ spinner.fail(`Node ${ref.nodeId} not found in Figma file.`);
119
+ throw new Error(
120
+ `Node "${ref.nodeId}" was not found in file "${ref.fileKey}".\n` +
121
+ 'Check the Figma URL — the node-id may be incorrect.',
122
+ );
123
+ }
124
+ spinner.succeed(`Fetched node: ${nodeEntry.document.name}`);
125
+ nodeDocument = nodeEntry.document;
126
+
127
+ // Persist raw (unfiltered) node JSON for downstream LLM synthesis + future
128
+ // cache-first runs. Re-runs overwrite atomically.
129
+ FigmaCache.put(
130
+ cwd,
131
+ ref.fileKey,
132
+ versionId,
133
+ ref.nodeId,
134
+ 'raw',
135
+ JSON.stringify(nodeDocument),
136
+ );
137
+ }
138
+
139
+ // nodeEntry.document shape — trust the cached bytes we wrote earlier.
140
+ const nodeEntry = { document: nodeDocument as Parameters<typeof filterFigmaNode>[0] };
141
+
142
+ // 5. Filter node tree + extract metadata
143
+ const filteredNode = filterFigmaNode(nodeEntry.document);
144
+ const textLabels = extractTextLabels(filteredNode);
145
+ const variants = extractVariants(filteredNode);
146
+
147
+ // 6. Resolve image node IDs (main + up to MAX_VARIANT_IMAGES variants)
148
+ const imageNodeIds = [ref.nodeId, ...variants.slice(0, MAX_VARIANT_IMAGES).map((v) => v.nodeId)];
149
+ const scaleKind = `${scale}x` as `${number}x`;
150
+
151
+ // 7. Warm image cache (skipped when refresh=true)
152
+ const cachedImages = new Map<string, Buffer>();
153
+ if (!refresh) {
154
+ for (const nodeId of imageNodeIds) {
155
+ const cached = FigmaCache.get(cwd, ref.fileKey, versionId, nodeId, scaleKind);
156
+ if (cached) cachedImages.set(nodeId, cached);
157
+ }
158
+ }
159
+
160
+ // 8. Fetch image URLs for uncached nodes
161
+ const uncachedIds = imageNodeIds.filter((id) => !cachedImages.has(id));
162
+ let imageUrls: Record<string, string | null> = {};
163
+ if (uncachedIds.length > 0) {
164
+ spinner.start(`Fetching image URLs for ${uncachedIds.length} node(s)…`);
165
+ try {
166
+ const urlsResponse = await getImageUrls(pat, ref.fileKey, uncachedIds, { scale, format: 'png' });
167
+ imageUrls = urlsResponse.images;
168
+ } catch (err) {
169
+ spinner.fail('Failed to fetch image URLs.');
170
+ throw err;
171
+ }
172
+ spinner.succeed('Image URLs fetched');
173
+ }
174
+
175
+ // 9. Download (or copy from cache) images
176
+ const frameBaseName = sanitizeName(filteredNode.name);
177
+ const variantNames = variants.slice(0, MAX_VARIANT_IMAGES).map((v) => v.name);
178
+ const imagePaths = await downloadImages({
179
+ cwd, fileKey: ref.fileKey, versionId, uiDir,
180
+ imageNodeIds, mainNodeId: ref.nodeId, variantNames,
181
+ cachedImages, imageUrls, frameBaseName, scaleKind, spinner,
182
+ });
183
+
184
+ // 10. Render and write spec_figma.md (always overwritten)
185
+ spinner.start('Rendering spec_figma.md…');
186
+ const markdown = renderSpecFigma({
187
+ filteredNode, variants, textLabels, imagePaths,
188
+ fileKey: ref.fileKey, nodeId: ref.nodeId, versionId,
189
+ fetchedAt: new Date().toISOString(),
190
+ generatorVersion: readGeneratorVersion(cwd),
191
+ });
192
+ const specFigmaPath = path.join(requirementsDir, 'spec_figma.md');
193
+ fs.writeFileSync(specFigmaPath, markdown, 'utf8');
194
+ spinner.succeed('spec_figma.md written');
195
+
196
+ // 11. Write spec.md stub only if absent
197
+ const specMdPath = path.join(requirementsDir, 'spec.md');
198
+ const specMdCreated = !fs.existsSync(specMdPath);
199
+ if (specMdCreated) {
200
+ fs.writeFileSync(specMdPath, minimalSpecMd(options.screenName), 'utf8');
201
+ spinner.info('spec.md created (stub — fill in your requirements)');
202
+ }
203
+
204
+ // 12. Evict old cache versions (keep last 3)
205
+ FigmaCache.bustOldVersions(cwd, ref.fileKey, versionId);
206
+
207
+ return { specFigmaPath, imagePaths, specMdCreated };
208
+ }
209
+ }
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Pure helper: collapse consecutive repeated segments in a Figma node path.
3
+ *
4
+ * Delimiter: " > " (space-angle-space).
5
+ * Collapsed runs use the multiplication sign × (U+00D7).
6
+ *
7
+ * Examples:
8
+ * "A > B > C" → "A > B > C" (unchanged)
9
+ * "A > B > B > C" → "A > B×2 > C"
10
+ * "Container > Container > Container > X" → "Container×3 > X"
11
+ * "A > A > A > A > A > B > C" → "A×5 > B > C"
12
+ * "A" → "A" (single)
13
+ * "" → "" (empty)
14
+ */
15
+ export function collapseNodePath(path: string): string {
16
+ if (!path) return path;
17
+
18
+ const DELIMITER = ' > ';
19
+ const segments = path.split(DELIMITER);
20
+
21
+ const collapsed: string[] = [];
22
+ let i = 0;
23
+
24
+ while (i < segments.length) {
25
+ const current = segments[i];
26
+ let count = 1;
27
+
28
+ // Count consecutive identical segments
29
+ while (i + count < segments.length && segments[i + count] === current) {
30
+ count++;
31
+ }
32
+
33
+ collapsed.push(count > 1 ? `${current}×${count}` : current);
34
+ i += count;
35
+ }
36
+
37
+ return collapsed.join(DELIMITER);
38
+ }
@@ -0,0 +1,80 @@
1
+ /**
2
+ * Pure function: filtered Figma node → spec_figma.md envelope string.
3
+ *
4
+ * The envelope is deterministic and stable across runs: frontmatter,
5
+ * auto-gen banner, Frame metadata, Screenshots, and a SYNTHESIS marker.
6
+ * Narrative sections (Purpose, ASCII Layout, Regions, Actions, Form Fields,
7
+ * Data Columns, Navigation) are appended BELOW the marker by the
8
+ * sungen-figma-source skill, which reads the raw cached node JSON.
9
+ *
10
+ * Section renderers live in spec-figma-section-renderers.ts.
11
+ *
12
+ * This module never reads from disk or makes network calls.
13
+ */
14
+
15
+ import type {
16
+ FilteredFigmaNode,
17
+ FigmaTextLabel,
18
+ FigmaVariantDefinition,
19
+ } from '../../tools/figma/figma-client-types';
20
+ import {
21
+ renderFrontmatter,
22
+ renderFrame,
23
+ renderScreenshots,
24
+ SYNTHESIS_MARKER,
25
+ } from './spec-figma-section-renderers';
26
+
27
+ // ---------------------------------------------------------------------------
28
+ // Types
29
+ // ---------------------------------------------------------------------------
30
+
31
+ export interface RenderSpecFigmaInput {
32
+ /** Root filtered node (the requested frame / component). */
33
+ filteredNode: FilteredFigmaNode;
34
+ /** Variant definitions from a COMPONENT_SET parent, or [] if none. Kept for compatibility. */
35
+ variants: FigmaVariantDefinition[];
36
+ /** Flat text labels extracted from the node tree. Kept for compatibility. */
37
+ textLabels: FigmaTextLabel[];
38
+ /** Relative paths of downloaded image files (e.g., ["ui/home.png", "ui/home-dark.png"]). */
39
+ imagePaths: string[];
40
+ /** Figma file key. */
41
+ fileKey: string;
42
+ /** Node ID in API form (e.g. "1:23"). */
43
+ nodeId: string;
44
+ /** Figma file version ID at fetch time. */
45
+ versionId: string;
46
+ /** ISO timestamp of when data was fetched. */
47
+ fetchedAt: string;
48
+ /** Generator version string (e.g., "sungen v2.4.5"). */
49
+ generatorVersion: string;
50
+ }
51
+
52
+ // ---------------------------------------------------------------------------
53
+ // Public API
54
+ // ---------------------------------------------------------------------------
55
+
56
+ /**
57
+ * Render the deterministic envelope for spec_figma.md.
58
+ * The LLM synthesis step (sungen-figma-source skill) appends the narrative
59
+ * sections AFTER the SYNTHESIS_MARKER comment. Re-synthesis replaces
60
+ * everything from the marker to EOF.
61
+ *
62
+ * Pure function — no I/O side effects.
63
+ */
64
+ export function renderSpecFigma(input: RenderSpecFigmaInput): string {
65
+ const sections = [
66
+ renderFrontmatter(input),
67
+ '',
68
+ '> Envelope is AUTO-GENERATED. Narrative sections below the marker are LLM-synthesized.',
69
+ '> To refine, copy useful parts into ../spec.md (authoritative).',
70
+ '',
71
+ renderFrame(input.filteredNode),
72
+ '',
73
+ renderScreenshots(input.imagePaths),
74
+ '',
75
+ SYNTHESIS_MARKER,
76
+ '',
77
+ ];
78
+
79
+ return sections.join('\n') + '\n';
80
+ }
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Section-render helpers for spec-figma-renderer.ts.
3
+ *
4
+ * After the shift to LLM-synthesized narrative sections, the envelope only
5
+ * needs: frontmatter, Frame metadata, Screenshots, and the SYNTHESIS marker.
6
+ * All prose sections (Purpose, ASCII Layout, Regions, Actions, Form Fields,
7
+ * Data Columns, Navigation) are appended below the marker by the
8
+ * sungen-figma-source skill.
9
+ *
10
+ * Pure — no I/O.
11
+ */
12
+
13
+ import type { FilteredFigmaNode } from '../../tools/figma/figma-client-types';
14
+ import type { RenderSpecFigmaInput } from './spec-figma-renderer';
15
+
16
+ /**
17
+ * Sentinel comment that separates the deterministic envelope (above) from
18
+ * the LLM-synthesized narrative (below). Re-synthesis replaces everything
19
+ * from this marker to EOF.
20
+ */
21
+ export const SYNTHESIS_MARKER = '<!-- SYNTHESIS-BELOW -->';
22
+
23
+ export function renderFrontmatter(input: RenderSpecFigmaInput): string {
24
+ return [
25
+ '---',
26
+ 'source: figma',
27
+ `file_key: ${input.fileKey}`,
28
+ `node_id: ${input.nodeId}`,
29
+ `figma_version_id: ${input.versionId}`,
30
+ `fetched_at: ${input.fetchedAt}`,
31
+ `generator: ${input.generatorVersion}`,
32
+ '---',
33
+ ].join('\n');
34
+ }
35
+
36
+ export function renderFrame(node: FilteredFigmaNode): string {
37
+ const bb = node.boundingBox;
38
+ const dims = bb ? `${bb.w}x${bb.h}` : '—';
39
+ return ['## Frame', `- Name: ${node.name}`, `- Dimensions: ${dims}`, `- Type: ${node.type}`].join('\n');
40
+ }
41
+
42
+ export function renderScreenshots(imagePaths: string[]): string {
43
+ if (imagePaths.length === 0) return '## Screenshots\n- (no images downloaded)';
44
+ const items = imagePaths.map((p, i) => `- \`${p}\` — ${i === 0 ? 'default state' : `variant ${i}`}`);
45
+ return ['## Screenshots', ...items].join('\n');
46
+ }
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: add-screen
3
3
  description: 'Add a new Sungen screen — scaffolds directories, helps fill spec.md, and can capture visuals from Figma (pre-launch) or live page via the capture skills'
4
- argument-hint: [screen-name] [url-path]
4
+ argument-hint: [screen-name] [url-path] [--figma <url>]
5
5
  allowed-tools: Read, Grep, Bash, Glob, Edit, Write, AskUserQuestion, mcp__playwright__browser_navigate, mcp__playwright__browser_take_screenshot, mcp__playwright__browser_snapshot, mcp__figma__get_design_context, mcp__figma__get_variable_defs, mcp__figma__get_screenshot
6
6
  ---
7
7
 
@@ -11,37 +11,82 @@ You are adding a new Sungen screen for test generation.
11
11
 
12
12
  Parse from `$ARGUMENTS`:
13
13
  - **screen** — screen name (e.g., `login`, `dashboard`, `settings`)
14
- - **path** — URL path (e.g., `/login`, `/dashboard`, `/settings`)
14
+ - **path** — URL path (e.g., `/login`, `/dashboard`, `/settings`) - optional when `--figma` is provided
15
+ - **--figma \<url\>** — Figma share URL (optional)
16
+ - **--refresh** — bypass Figma cache and re-fetch (optional, use with `--figma`)
17
+ - **--scale \<n\>** — PNG export scale factor, default 2 (optional)
18
+ - **--hi-res** — export at 4× scale, shorthand for `--scale 4` (optional)
15
19
 
16
20
  If **screen** is missing, ask: "What is the screen name? (e.g., `login`, `dashboard`)"
17
- If **path** is missing, ask: "What is the URL path? (e.g., `/login`, `/dashboard`)"
21
+ If **path** is missing and `--figma` was NOT provided, ask: "What is the URL path? (e.g., `/login`, `/dashboard`)"
18
22
 
19
23
  ## Steps
20
24
 
21
25
  ### 1. Scaffold the screen
22
26
 
23
- Run:
27
+ **Standard path (no `--figma`):**
24
28
  ```bash
25
29
  sungen add --screen <screen> --path <path>
26
30
  ```
27
31
 
28
- ### 2. Fill spec.md
32
+ **Figma branch (when `--figma <url>` is in `$ARGUMENTS`):**
29
33
 
30
- Use `AskUserQuestion`: *"Fill `spec.md` now?"* offer **Yes, fill now (Recommended)** / **Skip, fill later**.
34
+ Invoke the `sungen-figma-source` skill by running:
35
+ ```bash
36
+ sungen add --screen <screen> --figma '<url>' [--path <path>] [--refresh] [--scale <n>]
37
+ # Single-quote the URL — Figma links contain `&` which bash otherwise treats as a background operator.
38
+ ```
39
+
40
+ This CLI command automatically:
41
+ 1. Parses the Figma URL and fetches the design node.
42
+ 2. Downloads frame + variant PNGs to `qa/screens/<screen>/requirements/ui/`.
43
+ 3. Writes `qa/screens/<screen>/requirements/spec_figma.md` (auto-generated, always overwritten on re-run).
44
+ 4. Creates `qa/screens/<screen>/requirements/spec.md` stub **only if the file does not already exist** — never overwrites existing human content.
45
+
46
+ **Important coexistence rules:**
47
+ - `--figma` and `--path` can be passed together: `--path` provides the live URL for `run-test`; `--figma` provides design context.
48
+ - `spec.md` is the human-authored source of truth. Never modify it automatically.
49
+ - `spec_figma.md` is auto-generated — always overwritten; tell the user to copy useful sections into `spec.md`.
50
+ - `--capture` (Playwright screenshot) and `--figma` can be used simultaneously; they produce different outputs.
51
+
52
+ **If Figma auth is missing**, the command will fail with a message like:
53
+ > Figma PAT is not configured. Run: sungen figma auth set
54
+
55
+ In that case, guide the user to run `sungen figma auth set` and retry.
31
56
 
32
- If yes → open `qa/screens/<screen>/requirements/spec.md` and help the user fill sections, fields, validation rules, business rules, and states. Especially prompt for the optional **Figma URL** and **Live URL** fields in Overview — those unlock auto-capture without re-asking next run.
57
+ ### 1a. Synthesize narrative sections (Figma branch only)
33
58
 
34
- ### 3. Capture visual source
59
+ After `sungen add --figma` succeeds, the envelope of `spec_figma.md` is deterministic but the narrative below the `<!-- SYNTHESIS-BELOW -->` marker is empty. Invoke the `sungen-figma-source` skill to fill it:
35
60
 
36
- Use `AskUserQuestion`: *"Capture a visual reference for this screen?"*always offer all three so pre-launch projects work:
61
+ 1. Read `qa/screens/<screen>/requirements/spec_figma.md`note `file_key`, `node_id`, `figma_version_id` from frontmatter.
62
+ 2. Read the cached raw node JSON at `.sungen/figma-cache/<file_key>/<figma_version_id>/<safe_node_id>-raw.json` (colons in node_id become underscores).
63
+ 3. Follow the skill's 7-section template (Purpose / ASCII Layout / Regions / Actions / Form Fields / Data Columns / Navigation) and **replace** everything from the marker to EOF.
64
+ 4. Preserve the envelope above the marker byte-for-byte.
37
65
 
66
+ **Review gate.** Before moving on, show the user the synthesized narrative and use `AskUserQuestion`:
67
+
68
+ - **Approve** — narrative looks right, continue to Step 2
69
+ - **Edit** — user will tweak `spec_figma.md` now; wait for confirmation before continuing
70
+ - **Cancel** — abort; advise the user that `spec.md` was NOT modified and they can re-run `sungen add --figma --refresh` later
71
+
72
+ ### 2. Capture visual source
73
+
74
+ **If Figma branch (Step 1) already downloaded PNGs** → visuals already exist. Use `AskUserQuestion` to offer:
75
+ - **Continue** — Figma visuals are enough (Recommended)
76
+ - **Also capture live page** — supplement Figma with real page scan (invoke `sungen-capture-live` skill)
77
+
78
+ **If standard path (no `--figma`)** → go straight to source selection. Use `AskUserQuestion`: *"Pick a visual source for this screen:"*
38
79
  - **Figma design** (Recommended for pre-launch) — invoke `sungen-capture-figma` skill
39
80
  - **Live page scan** (dev/staging is up) — invoke `sungen-capture-live` skill
40
- - **Skip** — user will drop images manually into `requirements/ui/` later, or rely on `/sungen:create-test` to prompt again
81
+ - **Skip** — user will drop images manually into `requirements/ui/` later
41
82
 
42
83
  Each capture skill writes outputs into `qa/screens/<screen>/requirements/ui/` and reports back a summary. Do not inline capture logic here — always delegate to the skill so behavior stays consistent with `/sungen:create-test`.
43
84
 
44
- If the user has additional UI designs (mockups, hand-drawn sketches), suggest copying them to `requirements/ui/` — `sungen-capture-local` will pick them up during `/sungen:create-test`.
85
+ ### 3. Fill spec.md
86
+
87
+ Use `AskUserQuestion`: *"Fill `spec.md` now? (You can reference the captured visuals)"* — offer **Yes, fill now (Recommended)** / **Skip, fill later**.
88
+
89
+ If yes → open `qa/screens/<screen>/requirements/spec.md` and help the user fill sections, fields, validation rules, business rules, and states. Reference the captured visuals from Step 2 to suggest field names, form elements, and UI states. Especially prompt for the optional **Figma URL** and **Live URL** fields in Overview — those unlock auto-capture without re-asking next run.
45
90
 
46
91
  ### 4. Next steps
47
92