@ahmedalbarghouti/farq 0.0.2 → 0.1.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.
Files changed (51) hide show
  1. package/README.md +51 -13
  2. package/dist/concurrency.d.ts +2 -0
  3. package/dist/concurrency.js +16 -0
  4. package/dist/config.d.ts +16 -0
  5. package/dist/config.js +25 -0
  6. package/dist/git.d.ts +7 -3
  7. package/dist/git.js +36 -27
  8. package/dist/index.js +116 -33
  9. package/dist/open-pr.d.ts +6 -1
  10. package/dist/open-pr.js +42 -16
  11. package/dist/providers/claude.d.ts +1 -0
  12. package/dist/providers/claude.js +49 -32
  13. package/dist/providers/fake.d.ts +5 -2
  14. package/dist/providers/fake.js +26 -15
  15. package/dist/providers/index.d.ts +1 -1
  16. package/dist/providers/index.js +1 -1
  17. package/dist/render/pr.d.ts +6 -0
  18. package/dist/render/pr.js +29 -7
  19. package/dist/schema.d.ts +40 -7
  20. package/dist/schema.js +14 -1
  21. package/dist/summarize.js +7 -0
  22. package/dist/template.d.ts +6 -0
  23. package/dist/template.js +40 -19
  24. package/dist/ui/index.d.ts +1 -0
  25. package/dist/ui/index.js +1 -0
  26. package/dist/ui/lines.d.ts +19 -1
  27. package/dist/ui/lines.js +81 -39
  28. package/dist/ui/logger.d.ts +9 -2
  29. package/dist/ui/logger.js +15 -4
  30. package/dist/ui/spinner.d.ts +15 -2
  31. package/dist/ui/spinner.js +105 -22
  32. package/dist/ui/theme.d.ts +1 -0
  33. package/dist/ui/theme.js +3 -0
  34. package/dist/visual/chrome.js +21 -2
  35. package/dist/visual/cluster.d.ts +29 -0
  36. package/dist/visual/cluster.js +204 -0
  37. package/dist/visual/compose.d.ts +8 -1
  38. package/dist/visual/compose.js +17 -32
  39. package/dist/visual/css-scope.d.ts +6 -0
  40. package/dist/visual/css-scope.js +162 -0
  41. package/dist/visual/design.d.ts +103 -0
  42. package/dist/visual/design.js +327 -0
  43. package/dist/visual/diagram.d.ts +8 -0
  44. package/dist/visual/diagram.js +36 -14
  45. package/dist/visual/mockup.d.ts +16 -11
  46. package/dist/visual/mockup.js +45 -22
  47. package/dist/visual/pipeline.d.ts +35 -1
  48. package/dist/visual/pipeline.js +191 -69
  49. package/dist/visual/viewport.d.ts +16 -0
  50. package/dist/visual/viewport.js +19 -0
  51. package/package.json +1 -1
@@ -0,0 +1,327 @@
1
+ import { scopeCss } from "./css-scope.js";
2
+ import { VIEWPORT_MAX_HEIGHT, VIEWPORT_MAX_WIDTH } from "./viewport.js";
3
+ export const THEME_NAMES = ["midnight", "daylight"];
4
+ export const DEFAULT_THEME = "midnight";
5
+ /**
6
+ * Interior size of a rendered panel, in CSS pixels, measured from the shell
7
+ * below at the 1280x720 frame. The model designs against these so its output
8
+ * lands at roughly 1:1 instead of being scaled down into illegibility.
9
+ *
10
+ * Re-measure after changing the shell layout — `design.test.ts` checks these
11
+ * against a real Chrome render whenever Chrome is available.
12
+ */
13
+ export const MOCKUP_PANEL = { width: 605, height: 604 };
14
+ export const DIAGRAM_PANEL = { width: 1230, height: 626 };
15
+ /** Widths the model designs against before farq scales the result to fit. */
16
+ export const DEFAULT_MOCKUP_STAGE_WIDTH = 600;
17
+ export const DEFAULT_DIAGRAM_STAGE_WIDTH = 1200;
18
+ /** Bounds keep a stray stage_width from tanking legibility. */
19
+ const MOCKUP_STAGE_BOUNDS = { min: 320, max: 900 };
20
+ const DIAGRAM_STAGE_BOUNDS = { min: 480, max: 1600 };
21
+ const MIDNIGHT = {
22
+ canvas: "#0d1117",
23
+ canvasAlt: "#141b24",
24
+ surface: "#161d26",
25
+ surfaceAlt: "#1e2731",
26
+ surfaceSunken: "#0a0e13",
27
+ border: "#28323e",
28
+ borderStrong: "#3a4756",
29
+ text: "#e8eef5",
30
+ textMuted: "#9aa8b8",
31
+ textFaint: "#68778a",
32
+ accent: "#4dd0e1",
33
+ accentInk: "#06222a",
34
+ accentSoft: "rgba(77,208,225,0.14)",
35
+ positive: "#5ddc9a",
36
+ positiveSoft: "rgba(93,220,154,0.14)",
37
+ warning: "#f0b429",
38
+ danger: "#ff7a7a",
39
+ dangerSoft: "rgba(255,122,122,0.14)",
40
+ shadow: "0 18px 44px rgba(0,0,0,0.45)",
41
+ };
42
+ const DAYLIGHT = {
43
+ canvas: "#f2efe8",
44
+ canvasAlt: "#e8e4da",
45
+ surface: "#ffffff",
46
+ surfaceAlt: "#f7f5f0",
47
+ surfaceSunken: "#ebe7de",
48
+ border: "#dcd6c9",
49
+ borderStrong: "#bdb4a2",
50
+ text: "#161a1f",
51
+ textMuted: "#5b6673",
52
+ textFaint: "#8b95a2",
53
+ accent: "#0f7f8f",
54
+ accentInk: "#ffffff",
55
+ accentSoft: "rgba(15,127,143,0.12)",
56
+ positive: "#12734c",
57
+ positiveSoft: "rgba(18,115,76,0.12)",
58
+ warning: "#a2670a",
59
+ danger: "#c0392b",
60
+ dangerSoft: "rgba(192,57,43,0.12)",
61
+ shadow: "0 18px 40px rgba(28,24,16,0.14)",
62
+ };
63
+ const THEMES = {
64
+ midnight: MIDNIGHT,
65
+ daylight: DAYLIGHT,
66
+ };
67
+ const SYSTEM_SANS = '"Inter","SF Pro Text",-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif';
68
+ const SYSTEM_DISPLAY = '"Inter Tight","Inter","SF Pro Display",-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif';
69
+ const SYSTEM_MONO = '"JetBrains Mono","SF Mono",ui-monospace,SFMono-Regular,Menlo,Consolas,monospace';
70
+ export function isThemeName(value) {
71
+ return (typeof value === "string" && THEME_NAMES.includes(value));
72
+ }
73
+ export function resolveTheme(overrides = {}) {
74
+ const name = isThemeName(overrides.theme) ? overrides.theme : DEFAULT_THEME;
75
+ const tokens = { ...THEMES[name] };
76
+ if (overrides.accent && isSafeCssValue(overrides.accent)) {
77
+ tokens.accent = overrides.accent;
78
+ tokens.accentSoft = `color-mix(in srgb, ${overrides.accent} 16%, transparent)`;
79
+ }
80
+ return {
81
+ name,
82
+ tokens,
83
+ fontImport: overrides.fontImport,
84
+ fontSans: overrides.fontSans,
85
+ fontDisplay: overrides.fontDisplay,
86
+ };
87
+ }
88
+ /** Reject anything that could break out of a CSS declaration. */
89
+ function isSafeCssValue(value) {
90
+ return value.length <= 64 && !/[;{}<>]/.test(value);
91
+ }
92
+ function tokenCss(theme) {
93
+ const t = theme.tokens;
94
+ const sans = theme.fontSans ?? SYSTEM_SANS;
95
+ const display = theme.fontDisplay ?? sans;
96
+ return `:root{
97
+ --fq-canvas:${t.canvas};
98
+ --fq-canvas-alt:${t.canvasAlt};
99
+ --fq-surface:${t.surface};
100
+ --fq-surface-alt:${t.surfaceAlt};
101
+ --fq-surface-sunken:${t.surfaceSunken};
102
+ --fq-border:${t.border};
103
+ --fq-border-strong:${t.borderStrong};
104
+ --fq-text:${t.text};
105
+ --fq-text-muted:${t.textMuted};
106
+ --fq-text-faint:${t.textFaint};
107
+ --fq-accent:${t.accent};
108
+ --fq-accent-ink:${t.accentInk};
109
+ --fq-accent-soft:${t.accentSoft};
110
+ --fq-positive:${t.positive};
111
+ --fq-positive-soft:${t.positiveSoft};
112
+ --fq-warning:${t.warning};
113
+ --fq-danger:${t.danger};
114
+ --fq-danger-soft:${t.dangerSoft};
115
+ --fq-shadow:${t.shadow};
116
+ --fq-radius-sm:6px;
117
+ --fq-radius:10px;
118
+ --fq-radius-lg:16px;
119
+ --fq-space:8px;
120
+ --fq-font-sans:${sans};
121
+ --fq-font-display:${display};
122
+ --fq-font-mono:${SYSTEM_MONO};
123
+ }`;
124
+ }
125
+ /**
126
+ * The exact vocabulary the model is allowed to use. Keeping this in one place
127
+ * is what makes successive runs look like the same product.
128
+ */
129
+ export const STYLE_CONTRACT = `Design tokens (CSS custom properties already defined — use them, do not redefine them):
130
+ - Colors: var(--fq-canvas), var(--fq-canvas-alt), var(--fq-surface), var(--fq-surface-alt), var(--fq-surface-sunken), var(--fq-border), var(--fq-border-strong), var(--fq-text), var(--fq-text-muted), var(--fq-text-faint), var(--fq-accent), var(--fq-accent-ink) (text on accent), var(--fq-accent-soft), var(--fq-positive), var(--fq-positive-soft), var(--fq-warning), var(--fq-danger), var(--fq-danger-soft)
131
+ - Type: var(--fq-font-display) for headings, var(--fq-font-sans) for body, var(--fq-font-mono) for identifiers
132
+ - Shape: var(--fq-radius-sm), var(--fq-radius), var(--fq-radius-lg), var(--fq-shadow)
133
+
134
+ Style rules (hard):
135
+ - Every color you choose MUST be one of the variables above. The only exception is a color the diff states literally (e.g. the code sets #ff5722) — then use that literal value.
136
+ - Do not set font-family to anything except the three variables. Do not @import fonts. Do not reference external URLs, images, or scripts.
137
+ - Do not add gradients, glows, blurs, or decorative background art. The page frame, background, header and badge are supplied by farq — do not recreate them.
138
+ - Use exactly one accent color for emphasis; neutrals carry the rest.
139
+ - Legal pairings only: text on a surface is var(--fq-text) or var(--fq-text-muted); text on var(--fq-accent-soft) is var(--fq-accent); text on var(--fq-accent) is var(--fq-accent-ink). Never put muted or faint text on a colored fill.
140
+ - Spacing in multiples of 4px. Body text >= 13px.
141
+ - No raw code listings, no JSON dumps, no lorem ipsum. Placeholder copy must read like real product data.`;
142
+ export function clampStageWidth(input, kind = "mockup") {
143
+ const bounds = kind === "diagram" ? DIAGRAM_STAGE_BOUNDS : MOCKUP_STAGE_BOUNDS;
144
+ const fallback = kind === "diagram"
145
+ ? DEFAULT_DIAGRAM_STAGE_WIDTH
146
+ : DEFAULT_MOCKUP_STAGE_WIDTH;
147
+ const raw = input?.width;
148
+ if (typeof raw !== "number" || !Number.isFinite(raw) || raw <= 0) {
149
+ return { width: fallback };
150
+ }
151
+ return {
152
+ width: Math.min(bounds.max, Math.max(bounds.min, Math.round(raw))),
153
+ };
154
+ }
155
+ /**
156
+ * Measures each stage at its natural size and scales it to fit its viewport.
157
+ * This is why the prompts no longer need to threaten the model about overflow:
158
+ * content that is too tall shrinks instead of getting clipped.
159
+ *
160
+ * Short content is first stretched to the panel's aspect ratio so a mockup
161
+ * reads as a full screen rather than a floating card on empty background.
162
+ */
163
+ const FIT_SCRIPT = `(function(){
164
+ function fit(){
165
+ var stages=document.querySelectorAll('.fq-stage');
166
+ for(var i=0;i<stages.length;i++){
167
+ var s=stages[i],vp=s.parentElement;
168
+ if(!vp)continue;
169
+ s.style.transform='none';
170
+ var aw=vp.clientWidth,ah=vp.clientHeight;
171
+ if(aw>0&&ah>0){
172
+ var natural=s.offsetWidth||aw;
173
+ s.style.minHeight=(natural*(ah/aw))+'px';
174
+ }
175
+ var w=Math.max(s.scrollWidth,s.offsetWidth,1);
176
+ var h=Math.max(s.scrollHeight,s.offsetHeight,1);
177
+ var maxK=s.getAttribute('data-grow')==='1'?1.8:1;
178
+ var k=Math.min(aw/w,ah/h,maxK);
179
+ if(!isFinite(k)||k<=0)k=1;
180
+ s.style.transformOrigin='top left';
181
+ s.style.transform='translate('+((aw-w*k)/2)+'px,'+((ah-h*k)/2)+'px) scale('+k+')';
182
+ s.setAttribute('data-fit',aw+'x'+ah+'@'+k.toFixed(3));
183
+ }
184
+ }
185
+ fit();
186
+ document.addEventListener('DOMContentLoaded',fit);
187
+ window.addEventListener('load',fit);
188
+ if(document.fonts&&document.fonts.ready)document.fonts.ready.then(fit);
189
+ })();`;
190
+ function shellCss(theme) {
191
+ return `${theme.fontImport ? `@import url("${theme.fontImport}");\n` : ""}${tokenCss(theme)}
192
+ *,*::before,*::after{box-sizing:border-box}
193
+ html,body{margin:0;padding:0;width:${VIEWPORT_MAX_WIDTH}px;height:${VIEWPORT_MAX_HEIGHT}px;overflow:hidden}
194
+ body{background:var(--fq-canvas);color:var(--fq-text);font-family:var(--fq-font-sans);
195
+ font-size:14px;line-height:1.45;-webkit-font-smoothing:antialiased}
196
+ .fq-frame{display:flex;flex-direction:column;width:100%;height:100%;padding:20px 24px 24px}
197
+ .fq-head{display:flex;align-items:center;gap:12px;height:34px;flex:0 0 auto;margin-bottom:14px}
198
+ .fq-title{font-family:var(--fq-font-display);font-size:15px;font-weight:600;letter-spacing:-0.01em;
199
+ color:var(--fq-text);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
200
+ .fq-rule{flex:1 1 auto;height:1px;background:var(--fq-border)}
201
+ .fq-badge{flex:0 0 auto;font-size:10px;font-weight:600;letter-spacing:0.08em;text-transform:uppercase;
202
+ color:var(--fq-text-faint);border:1px solid var(--fq-border);border-radius:999px;padding:4px 10px}
203
+ .fq-body{flex:1 1 auto;min-height:0;display:grid;gap:18px}
204
+ .fq-body--split{grid-template-columns:1fr 1fr}
205
+ .fq-body--single{grid-template-columns:1fr}
206
+ .fq-panel{display:flex;flex-direction:column;min-height:0;min-width:0}
207
+ .fq-label{display:flex;align-items:center;gap:8px;flex:0 0 auto;margin-bottom:8px;
208
+ font-size:10px;font-weight:700;letter-spacing:0.1em;text-transform:uppercase;color:var(--fq-text-faint)}
209
+ .fq-label::after{content:"";flex:1 1 auto;height:1px;background:var(--fq-border)}
210
+ .fq-panel--after .fq-label{color:var(--fq-accent)}
211
+ .fq-panel--after .fq-label::after{background:var(--fq-accent-soft)}
212
+ .fq-viewport{position:relative;flex:1 1 auto;min-height:0;overflow:hidden;
213
+ background:var(--fq-canvas-alt);border:1px solid var(--fq-border);border-radius:var(--fq-radius);
214
+ box-shadow:var(--fq-shadow)}
215
+ .fq-stage{position:absolute;top:0;left:0;transform-origin:top left;display:flex;flex-direction:column}
216
+ .fq-stage>*{flex:1 1 auto}
217
+ .fq-root{background:var(--fq-surface);color:var(--fq-text);font-family:var(--fq-font-sans)}`;
218
+ }
219
+ function escapeHtml(value) {
220
+ return value
221
+ .replaceAll("&", "&amp;")
222
+ .replaceAll("<", "&lt;")
223
+ .replaceAll(">", "&gt;")
224
+ .replaceAll('"', "&quot;");
225
+ }
226
+ function document_(options) {
227
+ return `<!doctype html>
228
+ <html lang="en"><head><meta charset="utf-8" />
229
+ <title>${escapeHtml(options.title)}</title>
230
+ <style>${shellCss(options.theme)}</style>
231
+ <style>${options.extraCss}</style>
232
+ </head>
233
+ <body>
234
+ ${options.body}
235
+ <script>${FIT_SCRIPT}</script>
236
+ </body></html>`;
237
+ }
238
+ /**
239
+ * `grow` lets a diagram scale past 1:1 to fill the frame. Mockups stay capped
240
+ * at 1:1 so they read as a screenshot rather than a zoomed-in one.
241
+ */
242
+ function stage(className, width, body, grow = false) {
243
+ const growAttr = grow ? ' data-grow="1"' : "";
244
+ return `<div class="fq-viewport"><div class="fq-stage" style="width:${width}px"${growAttr}><div class="${className}">${body}</div></div></div>`;
245
+ }
246
+ export const PREVIEW_BADGE = "generated preview";
247
+ export function buildMockupDocument(input) {
248
+ const width = clampStageWidth({ width: input.stageWidth }, "mockup").width;
249
+ const extraCss = [
250
+ input.css ?? "",
251
+ scopeCss(input.beforeCss ?? "", ".fq-panel--before"),
252
+ scopeCss(input.afterCss ?? "", ".fq-panel--after"),
253
+ ]
254
+ .filter(Boolean)
255
+ .join("\n");
256
+ const body = `<div class="fq-frame">
257
+ <header class="fq-head">
258
+ <span class="fq-title">${escapeHtml(input.title)}</span>
259
+ <span class="fq-rule"></span>
260
+ <span class="fq-badge">${escapeHtml(input.badge ?? PREVIEW_BADGE)}</span>
261
+ </header>
262
+ <div class="fq-body fq-body--split">
263
+ <section class="fq-panel fq-panel--before">
264
+ <div class="fq-label">Before</div>
265
+ ${stage("fq-root", width, input.beforeBody)}
266
+ </section>
267
+ <section class="fq-panel fq-panel--after">
268
+ <div class="fq-label">After</div>
269
+ ${stage("fq-root", width, input.afterBody)}
270
+ </section>
271
+ </div>
272
+ </div>`;
273
+ return document_({
274
+ theme: input.theme,
275
+ title: input.title,
276
+ extraCss,
277
+ body,
278
+ });
279
+ }
280
+ export function buildDiagramDocument(input) {
281
+ const width = clampStageWidth({ width: input.stageWidth }, "diagram").width;
282
+ const body = `<div class="fq-frame">
283
+ <header class="fq-head">
284
+ <span class="fq-title">${escapeHtml(input.title)}</span>
285
+ <span class="fq-rule"></span>
286
+ <span class="fq-badge">${escapeHtml(input.badge ?? PREVIEW_BADGE)}</span>
287
+ </header>
288
+ <div class="fq-body fq-body--single">
289
+ <section class="fq-panel">
290
+ ${stage("fq-diagram", width, input.body, true)}
291
+ </section>
292
+ </div>
293
+ </div>`;
294
+ return document_({
295
+ theme: input.theme,
296
+ title: input.title,
297
+ extraCss: `.fq-diagram{display:flex;flex-direction:column;justify-content:center;background:transparent;color:var(--fq-text);font-family:var(--fq-font-sans);padding:8px}\n${input.css ?? ""}`,
298
+ body,
299
+ });
300
+ }
301
+ /** Frame for user-supplied before/after PNGs — same chrome as generated visuals. */
302
+ export function buildComposeDocument(input) {
303
+ const body = `<div class="fq-frame">
304
+ <header class="fq-head">
305
+ <span class="fq-title">${escapeHtml(input.title)}</span>
306
+ <span class="fq-rule"></span>
307
+ <span class="fq-badge">${escapeHtml(input.badge ?? "before / after")}</span>
308
+ </header>
309
+ <div class="fq-body fq-body--split">
310
+ <section class="fq-panel fq-panel--before">
311
+ <div class="fq-label">Before</div>
312
+ <div class="fq-viewport fq-shot"><img alt="Before" src="data:image/png;base64,${input.beforeBase64}" /></div>
313
+ </section>
314
+ <section class="fq-panel fq-panel--after">
315
+ <div class="fq-label">After</div>
316
+ <div class="fq-viewport fq-shot"><img alt="After" src="data:image/png;base64,${input.afterBase64}" /></div>
317
+ </section>
318
+ </div>
319
+ </div>`;
320
+ return document_({
321
+ theme: input.theme,
322
+ title: input.title,
323
+ extraCss: `.fq-shot{display:flex;align-items:center;justify-content:center;background:var(--fq-surface-sunken)}
324
+ .fq-shot img{max-width:100%;max-height:100%;width:auto;height:auto;display:block}`,
325
+ body,
326
+ });
327
+ }
@@ -1,5 +1,6 @@
1
1
  import type { ChangeSummary } from "../schema.js";
2
2
  import type { Provider } from "../providers/index.js";
3
+ import { type Theme } from "./design.js";
3
4
  export type DiagramResult = {
4
5
  feasible: true;
5
6
  htmlPath: string;
@@ -7,11 +8,18 @@ export type DiagramResult = {
7
8
  feasible: false;
8
9
  reason: string;
9
10
  };
11
+ export declare function buildDiagramPrompt(options: {
12
+ summary: ChangeSummary;
13
+ diffText: string;
14
+ }): string;
10
15
  export declare function generateDiagram(options: {
11
16
  provider: Provider;
12
17
  summary: ChangeSummary;
13
18
  diffText: string;
14
19
  outDir: string;
15
20
  model?: string;
21
+ theme?: Theme;
22
+ title?: string;
16
23
  log?: (msg: string) => void;
24
+ filePrefix?: string;
17
25
  }): Promise<DiagramResult>;
@@ -1,21 +1,30 @@
1
1
  import { mkdirSync, writeFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import { extractJson } from "../extract-json.js";
4
- export async function generateDiagram(options) {
5
- const prompt = `You create a conceptual before/after flowchart as one self-contained HTML document.
4
+ import { DEFAULT_DIAGRAM_STAGE_WIDTH, DIAGRAM_PANEL, STYLE_CONTRACT, buildDiagramDocument, resolveTheme, } from "./design.js";
5
+ export function buildDiagramPrompt(options) {
6
+ return `You explain a code change as a conceptual before/after flowchart.
7
+
8
+ This is the fallback when there is no pixel UI to show (API, logic, config, docs). farq supplies the page frame, header, badge and automatic scale-to-fit — you supply only the diagram body and its CSS.
9
+
10
+ The diagram renders into a ${DIAGRAM_PANEL.width}x${DIAGRAM_PANEL.height}px box. Fill it: this is a wide, short canvas, so lay the two columns out side by side and let them breathe.
6
11
 
7
- This is the fallback visual when a pixel UI mockup is not appropriate (API, logic, docs, config, etc.). Prefer a small flowchart or labeled before/after boxes that explain the change at a glance.
12
+ Produce:
13
+ - "body": an HTML fragment with two labelled columns (Before / After) of boxes and arrows. No <html>, <head>, <body>, <style> or <script> tags.
14
+ - "css": the stylesheet for that fragment.
15
+ - "stage_width": the pixel width you designed against (${DEFAULT_DIAGRAM_STAGE_WIDTH} is a good default).
8
16
 
9
- Rules:
10
- - Pure HTML/CSS, no libraries, no external requests, system fonts.
11
- - Two labeled columns (Before / After) with simple boxes/arrows (flowchart style).
12
- - Concept-level only: field/endpoint/step names OK. NO code, NO syntax, NO JSON dumps, NO walls of text.
13
- - Include a small corner badge text: generated preview
14
- - If you truly cannot produce a faithful conceptual visual from the diff alone, return {"feasible": false, "reason": "..."}. Prefer a simple flowchart over declining.
17
+ Content rules:
18
+ - Concept level only: step, field and endpoint names are fine. No code, no syntax, no JSON dumps, no paragraphs.
19
+ - At most 5 boxes per column, at most 8 words per box.
20
+ - Make the delta obvious: mark what is new or changed with the accent color, and keep unchanged steps neutral.
21
+ - Draw arrows with CSS borders or plain characters. No SVG sprites, no external assets.
22
+
23
+ ${STYLE_CONTRACT}
15
24
 
16
25
  Return JSON only:
17
- {"feasible": true, "html": "..."}
18
- or {"feasible": false, "reason": "..."}
26
+ {"feasible":true,"css":"...","body":"...","stage_width":${DEFAULT_DIAGRAM_STAGE_WIDTH}}
27
+ or {"feasible":false,"reason":"..."} — prefer a simple flowchart over declining.
19
28
 
20
29
  Change summary:
21
30
  ${JSON.stringify(options.summary, null, 2)}
@@ -23,14 +32,27 @@ ${JSON.stringify(options.summary, null, 2)}
23
32
  Diff:
24
33
  ${options.diffText}
25
34
  `;
35
+ }
36
+ export async function generateDiagram(options) {
37
+ const prompt = buildDiagramPrompt({
38
+ summary: options.summary,
39
+ diffText: options.diffText,
40
+ });
26
41
  const raw = await options.provider.complete(prompt, { model: options.model });
27
42
  const json = extractJson(raw);
28
- if (!json.feasible || !json.html) {
43
+ if (!json.feasible || !json.body) {
29
44
  options.log?.(json.reason ?? "diagram not feasible");
30
45
  return { feasible: false, reason: json.reason ?? "not feasible" };
31
46
  }
47
+ const html = buildDiagramDocument({
48
+ theme: options.theme ?? resolveTheme(),
49
+ title: options.title ?? options.summary.headline,
50
+ css: json.css,
51
+ body: json.body,
52
+ stageWidth: json.stage_width,
53
+ });
32
54
  mkdirSync(options.outDir, { recursive: true });
33
- const htmlPath = join(options.outDir, "diagram.html");
34
- writeFileSync(htmlPath, json.html, "utf8");
55
+ const htmlPath = join(options.outDir, `${options.filePrefix ?? ""}diagram.html`);
56
+ writeFileSync(htmlPath, html, "utf8");
35
57
  return { feasible: true, htmlPath };
36
58
  }
@@ -1,26 +1,31 @@
1
1
  import type { ChangeSummary } from "../schema.js";
2
2
  import type { Provider } from "../providers/index.js";
3
+ import { type Theme } from "./design.js";
3
4
  export type MockupResult = {
4
5
  feasible: true;
5
- beforePath: string;
6
- afterPath: string;
7
- viewport?: {
8
- width: number;
9
- height: number;
10
- };
6
+ htmlPath: string;
11
7
  } | {
12
8
  feasible: false;
13
9
  reason: string;
14
10
  };
11
+ export type MockupFile = {
12
+ path: string;
13
+ before: string;
14
+ after: string;
15
+ };
16
+ export declare function buildMockupPrompt(options: {
17
+ summary: ChangeSummary;
18
+ files: MockupFile[];
19
+ }): string;
15
20
  export declare function generateMockup(options: {
16
21
  provider: Provider;
17
22
  summary: ChangeSummary;
18
- files: Array<{
19
- path: string;
20
- before: string;
21
- after: string;
22
- }>;
23
+ files: MockupFile[];
23
24
  outDir: string;
24
25
  model?: string;
26
+ theme?: Theme;
27
+ title?: string;
25
28
  log?: (msg: string) => void;
29
+ /** Prefix for the written HTML file (e.g. visual-1-). */
30
+ filePrefix?: string;
26
31
  }): Promise<MockupResult>;
@@ -1,19 +1,33 @@
1
1
  import { mkdirSync, writeFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import { extractJson } from "../extract-json.js";
4
- export async function generateMockup(options) {
5
- const prompt = `You create faithful before/after HTML mockups from a git diff.
4
+ import { DEFAULT_MOCKUP_STAGE_WIDTH, MOCKUP_PANEL, STYLE_CONTRACT, buildMockupDocument, resolveTheme, } from "./design.js";
5
+ export function buildMockupPrompt(options) {
6
+ return `You turn a git diff into a faithful before/after UI mockup.
7
+
8
+ farq supplies the page frame: background, header, Before/After labels, the badge, and automatic scale-to-fit. You supply only the two panel interiors. Never recreate the frame.
9
+
10
+ Each panel renders into a ${MOCKUP_PANEL.width}x${MOCKUP_PANEL.height}px box. Compose for that box: show the changed component and just enough surrounding context to place it. This is a close-up, not a whole application screen — no site nav, no sidebars, no footer, unless the diff changes those. Anything you design wider than the box gets scaled down and becomes hard to read.
11
+
12
+ Produce:
13
+ - "before_body" / "after_body": HTML fragments for the two states. No <html>, <head>, <body>, <style> or <script> tags.
14
+ - "css": one stylesheet shared by both fragments.
15
+ - "before_css" / "after_css" (optional): rules for one state only. farq scopes them for you, so keep the same class names in both fragments and put the differences here.
16
+ - "stage_width": the pixel width you designed against. Default ${DEFAULT_MOCKUP_STAGE_WIDTH}; use 390 for a phone view. Never exceed ${MOCKUP_PANEL.width}.
6
17
 
7
- Rules:
8
- - The diff contains exact before/after markup/styles. Emit two fully self-contained HTML documents (inline CSS, no external requests, system fonts) that render this exact markup with these exact styles.
9
- - Stub only the minimum: container width, placeholder text where dynamic props appear (realistic neutral placeholders).
10
- - Do not invent UI that is not present in the code.
11
- - Never show raw code listings.
12
- - If you cannot render faithfully, return {"feasible": false, "reason": "..."}.
18
+ Fidelity rules:
19
+ - Render the markup and styles the diff actually shows. Do not invent UI that is not in the code.
20
+ - Stub only what is dynamic: realistic placeholder copy for props and data.
21
+ - The only visible difference between the panels must be the change itself. Do not redesign anything else.
22
+ - Whatever the diff adds must be easy to see: give it at least the weight of the content around it, never the faintest token on the panel. Do not add callouts, arrows or highlight boxes to point at it.
23
+ - Never render raw code, diff text, or file paths as content.
24
+ - If the diff contains no renderable UI, decline instead of guessing.
25
+
26
+ ${STYLE_CONTRACT}
13
27
 
14
28
  Return JSON only:
15
- {"feasible": true, "before_html": "...", "after_html": "...", "viewport": {"width":1280,"height":900}}
16
- or {"feasible": false, "reason": "..."}
29
+ {"feasible":true,"css":"...","before_css":"...","after_css":"...","before_body":"...","after_body":"...","stage_width":${DEFAULT_MOCKUP_STAGE_WIDTH}}
30
+ or {"feasible":false,"reason":"..."}
17
31
 
18
32
  Change summary context:
19
33
  ${JSON.stringify(options.summary, null, 2)}
@@ -23,24 +37,33 @@ ${options.files
23
37
  .map((f) => `### ${f.path}\n--- BEFORE ---\n${f.before}\n--- AFTER ---\n${f.after}`)
24
38
  .join("\n\n")}
25
39
  `;
40
+ }
41
+ export async function generateMockup(options) {
42
+ const prompt = buildMockupPrompt({
43
+ summary: options.summary,
44
+ files: options.files,
45
+ });
26
46
  const raw = await options.provider.complete(prompt, { model: options.model });
27
47
  const json = extractJson(raw);
28
48
  if (!json.feasible) {
29
49
  options.log?.(json.reason ?? "mockup not feasible");
30
50
  return { feasible: false, reason: json.reason ?? "not feasible" };
31
51
  }
32
- if (!json.before_html || !json.after_html) {
33
- return { feasible: false, reason: "missing before_html/after_html" };
52
+ if (!json.before_body || !json.after_body) {
53
+ return { feasible: false, reason: "missing before_body/after_body" };
34
54
  }
55
+ const html = buildMockupDocument({
56
+ theme: options.theme ?? resolveTheme(),
57
+ title: options.title ?? options.summary.headline,
58
+ css: json.css,
59
+ beforeCss: json.before_css,
60
+ afterCss: json.after_css,
61
+ beforeBody: json.before_body,
62
+ afterBody: json.after_body,
63
+ stageWidth: json.stage_width,
64
+ });
35
65
  mkdirSync(options.outDir, { recursive: true });
36
- const beforePath = join(options.outDir, "before.html");
37
- const afterPath = join(options.outDir, "after.html");
38
- writeFileSync(beforePath, json.before_html, "utf8");
39
- writeFileSync(afterPath, json.after_html, "utf8");
40
- return {
41
- feasible: true,
42
- beforePath,
43
- afterPath,
44
- viewport: json.viewport,
45
- };
66
+ const htmlPath = join(options.outDir, `${options.filePrefix ?? ""}mockup.html`);
67
+ writeFileSync(htmlPath, html, "utf8");
68
+ return { feasible: true, htmlPath };
46
69
  }
@@ -1,12 +1,40 @@
1
1
  import type { ChangeSummary } from "../schema.js";
2
2
  import type { Provider } from "../providers/index.js";
3
- import type { GatherDiffResult } from "../git.js";
3
+ import type { GatherDiffResult, VisualFile } from "../git.js";
4
+ import { type Theme } from "./design.js";
5
+ export declare const DEFAULT_VISUAL_CONCURRENCY = 3;
6
+ export type VisualImage = {
7
+ path: string;
8
+ title: string;
9
+ };
4
10
  export type VisualPipelineResult = {
5
11
  imagePath: string | null;
6
12
  images: string[];
13
+ imageMeta: VisualImage[];
7
14
  softDegraded: boolean;
8
15
  warning?: string;
9
16
  };
17
+ /** Structured events so the CLI can show what is actually happening. */
18
+ export type VisualProgress = {
19
+ kind: "topics";
20
+ total: number;
21
+ } | {
22
+ kind: "topic-start";
23
+ title: string;
24
+ mode: "mockup" | "diagram";
25
+ } | {
26
+ kind: "topic-fallback";
27
+ title: string;
28
+ } | {
29
+ kind: "topic-shot";
30
+ title: string;
31
+ } | {
32
+ kind: "topic-done";
33
+ title: string;
34
+ ok: boolean;
35
+ done: number;
36
+ total: number;
37
+ };
10
38
  export type VisualPipelineOptions = {
11
39
  cwd?: string;
12
40
  outDir?: string;
@@ -17,7 +45,13 @@ export type VisualPipelineOptions = {
17
45
  noImages?: boolean;
18
46
  before?: string;
19
47
  after?: string;
48
+ theme?: Theme;
49
+ maxTopics?: number;
50
+ concurrency?: number;
51
+ /** Pre-fetched before/after file contents; avoids git work in the hot path. */
52
+ visualFiles?: Promise<VisualFile[]> | VisualFile[];
20
53
  verbose?: boolean;
21
54
  log?: (msg: string) => void;
55
+ onProgress?: (event: VisualProgress) => void;
22
56
  };
23
57
  export declare function runVisualPipeline(options: VisualPipelineOptions): Promise<VisualPipelineResult>;