@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,204 @@
1
+ import { extractJson } from "../extract-json.js";
2
+ export const MAX_VISUAL_TOPICS = 5;
3
+ /**
4
+ * Topic grouping, cheapest source first:
5
+ * 1. visual_topics returned by the summarize call (free — no extra request)
6
+ * 2. a dedicated cheap-model call
7
+ * 3. file-overlap union-find
8
+ * Same-theme items (one feature across many files) → one topic.
9
+ * Truly unrelated domains → separate topics.
10
+ */
11
+ export async function clusterVisualTopics(summary, options) {
12
+ const limit = normalizeLimit(options?.maxTopics);
13
+ const items = summary.items;
14
+ if (items.length === 0)
15
+ return [];
16
+ if (items.length === 1) {
17
+ return [
18
+ {
19
+ id: 1,
20
+ title: items[0].title.slice(0, 80),
21
+ items,
22
+ files: uniqueFiles(items),
23
+ },
24
+ ];
25
+ }
26
+ const fromSummary = topicsFromIntentJson(summary, {
27
+ topics: summary.visual_topics,
28
+ });
29
+ if (fromSummary) {
30
+ options?.log?.(`visual topics (from summary): ${fromSummary.length} — ${fromSummary
31
+ .map((t) => t.title)
32
+ .join("; ")}`);
33
+ return capTopics(fromSummary, limit);
34
+ }
35
+ if (options?.provider) {
36
+ try {
37
+ const ai = await clusterByIntent(summary, options.provider, options.model);
38
+ if (ai) {
39
+ options.log?.(`visual topics (intent): ${ai.length} — ${ai.map((t) => t.title).join("; ")}`);
40
+ return capTopics(ai, limit);
41
+ }
42
+ options.log?.("visual topic intent clustering failed; using file overlap");
43
+ }
44
+ catch (err) {
45
+ const msg = err instanceof Error ? err.message : String(err);
46
+ options.log?.(`visual topic intent clustering error: ${msg}`);
47
+ }
48
+ }
49
+ return capTopics(clusterByFileOverlap(summary), limit);
50
+ }
51
+ function normalizeLimit(value) {
52
+ if (typeof value !== "number" || !Number.isFinite(value) || value < 1) {
53
+ return MAX_VISUAL_TOPICS;
54
+ }
55
+ return Math.min(MAX_VISUAL_TOPICS, Math.floor(value));
56
+ }
57
+ /** File-overlap union-find (fallback). Exported for tests. */
58
+ export function clusterByFileOverlap(summary) {
59
+ const items = summary.items;
60
+ if (items.length === 0)
61
+ return [];
62
+ const parent = items.map((_, i) => i);
63
+ const find = (i) => parent[i] === i ? i : (parent[i] = find(parent[i]));
64
+ const union = (a, b) => {
65
+ parent[find(a)] = find(b);
66
+ };
67
+ for (let i = 0; i < items.length; i++) {
68
+ for (let j = i + 1; j < items.length; j++) {
69
+ const a = items[i];
70
+ const b = items[j];
71
+ if (a.files.length > 0 &&
72
+ b.files.length > 0 &&
73
+ filesOverlap(a.files, b.files)) {
74
+ union(i, j);
75
+ }
76
+ }
77
+ }
78
+ const groups = new Map();
79
+ for (let i = 0; i < items.length; i++) {
80
+ const root = find(i);
81
+ const list = groups.get(root) ?? [];
82
+ list.push(items[i]);
83
+ groups.set(root, list);
84
+ }
85
+ const clusters = [...groups.values()].map((groupItems, idx) => ({
86
+ id: idx + 1,
87
+ title: topicTitle(groupItems),
88
+ items: groupItems,
89
+ files: uniqueFiles(groupItems),
90
+ }));
91
+ return capTopics(clusters, MAX_VISUAL_TOPICS);
92
+ }
93
+ /** Merge the smallest topics until at most `limit` remain. */
94
+ export function capTopics(topics, limit = MAX_VISUAL_TOPICS) {
95
+ let clusters = topics;
96
+ while (clusters.length > limit) {
97
+ clusters = [...clusters].sort((a, b) => a.items.length - b.items.length || a.files.length - b.files.length);
98
+ const smallest = clusters[0];
99
+ const next = clusters[1];
100
+ const merged = [...smallest.items, ...next.items];
101
+ clusters = [
102
+ {
103
+ id: 0,
104
+ title: topicTitle(merged),
105
+ items: merged,
106
+ files: uniqueFiles(merged),
107
+ },
108
+ ...clusters.slice(2),
109
+ ];
110
+ }
111
+ return finalizeTopics(clusters);
112
+ }
113
+ /** Build topics from model JSON; null if invalid. Exported for tests. */
114
+ export function topicsFromIntentJson(summary, raw) {
115
+ const items = summary.items;
116
+ if (!raw || typeof raw !== "object")
117
+ return null;
118
+ const topics = raw.topics;
119
+ if (!Array.isArray(topics) || topics.length === 0)
120
+ return null;
121
+ if (topics.length > MAX_VISUAL_TOPICS)
122
+ return null;
123
+ const used = new Set();
124
+ const built = [];
125
+ for (const t of topics) {
126
+ if (!t || typeof t !== "object")
127
+ return null;
128
+ const title = String(t.title ?? "").trim();
129
+ const indices = t.item_indices;
130
+ if (!title || !Array.isArray(indices) || indices.length === 0)
131
+ return null;
132
+ const groupItems = [];
133
+ for (const idx of indices) {
134
+ if (typeof idx !== "number" || !Number.isInteger(idx))
135
+ return null;
136
+ if (idx < 0 || idx >= items.length || used.has(idx))
137
+ return null;
138
+ used.add(idx);
139
+ groupItems.push(items[idx]);
140
+ }
141
+ built.push({
142
+ id: built.length + 1,
143
+ title: title.slice(0, 80),
144
+ items: groupItems,
145
+ files: uniqueFiles(groupItems),
146
+ });
147
+ }
148
+ // Every item must appear exactly once.
149
+ if (used.size !== items.length)
150
+ return null;
151
+ return finalizeTopics(built);
152
+ }
153
+ async function clusterByIntent(summary, provider, model) {
154
+ const listed = summary.items
155
+ .map((item, i) => `${i}. [${item.category}] ${item.title} — ${item.description} (files: ${item.files.join(", ") || "none"})`)
156
+ .join("\n");
157
+ const prompt = `You group change-summary items into visual topics for before/after diagrams.
158
+
159
+ Rules:
160
+ - If items all advance the SAME headline/feature/story, return EXACTLY ONE topic with every item index — even across many files, docs, prompts, tests, and upload plumbing.
161
+ - Example: clustering + pipeline + PR render + asset upload + README + prompt polish for "multi-image visuals" → one topic.
162
+ - Only create multiple topics when a reviewer would treat them as separate product changes (e.g. a UI redesign AND an unrelated billing API).
163
+ - Prompt/style tweaks, tests, and docs for a feature stay in that feature's topic.
164
+ - Maximum ${MAX_VISUAL_TOPICS} topics. Prefer fewer; one is best when unsure.
165
+ - Every item index must appear in exactly one topic. Use 0-based indices.
166
+ - Topic titles: short, human, <=80 chars.
167
+
168
+ Return JSON only:
169
+ {"topics":[{"title":"...","item_indices":[0,1,2]}]}
170
+
171
+ Headline: ${summary.headline}
172
+ Overview: ${summary.overview}
173
+
174
+ Items:
175
+ ${listed}
176
+ `;
177
+ const raw = await provider.complete(prompt, { model });
178
+ const json = extractJson(raw);
179
+ return topicsFromIntentJson(summary, json);
180
+ }
181
+ function finalizeTopics(clusters) {
182
+ return clusters.map((c, i) => ({
183
+ ...c,
184
+ id: i + 1,
185
+ title: c.title.slice(0, 80),
186
+ }));
187
+ }
188
+ function topicTitle(items) {
189
+ if (items.length === 1)
190
+ return items[0].title;
191
+ if (items.length === 2)
192
+ return `${items[0].title}; ${items[1].title}`;
193
+ return `${items[0].title} (+${items.length - 1} more)`;
194
+ }
195
+ function uniqueFiles(items) {
196
+ return [...new Set(items.flatMap((i) => i.files))];
197
+ }
198
+ function filesOverlap(a, b) {
199
+ const set = new Set(a.map(normalizePath));
200
+ return b.some((f) => set.has(normalizePath(f)));
201
+ }
202
+ function normalizePath(p) {
203
+ return p.replaceAll("\\", "/").replace(/^\.\//, "");
204
+ }
@@ -1,16 +1,23 @@
1
1
  import { resolveChrome, ChromeError } from "./chrome.js";
2
+ import { type Theme } from "./design.js";
2
3
  export type ComposeOptions = {
3
4
  cwd?: string;
4
5
  outDir?: string;
5
6
  beforePath: string;
6
7
  afterPath: string;
7
- badge?: "generated preview" | "before / after";
8
+ badge?: string;
9
+ title?: string;
10
+ theme?: Theme;
8
11
  chromePath?: string;
12
+ /** Output PNG basename (default before-after.png). */
13
+ outFileName?: string;
9
14
  };
10
15
  export declare function buildComposeHtml(options: {
11
16
  beforeBase64: string;
12
17
  afterBase64: string;
13
18
  badge: string;
19
+ title?: string;
20
+ theme?: Theme;
14
21
  }): string;
15
22
  export declare function composeBeforeAfter(options: ComposeOptions): Promise<string>;
16
23
  export { ChromeError, resolveChrome };
@@ -3,33 +3,16 @@ import { join, resolve } from "node:path";
3
3
  import { pathToFileURL } from "node:url";
4
4
  import { defaultOutDir } from "../paths.js";
5
5
  import { resolveChrome, screenshotHtml, ChromeError } from "./chrome.js";
6
+ import { buildComposeDocument, resolveTheme } from "./design.js";
7
+ import { DEFAULT_VIEWPORT } from "./viewport.js";
6
8
  export function buildComposeHtml(options) {
7
- const { beforeBase64, afterBase64, badge } = options;
8
- return `<!doctype html>
9
- <html><head><meta charset="utf-8" />
10
- <style>
11
- html,body{margin:0;background:#1b1d21;color:#e8e8e8;font-family:system-ui,sans-serif}
12
- .wrap{padding:24px}
13
- .badge{position:fixed;top:12px;right:12px;font-size:11px;letter-spacing:.04em;text-transform:uppercase;
14
- background:#2a2e35;border:1px solid #3a3f48;padding:4px 8px;border-radius:4px;opacity:.9}
15
- .grid{display:grid;grid-template-columns:1fr 1fr;gap:16px}
16
- h2{margin:0 0 10px;font-size:14px;font-weight:600;color:#b9c0c9}
17
- img{width:100%;height:auto;background:#0f1114;border:1px solid #2f343d;border-radius:6px;display:block}
18
- </style></head>
19
- <body>
20
- <div class="badge">${escapeHtml(badge)}</div>
21
- <div class="wrap"><div class="grid">
22
- <section><h2>Before</h2><img alt="Before" src="data:image/png;base64,${beforeBase64}" /></section>
23
- <section><h2>After</h2><img alt="After" src="data:image/png;base64,${afterBase64}" /></section>
24
- </div></div>
25
- </body></html>`;
26
- }
27
- function escapeHtml(s) {
28
- return s
29
- .replaceAll("&", "&amp;")
30
- .replaceAll("<", "&lt;")
31
- .replaceAll(">", "&gt;")
32
- .replaceAll('"', "&quot;");
9
+ return buildComposeDocument({
10
+ theme: options.theme ?? resolveTheme(),
11
+ title: options.title ?? "Before / after",
12
+ beforeBase64: options.beforeBase64,
13
+ afterBase64: options.afterBase64,
14
+ badge: options.badge,
15
+ });
33
16
  }
34
17
  export async function composeBeforeAfter(options) {
35
18
  const cwd = options.cwd ?? process.cwd();
@@ -37,22 +20,24 @@ export async function composeBeforeAfter(options) {
37
20
  mkdirSync(outDir, { recursive: true });
38
21
  const before = readFileSync(options.beforePath);
39
22
  const after = readFileSync(options.afterPath);
40
- const badge = options.badge ?? "generated preview";
41
23
  const html = buildComposeHtml({
42
24
  beforeBase64: before.toString("base64"),
43
25
  afterBase64: after.toString("base64"),
44
- badge,
26
+ badge: options.badge ?? "before / after",
27
+ title: options.title,
28
+ theme: options.theme,
45
29
  });
46
- const composePath = join(outDir, "compose.html");
30
+ const stem = (options.outFileName ?? "before-after.png").replace(/\.png$/i, "");
31
+ const composePath = join(outDir, `${stem}-compose.html`);
47
32
  writeFileSync(composePath, html, "utf8");
48
- const outPng = join(outDir, "before-after.png");
33
+ const outPng = join(outDir, `${stem}.png`);
49
34
  const chromePath = options.chromePath ?? resolveChrome();
50
35
  await screenshotHtml({
51
36
  chromePath,
52
37
  url: pathToFileURL(composePath).href,
53
38
  outPath: outPng,
54
- width: 1400,
55
- height: 900,
39
+ width: DEFAULT_VIEWPORT.width,
40
+ height: DEFAULT_VIEWPORT.height,
56
41
  });
57
42
  return outPng;
58
43
  }
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Prefix every top-level selector in a stylesheet so model-authored rules can
3
+ * only affect one before/after panel. Runs at build time on our side, so the
4
+ * output works on any Chrome (no CSS nesting or @scope support required).
5
+ */
6
+ export declare function scopeCss(css: string, prefix: string): string;
@@ -0,0 +1,162 @@
1
+ /**
2
+ * Prefix every top-level selector in a stylesheet so model-authored rules can
3
+ * only affect one before/after panel. Runs at build time on our side, so the
4
+ * output works on any Chrome (no CSS nesting or @scope support required).
5
+ */
6
+ export function scopeCss(css, prefix) {
7
+ if (!css || !css.trim())
8
+ return "";
9
+ return transformBlock(css, prefix);
10
+ }
11
+ function transformBlock(css, prefix) {
12
+ let out = "";
13
+ let prelude = "";
14
+ let i = 0;
15
+ while (i < css.length) {
16
+ const ch = css[i];
17
+ if (ch === "/" && css[i + 1] === "*") {
18
+ const end = css.indexOf("*/", i + 2);
19
+ i = end === -1 ? css.length : end + 2;
20
+ continue;
21
+ }
22
+ if (ch === '"' || ch === "'") {
23
+ const end = skipString(css, i);
24
+ prelude += css.slice(i, end);
25
+ i = end;
26
+ continue;
27
+ }
28
+ if (ch === "{") {
29
+ const { body, next } = readBlock(css, i);
30
+ out += renderRule(prelude.trim(), body, prefix);
31
+ prelude = "";
32
+ i = next;
33
+ continue;
34
+ }
35
+ if (ch === ";") {
36
+ // Statement at-rule such as @import / @charset — pass through untouched.
37
+ const stmt = prelude.trim();
38
+ if (stmt)
39
+ out += `${stmt};`;
40
+ prelude = "";
41
+ i++;
42
+ continue;
43
+ }
44
+ if (ch === "}") {
45
+ // Stray closer from malformed input; drop it rather than corrupt output.
46
+ i++;
47
+ continue;
48
+ }
49
+ prelude += ch;
50
+ i++;
51
+ }
52
+ return out;
53
+ }
54
+ /** At-rules whose body is a nested list of rules, not declarations. */
55
+ const NESTED_AT_RULES = new Set(["media", "supports", "container", "layer"]);
56
+ function renderRule(prelude, body, prefix) {
57
+ if (!prelude)
58
+ return "";
59
+ if (prelude.startsWith("@")) {
60
+ const name = prelude.slice(1).split(/[\s({]/)[0].toLowerCase();
61
+ if (NESTED_AT_RULES.has(name)) {
62
+ return `${prelude}{${transformBlock(body, prefix)}}`;
63
+ }
64
+ // @keyframes / @font-face / @property / @page carry no page selectors.
65
+ return `${prelude}{${body}}`;
66
+ }
67
+ return `${prefixSelectorList(prelude, prefix)}{${body}}`;
68
+ }
69
+ function prefixSelectorList(list, prefix) {
70
+ return splitTopLevel(list, ",")
71
+ .map((sel) => prefixSelector(sel.trim(), prefix))
72
+ .filter(Boolean)
73
+ .join(",");
74
+ }
75
+ const ROOT_SELECTOR = /^(html|body|:root)\b/i;
76
+ function prefixSelector(selector, prefix) {
77
+ if (!selector)
78
+ return "";
79
+ // Root-level selectors would escape the panel — retarget them at the panel.
80
+ if (ROOT_SELECTOR.test(selector)) {
81
+ const rest = selector.replace(ROOT_SELECTOR, "").trim();
82
+ if (!rest || rest === ">")
83
+ return prefix;
84
+ return `${prefix} ${rest}`.replace(/\s+/g, " ");
85
+ }
86
+ if (selector.startsWith("&")) {
87
+ return `${prefix}${selector.slice(1)}`;
88
+ }
89
+ return `${prefix} ${selector}`;
90
+ }
91
+ /** Split on a separator that sits outside strings, parens and brackets. */
92
+ function splitTopLevel(input, separator) {
93
+ const parts = [];
94
+ let depth = 0;
95
+ let current = "";
96
+ let i = 0;
97
+ while (i < input.length) {
98
+ const ch = input[i];
99
+ if (ch === '"' || ch === "'") {
100
+ const end = skipString(input, i);
101
+ current += input.slice(i, end);
102
+ i = end;
103
+ continue;
104
+ }
105
+ if (ch === "(" || ch === "[")
106
+ depth++;
107
+ else if (ch === ")" || ch === "]")
108
+ depth = Math.max(0, depth - 1);
109
+ if (ch === separator && depth === 0) {
110
+ parts.push(current);
111
+ current = "";
112
+ }
113
+ else {
114
+ current += ch;
115
+ }
116
+ i++;
117
+ }
118
+ parts.push(current);
119
+ return parts;
120
+ }
121
+ /** Read a `{...}` block starting at openIdx; returns inner body and index after `}`. */
122
+ function readBlock(css, openIdx) {
123
+ let depth = 0;
124
+ let i = openIdx;
125
+ while (i < css.length) {
126
+ const ch = css[i];
127
+ if (ch === "/" && css[i + 1] === "*") {
128
+ const end = css.indexOf("*/", i + 2);
129
+ i = end === -1 ? css.length : end + 2;
130
+ continue;
131
+ }
132
+ if (ch === '"' || ch === "'") {
133
+ i = skipString(css, i);
134
+ continue;
135
+ }
136
+ if (ch === "{")
137
+ depth++;
138
+ else if (ch === "}") {
139
+ depth--;
140
+ if (depth === 0) {
141
+ return { body: css.slice(openIdx + 1, i), next: i + 1 };
142
+ }
143
+ }
144
+ i++;
145
+ }
146
+ return { body: css.slice(openIdx + 1), next: css.length };
147
+ }
148
+ function skipString(input, start) {
149
+ const quote = input[start];
150
+ let i = start + 1;
151
+ while (i < input.length) {
152
+ const ch = input[i];
153
+ if (ch === "\\") {
154
+ i += 2;
155
+ continue;
156
+ }
157
+ if (ch === quote)
158
+ return i + 1;
159
+ i++;
160
+ }
161
+ return i;
162
+ }
@@ -0,0 +1,103 @@
1
+ export declare const THEME_NAMES: readonly ["midnight", "daylight"];
2
+ export type ThemeName = (typeof THEME_NAMES)[number];
3
+ export declare const DEFAULT_THEME: ThemeName;
4
+ /**
5
+ * Interior size of a rendered panel, in CSS pixels, measured from the shell
6
+ * below at the 1280x720 frame. The model designs against these so its output
7
+ * lands at roughly 1:1 instead of being scaled down into illegibility.
8
+ *
9
+ * Re-measure after changing the shell layout — `design.test.ts` checks these
10
+ * against a real Chrome render whenever Chrome is available.
11
+ */
12
+ export declare const MOCKUP_PANEL: {
13
+ readonly width: 605;
14
+ readonly height: 604;
15
+ };
16
+ export declare const DIAGRAM_PANEL: {
17
+ readonly width: 1230;
18
+ readonly height: 626;
19
+ };
20
+ /** Widths the model designs against before farq scales the result to fit. */
21
+ export declare const DEFAULT_MOCKUP_STAGE_WIDTH = 600;
22
+ export declare const DEFAULT_DIAGRAM_STAGE_WIDTH = 1200;
23
+ export type ThemeTokens = {
24
+ canvas: string;
25
+ canvasAlt: string;
26
+ surface: string;
27
+ surfaceAlt: string;
28
+ surfaceSunken: string;
29
+ border: string;
30
+ borderStrong: string;
31
+ text: string;
32
+ textMuted: string;
33
+ textFaint: string;
34
+ accent: string;
35
+ accentInk: string;
36
+ accentSoft: string;
37
+ positive: string;
38
+ positiveSoft: string;
39
+ warning: string;
40
+ danger: string;
41
+ dangerSoft: string;
42
+ shadow: string;
43
+ };
44
+ export type Theme = {
45
+ name: ThemeName;
46
+ tokens: ThemeTokens;
47
+ /** Optional webfont import; omitted by default so renders stay offline-safe. */
48
+ fontImport?: string;
49
+ fontSans?: string;
50
+ fontDisplay?: string;
51
+ };
52
+ export type ThemeOverrides = {
53
+ theme?: string;
54
+ accent?: string;
55
+ fontImport?: string;
56
+ fontSans?: string;
57
+ fontDisplay?: string;
58
+ };
59
+ export declare function isThemeName(value: unknown): value is ThemeName;
60
+ export declare function resolveTheme(overrides?: ThemeOverrides): Theme;
61
+ /**
62
+ * The exact vocabulary the model is allowed to use. Keeping this in one place
63
+ * is what makes successive runs look like the same product.
64
+ */
65
+ export declare const STYLE_CONTRACT = "Design tokens (CSS custom properties already defined \u2014 use them, do not redefine them):\n- 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)\n- Type: var(--fq-font-display) for headings, var(--fq-font-sans) for body, var(--fq-font-mono) for identifiers\n- Shape: var(--fq-radius-sm), var(--fq-radius), var(--fq-radius-lg), var(--fq-shadow)\n\nStyle rules (hard):\n- 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) \u2014 then use that literal value.\n- Do not set font-family to anything except the three variables. Do not @import fonts. Do not reference external URLs, images, or scripts.\n- Do not add gradients, glows, blurs, or decorative background art. The page frame, background, header and badge are supplied by farq \u2014 do not recreate them.\n- Use exactly one accent color for emphasis; neutrals carry the rest.\n- 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.\n- Spacing in multiples of 4px. Body text >= 13px.\n- No raw code listings, no JSON dumps, no lorem ipsum. Placeholder copy must read like real product data.";
66
+ export type StageKind = "mockup" | "diagram";
67
+ export type StageSize = {
68
+ width: number;
69
+ };
70
+ export declare function clampStageWidth(input?: {
71
+ width?: number;
72
+ } | null, kind?: StageKind): StageSize;
73
+ export declare const PREVIEW_BADGE = "generated preview";
74
+ export type MockupDocumentInput = {
75
+ theme: Theme;
76
+ title: string;
77
+ css?: string;
78
+ beforeCss?: string;
79
+ afterCss?: string;
80
+ beforeBody: string;
81
+ afterBody: string;
82
+ stageWidth?: number;
83
+ badge?: string;
84
+ };
85
+ export declare function buildMockupDocument(input: MockupDocumentInput): string;
86
+ export type DiagramDocumentInput = {
87
+ theme: Theme;
88
+ title: string;
89
+ css?: string;
90
+ body: string;
91
+ stageWidth?: number;
92
+ badge?: string;
93
+ };
94
+ export declare function buildDiagramDocument(input: DiagramDocumentInput): string;
95
+ export type ComposeDocumentInput = {
96
+ theme: Theme;
97
+ title: string;
98
+ beforeBase64: string;
99
+ afterBase64: string;
100
+ badge?: string;
101
+ };
102
+ /** Frame for user-supplied before/after PNGs — same chrome as generated visuals. */
103
+ export declare function buildComposeDocument(input: ComposeDocumentInput): string;