edsger 0.57.0 → 0.58.0
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.
- package/dist/phases/output-contracts.js +47 -36
- package/dist/phases/pr-shared/agent-utils.d.ts +11 -3
- package/dist/phases/pr-shared/agent-utils.js +48 -4
- package/dist/phases/screen-flow/index.js +73 -17
- package/dist/phases/screen-flow/mcp-server.d.ts +195 -0
- package/dist/phases/screen-flow/mcp-server.js +262 -0
- package/dist/phases/screen-flow/prompts.js +3 -1
- package/dist/phases/screen-flow/theme.js +23 -12
- package/dist/phases/screen-flow/types.js +30 -15
- package/package.json +1 -1
|
@@ -897,46 +897,57 @@ You MUST end your response with a JSON object containing the code refine results
|
|
|
897
897
|
\`\`\`
|
|
898
898
|
`,
|
|
899
899
|
'screen-flow': `
|
|
900
|
-
**CRITICAL —
|
|
900
|
+
**CRITICAL — How to return the result**:
|
|
901
901
|
|
|
902
|
-
|
|
902
|
+
Return the extraction by calling the MCP tool
|
|
903
|
+
\`mcp__screen-flow__submit_screen_flow\` **exactly once** with three arguments:
|
|
903
904
|
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
905
|
+
- \`summary\` — 1-3 sentence narrative of what kind of app this is and its primary user flows
|
|
906
|
+
- \`nodes\` — array of ScreenSchema objects (every user-facing screen, modal, drawer, tab, or named state)
|
|
907
|
+
- \`edges\` — array of ScreenEdge objects (transitions between screens)
|
|
908
|
+
|
|
909
|
+
The tool validates the arguments against the schema. If it returns an error,
|
|
910
|
+
fix the issue it describes and call the tool again. After a successful call,
|
|
911
|
+
end your turn — do not also paste the same data as a fenced text block.
|
|
912
|
+
|
|
913
|
+
You can also call \`mcp__screen-flow__record_progress({ phase, message })\` at
|
|
914
|
+
each phase boundary (detection / routing / screens / transitions / submission)
|
|
915
|
+
to keep the user informed during long runs. This is observability only — it
|
|
916
|
+
does not affect the extraction.
|
|
917
|
+
|
|
918
|
+
ScreenSchema fields:
|
|
919
|
+
- \`slug\` (unique within the flow), \`name\`, \`route?\`, \`file?\`
|
|
920
|
+
- \`kind\`: one of \`page\`, \`modal\`, \`drawer\`, \`tab\`, \`state\`
|
|
921
|
+
- \`layout\`: one of \`centered\`, \`sidebar\`, \`split\`, \`list-detail\`, \`tabs\`, \`stacked\`
|
|
922
|
+
- \`header?\`: \`{ title, subtitle?, back?, actions?: [{ label, variant?, icon? }] }\`
|
|
923
|
+
- \`body\`: array of sections; each section \`type\` is one of \`form\`, \`list\`, \`card-grid\`, \`table\`, \`kanban\`, \`text\`, \`image\`, \`chart\`, \`stats\`, \`empty-state\`, \`tabs\`, \`custom\`
|
|
924
|
+
|
|
925
|
+
ScreenEdge fields:
|
|
926
|
+
- \`fromSlug\`, \`toSlug\` (both MUST appear in nodes), \`triggerLabel\`, \`triggerFile?\`
|
|
927
|
+
- \`kind\`: one of \`navigate\`, \`modal\`, \`redirect\`, \`back\`
|
|
928
|
+
|
|
929
|
+
Schematic example of the tool call:
|
|
930
|
+
|
|
931
|
+
\`\`\`
|
|
932
|
+
submit_screen_flow({
|
|
933
|
+
summary: "Two-screen demo: sign in then land on home.",
|
|
934
|
+
nodes: [
|
|
935
|
+
{ slug: "login", name: "Login", route: "/signin", file: "src/pages/Login.tsx",
|
|
936
|
+
kind: "page", layout: "centered",
|
|
937
|
+
header: { title: "Sign in", actions: [{ label: "Sign up", variant: "ghost" }] },
|
|
938
|
+
body: [{ type: "form", submitLabel: "Sign in", fields: [
|
|
939
|
+
{ label: "Email", kind: "email", required: true },
|
|
940
|
+
{ label: "Password", kind: "password", required: true }
|
|
941
|
+
]}]
|
|
942
|
+
},
|
|
943
|
+
{ slug: "home", name: "Home", route: "/", file: "src/pages/Home.tsx",
|
|
944
|
+
kind: "page", layout: "sidebar", body: [] }
|
|
927
945
|
],
|
|
928
|
-
|
|
929
|
-
{
|
|
930
|
-
"
|
|
931
|
-
"toSlug": "home",
|
|
932
|
-
"triggerLabel": "Submit credentials",
|
|
933
|
-
"triggerFile": "src/pages/Login.tsx",
|
|
934
|
-
"kind": "navigate"
|
|
935
|
-
}
|
|
946
|
+
edges: [
|
|
947
|
+
{ fromSlug: "login", toSlug: "home", triggerLabel: "Submit credentials",
|
|
948
|
+
triggerFile: "src/pages/Login.tsx", kind: "navigate" }
|
|
936
949
|
]
|
|
937
|
-
}
|
|
950
|
+
})
|
|
938
951
|
\`\`\`
|
|
939
|
-
|
|
940
|
-
All node \`slug\` values must be unique. Every \`fromSlug\` / \`toSlug\` in edges must reference a slug that appears in \`nodes\`. Section \`type\` values are restricted to: \`form\`, \`list\`, \`card-grid\`, \`table\`, \`kanban\`, \`text\`, \`image\`, \`chart\`, \`stats\`, \`empty-state\`, \`tabs\`, \`custom\`. Edge \`kind\` values are restricted to: \`navigate\`, \`modal\`, \`redirect\`, \`back\`.
|
|
941
952
|
`,
|
|
942
953
|
};
|
|
@@ -24,16 +24,24 @@ export declare function createPromptGenerator(prompt: string): AsyncGenerator<{
|
|
|
24
24
|
}>;
|
|
25
25
|
/**
|
|
26
26
|
* Extract text content from assistant message content array.
|
|
27
|
+
*
|
|
28
|
+
* When `verbose`, also surfaces tool_use / tool_result blocks via
|
|
29
|
+
* logDebug so it's visible whether the agent is making MCP / file /
|
|
30
|
+
* bash calls — without these, a long-running session looks frozen
|
|
31
|
+
* between text emissions.
|
|
27
32
|
*/
|
|
28
33
|
export declare function extractTextFromContent(content: any[], verbose?: boolean): string;
|
|
29
34
|
/**
|
|
30
35
|
* Try to parse a JSON result from agent response text.
|
|
31
|
-
*
|
|
32
|
-
* Returns the parsed object or
|
|
36
|
+
* Tries a custom fenceTag (e.g. ```screen_flow) first when provided, then
|
|
37
|
+
* ```json, then falls back to raw JSON parsing. Returns the parsed object or
|
|
38
|
+
* null on failure.
|
|
33
39
|
*/
|
|
34
|
-
export declare function tryParseJsonFromResponse(responseText: string): unknown | null;
|
|
40
|
+
export declare function tryParseJsonFromResponse(responseText: string, fenceTag?: string): unknown | null;
|
|
35
41
|
/**
|
|
36
42
|
* Extract a specific keyed result from agent response.
|
|
37
43
|
* e.g., tryExtractResult(text, 'review_result') extracts the review_result key.
|
|
44
|
+
* The key is also tried as the fenced code-block tag so phases whose output
|
|
45
|
+
* contract uses a custom fence (e.g. ```screen_flow) parse correctly.
|
|
38
46
|
*/
|
|
39
47
|
export declare function tryExtractResult(responseText: string, key: string): unknown | null;
|
|
@@ -23,6 +23,11 @@ export async function* createPromptGenerator(prompt) {
|
|
|
23
23
|
}
|
|
24
24
|
/**
|
|
25
25
|
* Extract text content from assistant message content array.
|
|
26
|
+
*
|
|
27
|
+
* When `verbose`, also surfaces tool_use / tool_result blocks via
|
|
28
|
+
* logDebug so it's visible whether the agent is making MCP / file /
|
|
29
|
+
* bash calls — without these, a long-running session looks frozen
|
|
30
|
+
* between text emissions.
|
|
26
31
|
*/
|
|
27
32
|
export function extractTextFromContent(
|
|
28
33
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
@@ -33,16 +38,50 @@ content, verbose) {
|
|
|
33
38
|
text += `${item.text}\n`;
|
|
34
39
|
logDebug(item.text, verbose);
|
|
35
40
|
}
|
|
41
|
+
else if (verbose && item.type === 'tool_use') {
|
|
42
|
+
logDebug(`→ ${item.name}(${previewJson(item.input)})`, verbose);
|
|
43
|
+
}
|
|
44
|
+
else if (verbose && item.type === 'tool_result') {
|
|
45
|
+
const preview = Array.isArray(item.content)
|
|
46
|
+
? item.content
|
|
47
|
+
.filter((c) => c?.type === 'text')
|
|
48
|
+
.map((c) => c.text ?? '')
|
|
49
|
+
.join(' ')
|
|
50
|
+
: String(item.content ?? '');
|
|
51
|
+
const flag = item.is_error ? '✗' : '←';
|
|
52
|
+
logDebug(`${flag} ${truncate(preview, 200)}`, verbose);
|
|
53
|
+
}
|
|
36
54
|
}
|
|
37
55
|
return text;
|
|
38
56
|
}
|
|
57
|
+
function previewJson(value, max = 200) {
|
|
58
|
+
try {
|
|
59
|
+
return truncate(JSON.stringify(value), max);
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
return truncate(String(value), max);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
function truncate(text, max) {
|
|
66
|
+
if (text.length <= max) {
|
|
67
|
+
return text;
|
|
68
|
+
}
|
|
69
|
+
return `${text.slice(0, max - 1)}…`;
|
|
70
|
+
}
|
|
39
71
|
/**
|
|
40
72
|
* Try to parse a JSON result from agent response text.
|
|
41
|
-
*
|
|
42
|
-
* Returns the parsed object or
|
|
73
|
+
* Tries a custom fenceTag (e.g. ```screen_flow) first when provided, then
|
|
74
|
+
* ```json, then falls back to raw JSON parsing. Returns the parsed object or
|
|
75
|
+
* null on failure.
|
|
43
76
|
*/
|
|
44
|
-
export function tryParseJsonFromResponse(responseText) {
|
|
77
|
+
export function tryParseJsonFromResponse(responseText, fenceTag = 'json') {
|
|
45
78
|
try {
|
|
79
|
+
if (fenceTag !== 'json') {
|
|
80
|
+
const taggedMatch = responseText.match(new RegExp(`\`\`\`${escapeRegExp(fenceTag)}\\s*\\n([\\s\\S]*?)\\n\\s*\`\`\``));
|
|
81
|
+
if (taggedMatch) {
|
|
82
|
+
return JSON.parse(taggedMatch[1]);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
46
85
|
const jsonBlockMatch = responseText.match(/```json\s*\n([\s\S]*?)\n\s*```/);
|
|
47
86
|
return jsonBlockMatch
|
|
48
87
|
? JSON.parse(jsonBlockMatch[1])
|
|
@@ -55,9 +94,11 @@ export function tryParseJsonFromResponse(responseText) {
|
|
|
55
94
|
/**
|
|
56
95
|
* Extract a specific keyed result from agent response.
|
|
57
96
|
* e.g., tryExtractResult(text, 'review_result') extracts the review_result key.
|
|
97
|
+
* The key is also tried as the fenced code-block tag so phases whose output
|
|
98
|
+
* contract uses a custom fence (e.g. ```screen_flow) parse correctly.
|
|
58
99
|
*/
|
|
59
100
|
export function tryExtractResult(responseText, key) {
|
|
60
|
-
const parsed = tryParseJsonFromResponse(responseText);
|
|
101
|
+
const parsed = tryParseJsonFromResponse(responseText, key);
|
|
61
102
|
if (parsed &&
|
|
62
103
|
typeof parsed === 'object' &&
|
|
63
104
|
key in parsed) {
|
|
@@ -66,3 +107,6 @@ export function tryExtractResult(responseText, key) {
|
|
|
66
107
|
// If top-level has the expected shape, return the whole thing
|
|
67
108
|
return parsed;
|
|
68
109
|
}
|
|
110
|
+
function escapeRegExp(value) {
|
|
111
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
112
|
+
}
|
|
@@ -15,6 +15,7 @@ import { logError, logInfo, logSuccess, logWarning } from '../../utils/logger.js
|
|
|
15
15
|
import { cleanupIssueRepo, cloneIssueRepo, ensureWorkspaceDir, } from '../../workspace/workspace-manager.js';
|
|
16
16
|
import { fetchProductBasics } from '../find-shared/mcp.js';
|
|
17
17
|
import { createPromptGenerator, extractTextFromContent, tryExtractResult, } from '../pr-shared/agent-utils.js';
|
|
18
|
+
import { createScreenFlowCaptureState, createScreenFlowMcpServer, validateConsistency, } from './mcp-server.js';
|
|
18
19
|
import { createScreenFlowSystemPrompt, createScreenFlowUserPrompt, } from './prompts.js';
|
|
19
20
|
import { extractTheme } from './theme.js';
|
|
20
21
|
import { isScreenFlowExtraction, } from './types.js';
|
|
@@ -61,6 +62,17 @@ export async function runScreenFlowPhase(options) {
|
|
|
61
62
|
guidance,
|
|
62
63
|
});
|
|
63
64
|
logInfo('Running Claude screen-flow extraction...');
|
|
65
|
+
// The agent submits the extraction by calling submit_screen_flow on the
|
|
66
|
+
// in-process MCP server. The handler validates with Zod + cross-field
|
|
67
|
+
// checks and stores the result in `captureState.captured`. If the agent
|
|
68
|
+
// never calls the tool, we fall back to parsing a fenced screen_flow
|
|
69
|
+
// block out of the assistant text.
|
|
70
|
+
const captureState = createScreenFlowCaptureState();
|
|
71
|
+
const mcpServer = createScreenFlowMcpServer(captureState, {
|
|
72
|
+
onProgress: ({ phase, message }) => {
|
|
73
|
+
logInfo(`[${phase}] ${message}`);
|
|
74
|
+
},
|
|
75
|
+
});
|
|
64
76
|
let lastAssistantResponse = '';
|
|
65
77
|
let extraction = null;
|
|
66
78
|
for await (const message of query({
|
|
@@ -75,28 +87,19 @@ export async function runScreenFlowPhase(options) {
|
|
|
75
87
|
maxTurns: MAX_TURNS,
|
|
76
88
|
permissionMode: 'bypassPermissions',
|
|
77
89
|
cwd: repoPath,
|
|
90
|
+
mcpServers: {
|
|
91
|
+
'screen-flow': mcpServer,
|
|
92
|
+
},
|
|
78
93
|
},
|
|
79
94
|
})) {
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
if (message.type !== 'result') {
|
|
85
|
-
continue;
|
|
86
|
-
}
|
|
87
|
-
const responseText = message.subtype === 'success'
|
|
88
|
-
? message.result || lastAssistantResponse
|
|
89
|
-
: lastAssistantResponse;
|
|
90
|
-
const parsed = tryExtractResult(responseText, 'screen_flow');
|
|
91
|
-
if (isScreenFlowExtraction(parsed)) {
|
|
92
|
-
extraction = parsed;
|
|
93
|
-
}
|
|
94
|
-
else if (message.subtype !== 'success') {
|
|
95
|
-
logError(`Extraction incomplete: ${message.subtype}`);
|
|
95
|
+
const { assistantBuffer, extraction: nextExtraction } = processSdkMessage(message, lastAssistantResponse, captureState, verbose);
|
|
96
|
+
lastAssistantResponse = assistantBuffer;
|
|
97
|
+
if (nextExtraction) {
|
|
98
|
+
extraction = nextExtraction;
|
|
96
99
|
}
|
|
97
100
|
}
|
|
98
101
|
if (!extraction) {
|
|
99
|
-
const msg = 'Screen flow extraction failed:
|
|
102
|
+
const msg = 'Screen flow extraction failed: agent did not call submit_screen_flow and no parseable screen_flow block was found in the response';
|
|
100
103
|
await markFlowFailed(supabase, flowId, msg);
|
|
101
104
|
return { status: 'error', message: msg };
|
|
102
105
|
}
|
|
@@ -125,6 +128,59 @@ export async function runScreenFlowPhase(options) {
|
|
|
125
128
|
}
|
|
126
129
|
}
|
|
127
130
|
}
|
|
131
|
+
// Per-message handler — extracted out of the SDK loop to keep
|
|
132
|
+
// runScreenFlowPhase under the eslint complexity ceiling.
|
|
133
|
+
//
|
|
134
|
+
function processSdkMessage(
|
|
135
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
136
|
+
message, assistantBuffer, captureState, verbose) {
|
|
137
|
+
if (message.type === 'assistant') {
|
|
138
|
+
const next = assistantBuffer +
|
|
139
|
+
extractTextFromContent(message.message?.content ?? [], verbose);
|
|
140
|
+
return { assistantBuffer: next, extraction: null };
|
|
141
|
+
}
|
|
142
|
+
if (message.type === 'user' && verbose) {
|
|
143
|
+
// Surface tool_result blocks (incl. submit_screen_flow validation
|
|
144
|
+
// errors) so verbose mode shows the round-trip.
|
|
145
|
+
const userContent = message.message?.content;
|
|
146
|
+
if (Array.isArray(userContent)) {
|
|
147
|
+
extractTextFromContent(userContent, verbose);
|
|
148
|
+
}
|
|
149
|
+
return { assistantBuffer, extraction: null };
|
|
150
|
+
}
|
|
151
|
+
if (message.type !== 'result') {
|
|
152
|
+
return { assistantBuffer, extraction: null };
|
|
153
|
+
}
|
|
154
|
+
if (captureState.captured) {
|
|
155
|
+
return { assistantBuffer, extraction: captureState.captured };
|
|
156
|
+
}
|
|
157
|
+
const fallback = tryFallbackParse(message, assistantBuffer);
|
|
158
|
+
if (fallback) {
|
|
159
|
+
logWarning('Agent emitted a fenced screen_flow block instead of calling submit_screen_flow; using the parsed text as a fallback.');
|
|
160
|
+
return { assistantBuffer, extraction: fallback };
|
|
161
|
+
}
|
|
162
|
+
if (message.subtype !== 'success') {
|
|
163
|
+
logError(`Extraction incomplete: ${message.subtype}`);
|
|
164
|
+
}
|
|
165
|
+
return { assistantBuffer, extraction: null };
|
|
166
|
+
}
|
|
167
|
+
// Fallback parser: extract a screen_flow JSON block from the final assistant
|
|
168
|
+
// text if the agent skipped the submit_screen_flow tool call.
|
|
169
|
+
function tryFallbackParse(resultMessage, assistantText) {
|
|
170
|
+
const responseText = resultMessage.subtype === 'success'
|
|
171
|
+
? resultMessage.result || assistantText
|
|
172
|
+
: assistantText;
|
|
173
|
+
const parsed = tryExtractResult(responseText, 'screen_flow');
|
|
174
|
+
if (!isScreenFlowExtraction(parsed)) {
|
|
175
|
+
return null;
|
|
176
|
+
}
|
|
177
|
+
const { error } = validateConsistency(parsed);
|
|
178
|
+
if (error) {
|
|
179
|
+
logWarning(`Fallback extraction failed consistency check: ${error}`);
|
|
180
|
+
return null;
|
|
181
|
+
}
|
|
182
|
+
return parsed;
|
|
183
|
+
}
|
|
128
184
|
// ============================================================================
|
|
129
185
|
// Persistence
|
|
130
186
|
// ============================================================================
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* In-process MCP server exposing a single tool — `submit_screen_flow` —
|
|
3
|
+
* that the Claude Agent SDK session calls to return the structured
|
|
4
|
+
* extraction.
|
|
5
|
+
*
|
|
6
|
+
* Using a tool call instead of parsing a fenced text block lets the SDK
|
|
7
|
+
* enforce the schema (via Zod) and lets the agent self-correct when
|
|
8
|
+
* validation fails — the validation error is returned to the agent as
|
|
9
|
+
* the tool result and it can re-call the tool with corrected data.
|
|
10
|
+
*
|
|
11
|
+
* The capture pattern: callers pass in a `ScreenFlowCaptureState`. The
|
|
12
|
+
* tool handler stores the validated args on `state.captured` and the
|
|
13
|
+
* orchestrator reads it after the SDK loop ends. If the agent never
|
|
14
|
+
* calls the tool, `state.captured` stays null and the caller can fall
|
|
15
|
+
* back to parsing the assistant text.
|
|
16
|
+
*/
|
|
17
|
+
import { z } from 'zod';
|
|
18
|
+
import type { ScreenFlowExtraction } from './types.js';
|
|
19
|
+
export interface ScreenFlowCaptureState {
|
|
20
|
+
captured: ScreenFlowExtraction | null;
|
|
21
|
+
}
|
|
22
|
+
export declare function createScreenFlowCaptureState(): ScreenFlowCaptureState;
|
|
23
|
+
/** Optional sink for streaming progress messages from the agent. */
|
|
24
|
+
export type ScreenFlowProgressSink = (event: {
|
|
25
|
+
phase: 'detection' | 'routing' | 'screens' | 'transitions' | 'submission';
|
|
26
|
+
message: string;
|
|
27
|
+
}) => void;
|
|
28
|
+
export declare function validateConsistency(extraction: ScreenFlowExtraction): {
|
|
29
|
+
error: string | null;
|
|
30
|
+
};
|
|
31
|
+
/**
|
|
32
|
+
* Build the `submit_screen_flow` tool. Exported separately from the server
|
|
33
|
+
* so tests can exercise the handler directly without going through the
|
|
34
|
+
* MCP transport.
|
|
35
|
+
*/
|
|
36
|
+
export declare function createSubmitScreenFlowTool(state: ScreenFlowCaptureState): import("@anthropic-ai/claude-agent-sdk").SdkMcpToolDefinition<{
|
|
37
|
+
summary: z.ZodString;
|
|
38
|
+
nodes: z.ZodArray<z.ZodObject<{
|
|
39
|
+
slug: z.ZodString;
|
|
40
|
+
name: z.ZodString;
|
|
41
|
+
route: z.ZodOptional<z.ZodString>;
|
|
42
|
+
file: z.ZodOptional<z.ZodString>;
|
|
43
|
+
kind: z.ZodEnum<{
|
|
44
|
+
page: "page";
|
|
45
|
+
modal: "modal";
|
|
46
|
+
drawer: "drawer";
|
|
47
|
+
tab: "tab";
|
|
48
|
+
state: "state";
|
|
49
|
+
}>;
|
|
50
|
+
layout: z.ZodEnum<{
|
|
51
|
+
split: "split";
|
|
52
|
+
centered: "centered";
|
|
53
|
+
sidebar: "sidebar";
|
|
54
|
+
"list-detail": "list-detail";
|
|
55
|
+
tabs: "tabs";
|
|
56
|
+
stacked: "stacked";
|
|
57
|
+
}>;
|
|
58
|
+
header: z.ZodOptional<z.ZodObject<{
|
|
59
|
+
title: z.ZodString;
|
|
60
|
+
subtitle: z.ZodOptional<z.ZodString>;
|
|
61
|
+
back: z.ZodOptional<z.ZodBoolean>;
|
|
62
|
+
actions: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
|
63
|
+
label: z.ZodString;
|
|
64
|
+
variant: z.ZodOptional<z.ZodEnum<{
|
|
65
|
+
primary: "primary";
|
|
66
|
+
secondary: "secondary";
|
|
67
|
+
ghost: "ghost";
|
|
68
|
+
destructive: "destructive";
|
|
69
|
+
}>>;
|
|
70
|
+
icon: z.ZodOptional<z.ZodString>;
|
|
71
|
+
}, z.core.$strip>>>;
|
|
72
|
+
}, z.core.$strip>>;
|
|
73
|
+
body: z.ZodArray<z.ZodDiscriminatedUnion<[z.ZodObject<{
|
|
74
|
+
type: z.ZodLiteral<"form">;
|
|
75
|
+
fields: z.ZodArray<z.ZodObject<{
|
|
76
|
+
label: z.ZodString;
|
|
77
|
+
kind: z.ZodEnum<{
|
|
78
|
+
number: "number";
|
|
79
|
+
text: "text";
|
|
80
|
+
date: "date";
|
|
81
|
+
select: "select";
|
|
82
|
+
email: "email";
|
|
83
|
+
password: "password";
|
|
84
|
+
textarea: "textarea";
|
|
85
|
+
checkbox: "checkbox";
|
|
86
|
+
}>;
|
|
87
|
+
placeholder: z.ZodOptional<z.ZodString>;
|
|
88
|
+
value: z.ZodOptional<z.ZodString>;
|
|
89
|
+
required: z.ZodOptional<z.ZodBoolean>;
|
|
90
|
+
}, z.core.$strip>>;
|
|
91
|
+
submitLabel: z.ZodString;
|
|
92
|
+
secondaryLabel: z.ZodOptional<z.ZodString>;
|
|
93
|
+
}, z.core.$strip>, z.ZodObject<{
|
|
94
|
+
type: z.ZodLiteral<"list">;
|
|
95
|
+
items: z.ZodArray<z.ZodObject<{
|
|
96
|
+
title: z.ZodString;
|
|
97
|
+
subtitle: z.ZodOptional<z.ZodString>;
|
|
98
|
+
meta: z.ZodOptional<z.ZodString>;
|
|
99
|
+
icon: z.ZodOptional<z.ZodString>;
|
|
100
|
+
}, z.core.$strip>>;
|
|
101
|
+
emptyMessage: z.ZodOptional<z.ZodString>;
|
|
102
|
+
}, z.core.$strip>, z.ZodObject<{
|
|
103
|
+
type: z.ZodLiteral<"card-grid">;
|
|
104
|
+
cards: z.ZodArray<z.ZodObject<{
|
|
105
|
+
title: z.ZodString;
|
|
106
|
+
subtitle: z.ZodOptional<z.ZodString>;
|
|
107
|
+
meta: z.ZodOptional<z.ZodString>;
|
|
108
|
+
}, z.core.$strip>>;
|
|
109
|
+
columns: z.ZodOptional<z.ZodUnion<readonly [z.ZodLiteral<2>, z.ZodLiteral<3>, z.ZodLiteral<4>]>>;
|
|
110
|
+
}, z.core.$strip>, z.ZodObject<{
|
|
111
|
+
type: z.ZodLiteral<"table">;
|
|
112
|
+
columns: z.ZodArray<z.ZodString>;
|
|
113
|
+
rows: z.ZodArray<z.ZodArray<z.ZodString>>;
|
|
114
|
+
}, z.core.$strip>, z.ZodObject<{
|
|
115
|
+
type: z.ZodLiteral<"kanban">;
|
|
116
|
+
columns: z.ZodArray<z.ZodObject<{
|
|
117
|
+
title: z.ZodString;
|
|
118
|
+
cards: z.ZodArray<z.ZodObject<{
|
|
119
|
+
title: z.ZodString;
|
|
120
|
+
meta: z.ZodOptional<z.ZodString>;
|
|
121
|
+
}, z.core.$strip>>;
|
|
122
|
+
}, z.core.$strip>>;
|
|
123
|
+
}, z.core.$strip>, z.ZodObject<{
|
|
124
|
+
type: z.ZodLiteral<"text">;
|
|
125
|
+
content: z.ZodString;
|
|
126
|
+
}, z.core.$strip>, z.ZodObject<{
|
|
127
|
+
type: z.ZodLiteral<"image">;
|
|
128
|
+
alt: z.ZodString;
|
|
129
|
+
aspect: z.ZodOptional<z.ZodEnum<{
|
|
130
|
+
video: "video";
|
|
131
|
+
square: "square";
|
|
132
|
+
wide: "wide";
|
|
133
|
+
}>>;
|
|
134
|
+
}, z.core.$strip>, z.ZodObject<{
|
|
135
|
+
type: z.ZodLiteral<"chart">;
|
|
136
|
+
chartKind: z.ZodEnum<{
|
|
137
|
+
line: "line";
|
|
138
|
+
bar: "bar";
|
|
139
|
+
pie: "pie";
|
|
140
|
+
}>;
|
|
141
|
+
label: z.ZodOptional<z.ZodString>;
|
|
142
|
+
}, z.core.$strip>, z.ZodObject<{
|
|
143
|
+
type: z.ZodLiteral<"stats">;
|
|
144
|
+
items: z.ZodArray<z.ZodObject<{
|
|
145
|
+
label: z.ZodString;
|
|
146
|
+
value: z.ZodString;
|
|
147
|
+
delta: z.ZodOptional<z.ZodString>;
|
|
148
|
+
}, z.core.$strip>>;
|
|
149
|
+
}, z.core.$strip>, z.ZodObject<{
|
|
150
|
+
type: z.ZodLiteral<"empty-state">;
|
|
151
|
+
title: z.ZodString;
|
|
152
|
+
message: z.ZodOptional<z.ZodString>;
|
|
153
|
+
cta: z.ZodOptional<z.ZodString>;
|
|
154
|
+
}, z.core.$strip>, z.ZodObject<{
|
|
155
|
+
type: z.ZodLiteral<"tabs">;
|
|
156
|
+
tabs: z.ZodArray<z.ZodString>;
|
|
157
|
+
activeIndex: z.ZodOptional<z.ZodNumber>;
|
|
158
|
+
}, z.core.$strip>, z.ZodObject<{
|
|
159
|
+
type: z.ZodLiteral<"custom">;
|
|
160
|
+
label: z.ZodString;
|
|
161
|
+
}, z.core.$strip>], "type">>;
|
|
162
|
+
}, z.core.$strip>>;
|
|
163
|
+
edges: z.ZodArray<z.ZodObject<{
|
|
164
|
+
fromSlug: z.ZodString;
|
|
165
|
+
toSlug: z.ZodString;
|
|
166
|
+
triggerLabel: z.ZodString;
|
|
167
|
+
triggerFile: z.ZodOptional<z.ZodString>;
|
|
168
|
+
kind: z.ZodEnum<{
|
|
169
|
+
modal: "modal";
|
|
170
|
+
navigate: "navigate";
|
|
171
|
+
redirect: "redirect";
|
|
172
|
+
back: "back";
|
|
173
|
+
}>;
|
|
174
|
+
}, z.core.$strip>>;
|
|
175
|
+
}>;
|
|
176
|
+
/**
|
|
177
|
+
* Build the `record_progress` tool. A side-channel that lets the agent
|
|
178
|
+
* push a human-readable status message to the CLI / desktop UI so a
|
|
179
|
+
* multi-minute extraction doesn't look frozen between text emissions.
|
|
180
|
+
* Returning `{ ok: true }` keeps it cheap — it has no semantic effect
|
|
181
|
+
* on the extraction.
|
|
182
|
+
*/
|
|
183
|
+
export declare function createRecordProgressTool(sink?: ScreenFlowProgressSink): import("@anthropic-ai/claude-agent-sdk").SdkMcpToolDefinition<{
|
|
184
|
+
phase: z.ZodEnum<{
|
|
185
|
+
detection: "detection";
|
|
186
|
+
routing: "routing";
|
|
187
|
+
screens: "screens";
|
|
188
|
+
transitions: "transitions";
|
|
189
|
+
submission: "submission";
|
|
190
|
+
}>;
|
|
191
|
+
message: z.ZodString;
|
|
192
|
+
}>;
|
|
193
|
+
export declare function createScreenFlowMcpServer(state: ScreenFlowCaptureState, options?: {
|
|
194
|
+
onProgress?: ScreenFlowProgressSink;
|
|
195
|
+
}): import("@anthropic-ai/claude-agent-sdk").McpSdkServerConfigWithInstance;
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* In-process MCP server exposing a single tool — `submit_screen_flow` —
|
|
3
|
+
* that the Claude Agent SDK session calls to return the structured
|
|
4
|
+
* extraction.
|
|
5
|
+
*
|
|
6
|
+
* Using a tool call instead of parsing a fenced text block lets the SDK
|
|
7
|
+
* enforce the schema (via Zod) and lets the agent self-correct when
|
|
8
|
+
* validation fails — the validation error is returned to the agent as
|
|
9
|
+
* the tool result and it can re-call the tool with corrected data.
|
|
10
|
+
*
|
|
11
|
+
* The capture pattern: callers pass in a `ScreenFlowCaptureState`. The
|
|
12
|
+
* tool handler stores the validated args on `state.captured` and the
|
|
13
|
+
* orchestrator reads it after the SDK loop ends. If the agent never
|
|
14
|
+
* calls the tool, `state.captured` stays null and the caller can fall
|
|
15
|
+
* back to parsing the assistant text.
|
|
16
|
+
*/
|
|
17
|
+
import { createSdkMcpServer, tool } from '@anthropic-ai/claude-agent-sdk';
|
|
18
|
+
import { z } from 'zod';
|
|
19
|
+
export function createScreenFlowCaptureState() {
|
|
20
|
+
return { captured: null };
|
|
21
|
+
}
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
// Zod schemas (mirror types.ts — kept in sync by tests)
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
const formFieldSchema = z.object({
|
|
26
|
+
label: z.string(),
|
|
27
|
+
kind: z.enum([
|
|
28
|
+
'text',
|
|
29
|
+
'email',
|
|
30
|
+
'password',
|
|
31
|
+
'textarea',
|
|
32
|
+
'select',
|
|
33
|
+
'checkbox',
|
|
34
|
+
'date',
|
|
35
|
+
'number',
|
|
36
|
+
]),
|
|
37
|
+
placeholder: z.string().optional(),
|
|
38
|
+
value: z.string().optional(),
|
|
39
|
+
required: z.boolean().optional(),
|
|
40
|
+
});
|
|
41
|
+
const listItemSchema = z.object({
|
|
42
|
+
title: z.string(),
|
|
43
|
+
subtitle: z.string().optional(),
|
|
44
|
+
meta: z.string().optional(),
|
|
45
|
+
icon: z.string().optional(),
|
|
46
|
+
});
|
|
47
|
+
const cardItemSchema = z.object({
|
|
48
|
+
title: z.string(),
|
|
49
|
+
subtitle: z.string().optional(),
|
|
50
|
+
meta: z.string().optional(),
|
|
51
|
+
});
|
|
52
|
+
const kanbanColumnSchema = z.object({
|
|
53
|
+
title: z.string(),
|
|
54
|
+
cards: z.array(z.object({ title: z.string(), meta: z.string().optional() })),
|
|
55
|
+
});
|
|
56
|
+
const sectionSchema = z.discriminatedUnion('type', [
|
|
57
|
+
z.object({
|
|
58
|
+
type: z.literal('form'),
|
|
59
|
+
fields: z.array(formFieldSchema),
|
|
60
|
+
submitLabel: z.string(),
|
|
61
|
+
secondaryLabel: z.string().optional(),
|
|
62
|
+
}),
|
|
63
|
+
z.object({
|
|
64
|
+
type: z.literal('list'),
|
|
65
|
+
items: z.array(listItemSchema),
|
|
66
|
+
emptyMessage: z.string().optional(),
|
|
67
|
+
}),
|
|
68
|
+
z.object({
|
|
69
|
+
type: z.literal('card-grid'),
|
|
70
|
+
cards: z.array(cardItemSchema),
|
|
71
|
+
columns: z.union([z.literal(2), z.literal(3), z.literal(4)]).optional(),
|
|
72
|
+
}),
|
|
73
|
+
z.object({
|
|
74
|
+
type: z.literal('table'),
|
|
75
|
+
columns: z.array(z.string()),
|
|
76
|
+
rows: z.array(z.array(z.string())),
|
|
77
|
+
}),
|
|
78
|
+
z.object({
|
|
79
|
+
type: z.literal('kanban'),
|
|
80
|
+
columns: z.array(kanbanColumnSchema),
|
|
81
|
+
}),
|
|
82
|
+
z.object({
|
|
83
|
+
type: z.literal('text'),
|
|
84
|
+
content: z.string(),
|
|
85
|
+
}),
|
|
86
|
+
z.object({
|
|
87
|
+
type: z.literal('image'),
|
|
88
|
+
alt: z.string(),
|
|
89
|
+
aspect: z.enum(['video', 'square', 'wide']).optional(),
|
|
90
|
+
}),
|
|
91
|
+
z.object({
|
|
92
|
+
type: z.literal('chart'),
|
|
93
|
+
chartKind: z.enum(['line', 'bar', 'pie']),
|
|
94
|
+
label: z.string().optional(),
|
|
95
|
+
}),
|
|
96
|
+
z.object({
|
|
97
|
+
type: z.literal('stats'),
|
|
98
|
+
items: z.array(z.object({
|
|
99
|
+
label: z.string(),
|
|
100
|
+
value: z.string(),
|
|
101
|
+
delta: z.string().optional(),
|
|
102
|
+
})),
|
|
103
|
+
}),
|
|
104
|
+
z.object({
|
|
105
|
+
type: z.literal('empty-state'),
|
|
106
|
+
title: z.string(),
|
|
107
|
+
message: z.string().optional(),
|
|
108
|
+
cta: z.string().optional(),
|
|
109
|
+
}),
|
|
110
|
+
z.object({
|
|
111
|
+
type: z.literal('tabs'),
|
|
112
|
+
tabs: z.array(z.string()),
|
|
113
|
+
activeIndex: z.number().optional(),
|
|
114
|
+
}),
|
|
115
|
+
z.object({
|
|
116
|
+
type: z.literal('custom'),
|
|
117
|
+
label: z.string(),
|
|
118
|
+
}),
|
|
119
|
+
]);
|
|
120
|
+
const screenActionSchema = z.object({
|
|
121
|
+
label: z.string(),
|
|
122
|
+
variant: z.enum(['primary', 'secondary', 'ghost', 'destructive']).optional(),
|
|
123
|
+
icon: z.string().optional(),
|
|
124
|
+
});
|
|
125
|
+
const screenHeaderSchema = z.object({
|
|
126
|
+
title: z.string(),
|
|
127
|
+
subtitle: z.string().optional(),
|
|
128
|
+
back: z.boolean().optional(),
|
|
129
|
+
actions: z.array(screenActionSchema).optional(),
|
|
130
|
+
});
|
|
131
|
+
const screenNodeSchema = z.object({
|
|
132
|
+
slug: z.string().min(1),
|
|
133
|
+
name: z.string().min(1),
|
|
134
|
+
route: z.string().optional(),
|
|
135
|
+
file: z.string().optional(),
|
|
136
|
+
kind: z.enum(['page', 'modal', 'drawer', 'tab', 'state']),
|
|
137
|
+
layout: z.enum([
|
|
138
|
+
'centered',
|
|
139
|
+
'sidebar',
|
|
140
|
+
'split',
|
|
141
|
+
'list-detail',
|
|
142
|
+
'tabs',
|
|
143
|
+
'stacked',
|
|
144
|
+
]),
|
|
145
|
+
header: screenHeaderSchema.optional(),
|
|
146
|
+
body: z.array(sectionSchema),
|
|
147
|
+
});
|
|
148
|
+
const screenEdgeSchema = z.object({
|
|
149
|
+
fromSlug: z.string().min(1),
|
|
150
|
+
toSlug: z.string().min(1),
|
|
151
|
+
triggerLabel: z.string(),
|
|
152
|
+
triggerFile: z.string().optional(),
|
|
153
|
+
kind: z.enum(['navigate', 'modal', 'redirect', 'back']),
|
|
154
|
+
});
|
|
155
|
+
// ---------------------------------------------------------------------------
|
|
156
|
+
// Cross-field consistency (Zod can't express this)
|
|
157
|
+
// ---------------------------------------------------------------------------
|
|
158
|
+
export function validateConsistency(extraction) {
|
|
159
|
+
const slugs = new Set();
|
|
160
|
+
for (const node of extraction.nodes) {
|
|
161
|
+
if (slugs.has(node.slug)) {
|
|
162
|
+
return {
|
|
163
|
+
error: `Duplicate node slug "${node.slug}". Each node.slug MUST be unique within the flow. Re-call submit_screen_flow with deduplicated nodes.`,
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
slugs.add(node.slug);
|
|
167
|
+
}
|
|
168
|
+
for (const edge of extraction.edges) {
|
|
169
|
+
if (!slugs.has(edge.fromSlug)) {
|
|
170
|
+
return {
|
|
171
|
+
error: `Edge fromSlug "${edge.fromSlug}" → "${edge.toSlug}" does not match any node slug. Either add the missing node or drop the edge, then re-call submit_screen_flow.`,
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
if (!slugs.has(edge.toSlug)) {
|
|
175
|
+
return {
|
|
176
|
+
error: `Edge fromSlug "${edge.fromSlug}" → toSlug "${edge.toSlug}" does not match any node slug. Either add the missing node or drop the edge, then re-call submit_screen_flow.`,
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
return { error: null };
|
|
181
|
+
}
|
|
182
|
+
// ---------------------------------------------------------------------------
|
|
183
|
+
// Tool factory + server factory
|
|
184
|
+
// ---------------------------------------------------------------------------
|
|
185
|
+
/**
|
|
186
|
+
* Build the `submit_screen_flow` tool. Exported separately from the server
|
|
187
|
+
* so tests can exercise the handler directly without going through the
|
|
188
|
+
* MCP transport.
|
|
189
|
+
*/
|
|
190
|
+
export function createSubmitScreenFlowTool(state) {
|
|
191
|
+
return tool('submit_screen_flow', [
|
|
192
|
+
'Submit the final screen flow extraction. Call this EXACTLY once,',
|
|
193
|
+
'when you have finished mapping every screen and transition. Pass the',
|
|
194
|
+
'full structured flow as the argument. After this call succeeds, end',
|
|
195
|
+
'your turn — do NOT also paste the same data as a fenced code block.',
|
|
196
|
+
'If validation fails, the error message tells you what to fix; call',
|
|
197
|
+
'the tool again with corrected data.',
|
|
198
|
+
].join(' '), {
|
|
199
|
+
summary: z
|
|
200
|
+
.string()
|
|
201
|
+
.min(1)
|
|
202
|
+
.describe('1-3 sentence narrative of what kind of app this is and its primary user flows.'),
|
|
203
|
+
nodes: z
|
|
204
|
+
.array(screenNodeSchema)
|
|
205
|
+
.describe('Every user-facing screen, modal, drawer, tab, or named state. node.slug MUST be unique within the flow.'),
|
|
206
|
+
edges: z
|
|
207
|
+
.array(screenEdgeSchema)
|
|
208
|
+
.describe('Transitions between screens. Every fromSlug / toSlug MUST reference a slug present in nodes; drop edges whose endpoints you did not emit.'),
|
|
209
|
+
}, async (args) => {
|
|
210
|
+
const extraction = {
|
|
211
|
+
summary: args.summary,
|
|
212
|
+
nodes: args.nodes,
|
|
213
|
+
edges: args.edges,
|
|
214
|
+
};
|
|
215
|
+
const { error } = validateConsistency(extraction);
|
|
216
|
+
if (error) {
|
|
217
|
+
return {
|
|
218
|
+
content: [{ type: 'text', text: error }],
|
|
219
|
+
isError: true,
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
state.captured = extraction;
|
|
223
|
+
return {
|
|
224
|
+
content: [
|
|
225
|
+
{
|
|
226
|
+
type: 'text',
|
|
227
|
+
text: `Captured ${extraction.nodes.length} screens / ${extraction.edges.length} transitions. End your turn now.`,
|
|
228
|
+
},
|
|
229
|
+
],
|
|
230
|
+
};
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
/**
|
|
234
|
+
* Build the `record_progress` tool. A side-channel that lets the agent
|
|
235
|
+
* push a human-readable status message to the CLI / desktop UI so a
|
|
236
|
+
* multi-minute extraction doesn't look frozen between text emissions.
|
|
237
|
+
* Returning `{ ok: true }` keeps it cheap — it has no semantic effect
|
|
238
|
+
* on the extraction.
|
|
239
|
+
*/
|
|
240
|
+
export function createRecordProgressTool(sink) {
|
|
241
|
+
return tool('record_progress', 'Send a short status update to the user. Does not affect the extraction. Call it at each phase boundary (after detecting the framework, after enumerating routes, while mapping screens, when about to submit) so the user sees progress.', {
|
|
242
|
+
phase: z
|
|
243
|
+
.enum(['detection', 'routing', 'screens', 'transitions', 'submission'])
|
|
244
|
+
.describe('Which phase the message belongs to.'),
|
|
245
|
+
message: z.string().min(1).describe('Human-readable status update.'),
|
|
246
|
+
}, async (args) => {
|
|
247
|
+
sink?.({ phase: args.phase, message: args.message });
|
|
248
|
+
return {
|
|
249
|
+
content: [{ type: 'text', text: 'ok' }],
|
|
250
|
+
};
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
export function createScreenFlowMcpServer(state, options) {
|
|
254
|
+
return createSdkMcpServer({
|
|
255
|
+
name: 'screen-flow',
|
|
256
|
+
version: '1.0.0',
|
|
257
|
+
tools: [
|
|
258
|
+
createSubmitScreenFlowTool(state),
|
|
259
|
+
createRecordProgressTool(options?.onProgress),
|
|
260
|
+
],
|
|
261
|
+
});
|
|
262
|
+
}
|
|
@@ -35,5 +35,7 @@ export function createScreenFlowUserPrompt(args) {
|
|
|
35
35
|
|
|
36
36
|
Start by detecting the framework (check package.json / pubspec.yaml / Package.swift), then locate the router definition or pages directory. Read just enough source per screen to fill in a useful ScreenSchema — do not need to read everything.
|
|
37
37
|
|
|
38
|
-
|
|
38
|
+
Call \`mcp__screen-flow__record_progress\` at each phase boundary so the user can see your progress (otherwise the CLI looks frozen).
|
|
39
|
+
|
|
40
|
+
When you are done, return the result by **calling the \`mcp__screen-flow__submit_screen_flow\` tool exactly once** with \`summary\`, \`nodes\`, and \`edges\` as arguments. Do not paste the JSON as a fenced text block — the tool call is the deliverable. If the tool returns an error, fix the issue it describes and call the tool again.`;
|
|
39
41
|
}
|
|
@@ -94,19 +94,23 @@ function parseTailwindColors(source) {
|
|
|
94
94
|
// either a single string ("primary: '#ff0066'") or an object containing
|
|
95
95
|
// a 500 key (the Tailwind convention).
|
|
96
96
|
const primaryString = matchColorEntry(source, 'primary');
|
|
97
|
-
if (primaryString)
|
|
97
|
+
if (primaryString) {
|
|
98
98
|
theme.primary = primaryString;
|
|
99
|
+
}
|
|
99
100
|
const neutralString = matchColorEntry(source, 'neutral');
|
|
100
|
-
if (neutralString)
|
|
101
|
+
if (neutralString) {
|
|
101
102
|
theme.neutral = neutralString;
|
|
103
|
+
}
|
|
102
104
|
// Pull a radius default if defined under theme.borderRadius
|
|
103
105
|
const radiusMatch = source.match(/borderRadius\s*:\s*{[^}]*?(?:DEFAULT|md|lg)\s*:\s*['"]([^'"]+)['"]/);
|
|
104
|
-
if (radiusMatch)
|
|
106
|
+
if (radiusMatch) {
|
|
105
107
|
theme.radius = radiusMatch[1];
|
|
108
|
+
}
|
|
106
109
|
// Pull the default sans font family
|
|
107
110
|
const fontMatch = source.match(/fontFamily\s*:\s*{[^}]*?sans\s*:\s*\[?\s*['"]([^'"]+)['"]/);
|
|
108
|
-
if (fontMatch)
|
|
111
|
+
if (fontMatch) {
|
|
109
112
|
theme.font = fontMatch[1];
|
|
113
|
+
}
|
|
110
114
|
return theme;
|
|
111
115
|
}
|
|
112
116
|
function matchColorEntry(source, key) {
|
|
@@ -132,22 +136,26 @@ function parseTokensJson(json) {
|
|
|
132
136
|
const colors = (json.colors ?? json.color);
|
|
133
137
|
if (colors && typeof colors === 'object') {
|
|
134
138
|
const primary = colors.primary;
|
|
135
|
-
if (typeof primary === 'string')
|
|
139
|
+
if (typeof primary === 'string') {
|
|
136
140
|
theme.primary = primary;
|
|
141
|
+
}
|
|
137
142
|
else if (primary && typeof primary === 'object' && '500' in primary) {
|
|
138
143
|
theme.primary = primary['500'];
|
|
139
144
|
}
|
|
140
145
|
const neutral = colors.neutral;
|
|
141
|
-
if (typeof neutral === 'string')
|
|
146
|
+
if (typeof neutral === 'string') {
|
|
142
147
|
theme.neutral = neutral;
|
|
148
|
+
}
|
|
143
149
|
else if (neutral && typeof neutral === 'object' && '500' in neutral) {
|
|
144
150
|
theme.neutral = neutral['500'];
|
|
145
151
|
}
|
|
146
152
|
}
|
|
147
|
-
if (typeof json.radius === 'string')
|
|
153
|
+
if (typeof json.radius === 'string') {
|
|
148
154
|
theme.radius = json.radius;
|
|
149
|
-
|
|
155
|
+
}
|
|
156
|
+
if (typeof json.font === 'string') {
|
|
150
157
|
theme.font = json.font;
|
|
158
|
+
}
|
|
151
159
|
return theme;
|
|
152
160
|
}
|
|
153
161
|
function parseGlobalCss(source) {
|
|
@@ -155,14 +163,17 @@ function parseGlobalCss(source) {
|
|
|
155
163
|
const primaryMatch = source.match(/--primary\s*:\s*([^;]+);/);
|
|
156
164
|
if (primaryMatch) {
|
|
157
165
|
const value = primaryMatch[1].trim();
|
|
158
|
-
if (isColorish(value))
|
|
166
|
+
if (isColorish(value)) {
|
|
159
167
|
theme.primary = value;
|
|
160
|
-
|
|
161
|
-
|
|
168
|
+
}
|
|
169
|
+
else {
|
|
170
|
+
theme.primary = `hsl(${value})`;
|
|
171
|
+
} // common shadcn pattern
|
|
162
172
|
}
|
|
163
173
|
const radiusMatch = source.match(/--radius\s*:\s*([^;]+);/);
|
|
164
|
-
if (radiusMatch)
|
|
174
|
+
if (radiusMatch) {
|
|
165
175
|
theme.radius = radiusMatch[1].trim();
|
|
176
|
+
}
|
|
166
177
|
return theme;
|
|
167
178
|
}
|
|
168
179
|
function isColorish(value) {
|
|
@@ -20,47 +20,62 @@ function isRecord(value) {
|
|
|
20
20
|
return typeof value === 'object' && value !== null;
|
|
21
21
|
}
|
|
22
22
|
function isScreenSchema(value) {
|
|
23
|
-
if (!isRecord(value))
|
|
23
|
+
if (!isRecord(value)) {
|
|
24
24
|
return false;
|
|
25
|
-
|
|
25
|
+
}
|
|
26
|
+
if (typeof value.slug !== 'string' || value.slug.length === 0) {
|
|
26
27
|
return false;
|
|
27
|
-
|
|
28
|
+
}
|
|
29
|
+
if (typeof value.name !== 'string' || value.name.length === 0) {
|
|
28
30
|
return false;
|
|
31
|
+
}
|
|
29
32
|
if (typeof value.kind !== 'string' || !SCREEN_KINDS.has(value.kind)) {
|
|
30
33
|
return false;
|
|
31
34
|
}
|
|
32
|
-
if (typeof value.layout !== 'string')
|
|
35
|
+
if (typeof value.layout !== 'string') {
|
|
33
36
|
return false;
|
|
34
|
-
|
|
37
|
+
}
|
|
38
|
+
if (!Array.isArray(value.body)) {
|
|
35
39
|
return false;
|
|
40
|
+
}
|
|
36
41
|
return true;
|
|
37
42
|
}
|
|
38
43
|
function isScreenEdge(value) {
|
|
39
|
-
if (!isRecord(value))
|
|
44
|
+
if (!isRecord(value)) {
|
|
40
45
|
return false;
|
|
41
|
-
|
|
46
|
+
}
|
|
47
|
+
if (typeof value.fromSlug !== 'string') {
|
|
42
48
|
return false;
|
|
43
|
-
|
|
49
|
+
}
|
|
50
|
+
if (typeof value.toSlug !== 'string') {
|
|
44
51
|
return false;
|
|
45
|
-
|
|
52
|
+
}
|
|
53
|
+
if (typeof value.triggerLabel !== 'string') {
|
|
46
54
|
return false;
|
|
55
|
+
}
|
|
47
56
|
if (typeof value.kind !== 'string' || !EDGE_KINDS.has(value.kind)) {
|
|
48
57
|
return false;
|
|
49
58
|
}
|
|
50
59
|
return true;
|
|
51
60
|
}
|
|
52
61
|
export function isScreenFlowExtraction(value) {
|
|
53
|
-
if (!isRecord(value))
|
|
62
|
+
if (!isRecord(value)) {
|
|
54
63
|
return false;
|
|
55
|
-
|
|
64
|
+
}
|
|
65
|
+
if (typeof value.summary !== 'string') {
|
|
56
66
|
return false;
|
|
57
|
-
|
|
67
|
+
}
|
|
68
|
+
if (!Array.isArray(value.nodes)) {
|
|
58
69
|
return false;
|
|
59
|
-
|
|
70
|
+
}
|
|
71
|
+
if (!Array.isArray(value.edges)) {
|
|
60
72
|
return false;
|
|
61
|
-
|
|
73
|
+
}
|
|
74
|
+
if (!value.nodes.every(isScreenSchema)) {
|
|
62
75
|
return false;
|
|
63
|
-
|
|
76
|
+
}
|
|
77
|
+
if (!value.edges.every(isScreenEdge)) {
|
|
64
78
|
return false;
|
|
79
|
+
}
|
|
65
80
|
return true;
|
|
66
81
|
}
|