@ahmedalbarghouti/farq 0.0.2 → 0.0.3

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/README.md CHANGED
@@ -5,13 +5,13 @@
5
5
  One command. No servers. No API keys. Auth stays with your local `claude` or `opencode` CLI.
6
6
 
7
7
  ```bash
8
- npx @ahmedalbarghouti/farq@0.0.2 pr # title + markdown body (+ image when feasible)
8
+ npx @ahmedalbarghouti/farq@0.0.3 pr # title + markdown body (+ image when feasible)
9
9
  npx farq slack # Slack mrkdwn daily update
10
10
  npx farq json # structured JSON
11
11
  npx farq pr --open # fill PR template + create with gh
12
12
  ```
13
13
 
14
- > **0.0.2** — PATH/`gh`/Chrome resolution, hosted-image PR body cleanup, orphaned asset prune. CLI surface (`pr` / `slack` / `json`) is the stable bit.
14
+ > **0.0.3** — PATH/`gh`/Chrome resolution, hosted-image PR body cleanup, orphaned asset prune. CLI surface (`pr` / `slack` / `json`) is the stable bit.
15
15
 
16
16
  ## Install
17
17
 
@@ -23,7 +23,7 @@ farq --help
23
23
  Or run without installing:
24
24
 
25
25
  ```bash
26
- npx @ahmedalbarghouti/farq@0.0.2 --help
26
+ npx @ahmedalbarghouti/farq@0.0.3 --help
27
27
  ```
28
28
 
29
29
  Generated images land in a **user cache directory outside the repo** by default (no `.gitignore` change needed). Use `--out .farq` if you want them in-tree.
@@ -76,7 +76,7 @@ farq pr --open
76
76
 
77
77
  1. Reads `.github` PR template if present and fills known sections
78
78
  2. Infers title style from recent merged PR titles when `gh` works
79
- 3. Best-effort uploads the composed image as a prerelease asset and embeds the URL
79
+ 3. Best-effort uploads composed image(s) as prerelease assets and embeds the URL(s)
80
80
  4. Creates the PR with `gh pr create`, or **updates** title/body with `gh pr edit` if one already exists for the branch, then opens it in the browser
81
81
 
82
82
  On the default branch (`main` / `master` / repo default), `--open` skips create and still prints the artifact. Titles are capped at GitHub's **256** character limit (overflow goes into the body).
@@ -91,7 +91,7 @@ Validated change summary plus an `images` array of produced file paths.
91
91
 
92
92
  ## Images (honesty policy)
93
93
 
94
- - Visuals come from the diff only. UI markup gets a mockup attempt; everything else gets a small concept flowchart/diagram. If the model cannot produce a faithful preview, **no image** is produced (still exit 0).
94
+ - Visuals come from the diff only. A cheap model groups summary items into **up to 5** visual topics by intent (same feature → one image; truly unrelated domains → separate). File-overlap is the fallback if that grouping fails. UI markup gets a mockup attempt; everything else gets a small concept flowchart/diagram. Screenshots are capped at **1280×720** so content must fit the frame (no tall cut-off pages). If a topic is infeasible, it is skipped (still exit 0).
95
95
  - Generated compositions include a small **generated preview** badge.
96
96
  - Diagrams stay conceptual — no code dumps.
97
97
  - Missing Chrome / visual failure **soft-degrades** to text-only with a stderr warning (exit 0), unless you passed `--before`/`--after` (then hard-fail).
@@ -141,22 +141,11 @@ If both `claude` and `opencode` are installed and nothing is configured, farq us
141
141
 
142
142
  Progress → **stderr**. Artifact → **stdout**.
143
143
 
144
- ## Publishing notes (maintainers)
145
-
146
- Tag-triggered publish via GitHub Actions (npm provenance):
147
-
148
- ```bash
149
- git tag v0.0.2
150
- git push origin v0.0.2
151
- ```
152
-
153
- Requires repo secret `NPM_TOKEN`. Package name: `@ahmedalbarghouti/farq`.
154
-
155
144
  ## Repository ops
156
145
 
157
146
  - **CI** runs on every PR and on pushes to `main` (test + build + `--help` smoke).
158
147
  - **`main` is protected** — changes go through PRs; the `CI / test` check must pass.
159
- - **Releases** are tag-triggered: create `v0.0.2` (or later) after merge; the publish workflow needs a repo secret named `NPM_TOKEN` (npm automation/granular token with publish rights).
148
+ - **Releases** are tag-triggered: create `v0.0.3` (or later) after merge; the publish workflow needs a repo secret named `NPM_TOKEN` (npm automation/granular token with publish rights).
160
149
  - **`--open` image assets:** uploaded as `farq-assets-<branch>` prereleases; farq deletes orphaned tags whose branch no longer has an open PR.
161
150
 
162
151
  ## Roadmap
package/dist/index.js CHANGED
@@ -87,6 +87,7 @@ async function run(type, opts) {
87
87
  : undefined;
88
88
  let imagePath = null;
89
89
  let images = [];
90
+ let imageMeta = [];
90
91
  if (imagesEnabled || (opts.before && opts.after)) {
91
92
  const visSpin = ui.stage("visual", provider.name);
92
93
  try {
@@ -105,6 +106,7 @@ async function run(type, opts) {
105
106
  });
106
107
  imagePath = visual.imagePath;
107
108
  images = visual.images;
109
+ imageMeta = visual.imageMeta;
108
110
  if (visual.warning) {
109
111
  visSpin.succeed("visuals skipped");
110
112
  ui.note(visual.warning);
@@ -126,10 +128,10 @@ async function run(type, opts) {
126
128
  ui.note(`${msg} (continuing without image)`);
127
129
  }
128
130
  }
129
- const relImage = imagePath != null ? displayImageRef(cwd, imagePath) : null;
131
+ const prImages = (imageMeta.length > 0 ? imageMeta : images.map((p) => ({ path: p, title: "before / after" }))).map((img) => ({ path: displayImageRef(cwd, img.path), title: img.title }));
130
132
  let artifact = "";
131
133
  if (type === "pr") {
132
- artifact = renderPr({ summary, imagePath: relImage });
134
+ artifact = renderPr({ summary, images: prImages });
133
135
  }
134
136
  else if (type === "slack") {
135
137
  artifact = renderSlack(summary);
@@ -147,6 +149,8 @@ async function run(type, opts) {
147
149
  ? artifact.split("\n\n").slice(1).join("\n\n")
148
150
  : artifact,
149
151
  imagePath,
152
+ imagePaths: images,
153
+ imageTitles: imageMeta.map((m) => m.title),
150
154
  });
151
155
  if (result.skipped) {
152
156
  openSpin.succeed(result.reason);
@@ -186,7 +190,7 @@ async function main() {
186
190
  program
187
191
  .name("farq")
188
192
  .description("Turn git branch changes into paste-ready PR/Slack updates with optional before/after visuals")
189
- .version("0.0.2");
193
+ .version("0.0.3");
190
194
  addShared(program
191
195
  .command("pr", { isDefault: true })
192
196
  .description("PR title + body markdown")
package/dist/open-pr.d.ts CHANGED
@@ -10,6 +10,7 @@ export type OpenPrResult = {
10
10
  title: string;
11
11
  body: string;
12
12
  imageUrl?: string | null;
13
+ imageUrls?: string[];
13
14
  };
14
15
  /** Prerelease tag used to host a branch's composed PNG. */
15
16
  export declare function assetTagForBranch(branch: string): string;
@@ -26,11 +27,15 @@ export declare function openPullRequest(options: {
26
27
  summary: ChangeSummary;
27
28
  bodyMarkdown: string;
28
29
  imagePath?: string | null;
30
+ imagePaths?: string[];
31
+ imageTitles?: string[];
29
32
  }): Promise<OpenPrResult>;
30
33
  /**
31
34
  * Delete `farq-assets-*` prereleases whose branch no longer has an open PR.
32
35
  * Always keeps `keepTag` (the just-uploaded asset).
33
36
  */
34
37
  export declare function pruneOrphanedFarqAssets(cwd: string, keepTag: string): Promise<string[]>;
35
- /** Best-effort: prerelease asset URL for the PNG. */
38
+ /** Best-effort: prerelease asset URL for one PNG. */
36
39
  export declare function uploadPrImage(cwd: string, imagePath: string, branch: string): Promise<string>;
40
+ /** Best-effort: upload many PNGs to one prerelease; URLs in input order. */
41
+ export declare function uploadPrImages(cwd: string, imagePaths: string[], branch: string): Promise<string[]>;
package/dist/open-pr.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { mkdtempSync, writeFileSync, rmSync } from "node:fs";
2
- import { join } from "node:path";
2
+ import { basename, join } from "node:path";
3
3
  import { tmpdir } from "node:os";
4
4
  import { execa } from "execa";
5
5
  import { fillPrTemplate, findPrTemplate } from "./template.js";
@@ -104,14 +104,19 @@ export async function openPullRequest(options) {
104
104
  reason: `Already on ${defaultBranch} — skipping PR create`,
105
105
  };
106
106
  }
107
- let imageUrl = null;
107
+ const paths = options.imagePaths && options.imagePaths.length > 0
108
+ ? options.imagePaths
109
+ : options.imagePath
110
+ ? [options.imagePath]
111
+ : [];
112
+ let imageUrls = [];
108
113
  let warning;
109
- if (options.imagePath) {
114
+ if (paths.length > 0) {
110
115
  try {
111
- imageUrl = await uploadPrImage(cwd, options.imagePath, branch);
116
+ imageUrls = await uploadPrImages(cwd, paths, branch);
112
117
  }
113
118
  catch (err) {
114
- warning = `Image upload failed — attach manually: ${options.imagePath} (${err instanceof Error ? err.message : String(err)})`;
119
+ warning = `Image upload failed — attach manually: ${paths.join(", ")} (${err instanceof Error ? err.message : String(err)})`;
115
120
  }
116
121
  }
117
122
  const template = findPrTemplate(cwd);
@@ -119,7 +124,11 @@ export async function openPullRequest(options) {
119
124
  template,
120
125
  summary: options.summary,
121
126
  bodyMarkdown: options.bodyMarkdown,
122
- imageUrl,
127
+ imageUrl: imageUrls[0] ?? null,
128
+ images: imageUrls.map((url, i) => ({
129
+ url,
130
+ title: options.imageTitles?.[i],
131
+ })),
123
132
  });
124
133
  const dir = mkdtempSync(join(tmpdir(), "farq-pr-"));
125
134
  const bodyFile = join(dir, "body.md");
@@ -149,7 +158,8 @@ export async function openPullRequest(options) {
149
158
  warning,
150
159
  title,
151
160
  body,
152
- imageUrl,
161
+ imageUrl: imageUrls[0] ?? null,
162
+ imageUrls,
153
163
  };
154
164
  }
155
165
  const created = await gh(cwd, [
@@ -173,7 +183,8 @@ export async function openPullRequest(options) {
173
183
  warning,
174
184
  title,
175
185
  body,
176
- imageUrl,
186
+ imageUrl: imageUrls[0] ?? null,
187
+ imageUrls,
177
188
  };
178
189
  }
179
190
  catch (err) {
@@ -225,8 +236,18 @@ export async function pruneOrphanedFarqAssets(cwd, keepTag) {
225
236
  }
226
237
  return deleted;
227
238
  }
228
- /** Best-effort: prerelease asset URL for the PNG. */
239
+ /** Best-effort: prerelease asset URL for one PNG. */
229
240
  export async function uploadPrImage(cwd, imagePath, branch) {
241
+ const urls = await uploadPrImages(cwd, [imagePath], branch);
242
+ const url = urls[0];
243
+ if (!url)
244
+ throw new Error("no asset URL returned");
245
+ return url;
246
+ }
247
+ /** Best-effort: upload many PNGs to one prerelease; URLs in input order. */
248
+ export async function uploadPrImages(cwd, imagePaths, branch) {
249
+ if (imagePaths.length === 0)
250
+ return [];
230
251
  const tag = assetTagForBranch(branch);
231
252
  try {
232
253
  await gh(cwd, ["release", "delete", tag, "--yes", "--cleanup-tag"], {
@@ -240,7 +261,7 @@ export async function uploadPrImage(cwd, imagePath, branch) {
240
261
  "release",
241
262
  "create",
242
263
  tag,
243
- imagePath,
264
+ ...imagePaths,
244
265
  "--title",
245
266
  `farq assets (${branch})`,
246
267
  "--notes",
@@ -253,12 +274,17 @@ export async function uploadPrImage(cwd, imagePath, branch) {
253
274
  tag,
254
275
  "--json",
255
276
  "assets",
256
- "--jq",
257
- ".assets[0].url",
258
277
  ]);
259
- const url = stdout.trim();
260
- if (!url)
261
- throw new Error("no asset URL returned");
278
+ const parsed = JSON.parse(stdout);
279
+ const assets = parsed.assets ?? [];
280
+ const urls = imagePaths.map((p) => {
281
+ const name = basename(p);
282
+ const hit = assets.find((a) => a.name === name);
283
+ if (!hit?.url) {
284
+ throw new Error(`no asset URL for ${name}`);
285
+ }
286
+ return hit.url;
287
+ });
262
288
  await pruneOrphanedFarqAssets(cwd, tag);
263
- return url;
289
+ return urls;
264
290
  }
@@ -1,7 +1,13 @@
1
1
  import type { ChangeSummary } from "../schema.js";
2
+ export type PrImage = {
3
+ path: string;
4
+ title?: string;
5
+ };
2
6
  export type RenderPrOptions = {
3
7
  summary: ChangeSummary;
8
+ /** @deprecated prefer images */
4
9
  imagePath?: string | null;
5
10
  imageAlt?: string;
11
+ images?: PrImage[];
6
12
  };
7
13
  export declare function renderPr(options: RenderPrOptions): string;
package/dist/render/pr.js CHANGED
@@ -18,13 +18,27 @@ export function renderPr(options) {
18
18
  out.push("");
19
19
  out.push(overflow);
20
20
  }
21
- if (options.imagePath) {
22
- out.push("");
23
- out.push("### Before / After");
24
- out.push("");
25
- const alt = options.imageAlt ?? "before / after";
26
- out.push(`![${alt}](${options.imagePath})`);
27
- if (!isHostedImageUrl(options.imagePath)) {
21
+ const images = normalizeImages(options);
22
+ if (images.length > 0) {
23
+ const anyLocal = images.some((img) => !isHostedImageUrl(img.path));
24
+ if (images.length === 1) {
25
+ out.push("");
26
+ out.push("### Before / After");
27
+ out.push("");
28
+ const alt = images[0].title ?? options.imageAlt ?? "before / after";
29
+ out.push(`![${alt}](${images[0].path})`);
30
+ }
31
+ else {
32
+ out.push("");
33
+ out.push("### Visuals");
34
+ for (const img of images) {
35
+ out.push("");
36
+ out.push(`#### ${img.title ?? "Before / After"}`);
37
+ out.push("");
38
+ out.push(`![${img.title ?? "before / after"}](${img.path})`);
39
+ }
40
+ }
41
+ if (anyLocal) {
28
42
  out.push("");
29
43
  out.push(LOCAL_IMAGE_NOTE);
30
44
  }
@@ -49,3 +63,11 @@ export function renderPr(options) {
49
63
  }
50
64
  return `${title}\n\n${out.join("\n").trim()}\n`;
51
65
  }
66
+ function normalizeImages(options) {
67
+ if (options.images && options.images.length > 0)
68
+ return options.images;
69
+ if (options.imagePath) {
70
+ return [{ path: options.imagePath, title: options.imageAlt }];
71
+ }
72
+ return [];
73
+ }
@@ -1,10 +1,16 @@
1
1
  import type { ChangeSummary } from "./schema.js";
2
2
  export declare function findPrTemplate(cwd: string): string | null;
3
+ export type TemplateImage = {
4
+ url: string;
5
+ title?: string;
6
+ };
3
7
  export declare function fillPrTemplate(options: {
4
8
  template: string | null;
5
9
  summary: ChangeSummary;
6
10
  bodyMarkdown: string;
11
+ /** @deprecated prefer images */
7
12
  imageUrl?: string | null;
13
+ images?: TemplateImage[];
8
14
  }): {
9
15
  title: string;
10
16
  body: string;
package/dist/template.js CHANGED
@@ -19,23 +19,16 @@ export function findPrTemplate(cwd) {
19
19
  }
20
20
  export function fillPrTemplate(options) {
21
21
  const { title, overflow } = truncateTitle(options.summary.headline, GITHUB_TITLE_MAX);
22
+ const images = normalizeTemplateImages(options);
22
23
  let body = options.template?.trim() || "";
23
24
  if (!body) {
24
25
  body = options.bodyMarkdown;
25
26
  if (overflow)
26
27
  body = overflow + "\n\n" + body;
27
- if (options.imageUrl) {
28
- // Swap any non-http(s) image target (cache path, .farq/, etc.).
29
- const re = /!\[([^\]]*)\]\((?!https?:\/\/)[^)]+\)/;
30
- if (re.test(body)) {
31
- body = body.replace(re, "![$1](" + options.imageUrl + ")");
32
- }
33
- else {
34
- body =
35
- "### Before / After\n\n![before / after](" +
36
- options.imageUrl +
37
- ")\n\n" +
38
- body;
28
+ if (images.length > 0) {
29
+ body = replaceLocalImages(body, images);
30
+ if (!hasMarkdownImage(body)) {
31
+ body = renderImageMarkdown(images) + "\n\n" + body;
39
32
  }
40
33
  body = stripLocalImageNote(body);
41
34
  }
@@ -51,20 +44,48 @@ export function fillPrTemplate(options) {
51
44
  body = fillSection(body, /test\s*plan|testing|how to test/i, testPlan);
52
45
  if (overflow)
53
46
  body = overflow + "\n\n" + body;
54
- if (options.imageUrl) {
55
- if (/before\s*\/\s*after|screenshots?/i.test(body)) {
56
- body = fillSection(body, /before\s*\/\s*after|screenshots?/i, "![before / after](" + options.imageUrl + ")");
47
+ if (images.length > 0) {
48
+ const md = renderImageMarkdown(images);
49
+ if (/before\s*\/\s*after|screenshots?|visuals?/i.test(body)) {
50
+ body = fillSection(body, /before\s*\/\s*after|screenshots?|visuals?/i, md.replace(/^###[^\n]+\n+/, "").trim());
57
51
  }
58
52
  else {
59
- body +=
60
- "\n\n### Before / After\n\n![before / after](" +
61
- options.imageUrl +
62
- ")\n";
53
+ body += "\n\n" + md + "\n";
63
54
  }
64
55
  body = stripLocalImageNote(body);
65
56
  }
66
57
  return { title, body };
67
58
  }
59
+ function normalizeTemplateImages(options) {
60
+ if (options.images && options.images.length > 0)
61
+ return options.images;
62
+ if (options.imageUrl)
63
+ return [{ url: options.imageUrl }];
64
+ return [];
65
+ }
66
+ function hasMarkdownImage(body) {
67
+ return /!\[[^\]]*\]\([^)]+\)/.test(body);
68
+ }
69
+ function replaceLocalImages(body, images) {
70
+ let i = 0;
71
+ return body.replace(/!\[([^\]]*)\]\((?!https?:\/\/)[^)]+\)/g, (_m, alt) => {
72
+ const img = images[Math.min(i, images.length - 1)];
73
+ i += 1;
74
+ return `![${alt || img.title || "before / after"}](${img.url})`;
75
+ });
76
+ }
77
+ function renderImageMarkdown(images) {
78
+ if (images.length === 1) {
79
+ const alt = images[0].title ?? "before / after";
80
+ return `### Before / After\n\n![${alt}](${images[0].url})`;
81
+ }
82
+ const parts = ["### Visuals"];
83
+ for (const img of images) {
84
+ const alt = img.title ?? "before / after";
85
+ parts.push("", `#### ${alt}`, "", `![${alt}](${img.url})`);
86
+ }
87
+ return parts.join("\n");
88
+ }
68
89
  function fillSection(template, heading, content) {
69
90
  const lines = template.split("\n");
70
91
  let inSection = false;
@@ -2,6 +2,7 @@ import { execa } from "execa";
2
2
  import { platform } from "node:os";
3
3
  import { join } from "node:path";
4
4
  import { resolveExecutable, ToolNotFoundError, } from "../tools.js";
5
+ import { clampViewport } from "./viewport.js";
5
6
  const TIMEOUT_MS = 30_000;
6
7
  export class ChromeError extends Error {
7
8
  constructor(message) {
@@ -55,8 +56,10 @@ export function resolveChrome(env = process.env) {
55
56
  }
56
57
  export async function screenshotHtml(options) {
57
58
  const chrome = options.chromePath ?? resolveChrome();
58
- const width = options.width ?? 1280;
59
- const height = options.height ?? 900;
59
+ const { width, height } = clampViewport({
60
+ width: options.width,
61
+ height: options.height,
62
+ });
60
63
  try {
61
64
  await execa(chrome, [
62
65
  "--headless=new",
@@ -0,0 +1,23 @@
1
+ import type { ChangeItem, ChangeSummary } from "../schema.js";
2
+ import type { Provider } from "../providers/index.js";
3
+ export declare const MAX_VISUAL_TOPICS = 5;
4
+ export type VisualTopic = {
5
+ id: number;
6
+ title: string;
7
+ items: ChangeItem[];
8
+ files: string[];
9
+ };
10
+ /**
11
+ * Prefer intent clustering via the cheap model; fall back to file-overlap.
12
+ * Same-theme items (one feature across many files) → one topic.
13
+ * Truly unrelated domains → separate topics (cap 5).
14
+ */
15
+ export declare function clusterVisualTopics(summary: ChangeSummary, options?: {
16
+ provider?: Provider;
17
+ model?: string;
18
+ log?: (msg: string) => void;
19
+ }): Promise<VisualTopic[]>;
20
+ /** File-overlap union-find (fallback). Exported for tests. */
21
+ export declare function clusterByFileOverlap(summary: ChangeSummary): VisualTopic[];
22
+ /** Build topics from model JSON; null if invalid. Exported for tests. */
23
+ export declare function topicsFromIntentJson(summary: ChangeSummary, raw: unknown): VisualTopic[] | null;
@@ -0,0 +1,180 @@
1
+ import { extractJson } from "../extract-json.js";
2
+ export const MAX_VISUAL_TOPICS = 5;
3
+ /**
4
+ * Prefer intent clustering via the cheap model; fall back to file-overlap.
5
+ * Same-theme items (one feature across many files) → one topic.
6
+ * Truly unrelated domains → separate topics (cap 5).
7
+ */
8
+ export async function clusterVisualTopics(summary, options) {
9
+ const items = summary.items;
10
+ if (items.length === 0)
11
+ return [];
12
+ if (items.length === 1) {
13
+ return [
14
+ {
15
+ id: 1,
16
+ title: items[0].title.slice(0, 80),
17
+ items,
18
+ files: uniqueFiles(items),
19
+ },
20
+ ];
21
+ }
22
+ if (options?.provider) {
23
+ try {
24
+ const ai = await clusterByIntent(summary, options.provider, options.model);
25
+ if (ai) {
26
+ options.log?.(`visual topics (intent): ${ai.length} — ${ai.map((t) => t.title).join("; ")}`);
27
+ return ai;
28
+ }
29
+ options.log?.("visual topic intent clustering failed; using file overlap");
30
+ }
31
+ catch (err) {
32
+ const msg = err instanceof Error ? err.message : String(err);
33
+ options.log?.(`visual topic intent clustering error: ${msg}`);
34
+ }
35
+ }
36
+ return clusterByFileOverlap(summary);
37
+ }
38
+ /** File-overlap union-find (fallback). Exported for tests. */
39
+ export function clusterByFileOverlap(summary) {
40
+ const items = summary.items;
41
+ if (items.length === 0)
42
+ return [];
43
+ const parent = items.map((_, i) => i);
44
+ const find = (i) => parent[i] === i ? i : (parent[i] = find(parent[i]));
45
+ const union = (a, b) => {
46
+ parent[find(a)] = find(b);
47
+ };
48
+ for (let i = 0; i < items.length; i++) {
49
+ for (let j = i + 1; j < items.length; j++) {
50
+ const a = items[i];
51
+ const b = items[j];
52
+ if (a.files.length > 0 &&
53
+ b.files.length > 0 &&
54
+ filesOverlap(a.files, b.files)) {
55
+ union(i, j);
56
+ }
57
+ }
58
+ }
59
+ const groups = new Map();
60
+ for (let i = 0; i < items.length; i++) {
61
+ const root = find(i);
62
+ const list = groups.get(root) ?? [];
63
+ list.push(items[i]);
64
+ groups.set(root, list);
65
+ }
66
+ let clusters = [...groups.values()].map((groupItems, idx) => ({
67
+ id: idx + 1,
68
+ title: topicTitle(groupItems),
69
+ items: groupItems,
70
+ files: uniqueFiles(groupItems),
71
+ }));
72
+ while (clusters.length > MAX_VISUAL_TOPICS) {
73
+ clusters.sort((a, b) => a.items.length - b.items.length || a.files.length - b.files.length);
74
+ const smallest = clusters[0];
75
+ const next = clusters[1];
76
+ const rest = clusters.slice(2);
77
+ clusters = [
78
+ {
79
+ id: 0,
80
+ title: topicTitle([...smallest.items, ...next.items]),
81
+ items: [...smallest.items, ...next.items],
82
+ files: uniqueFiles([...smallest.items, ...next.items]),
83
+ },
84
+ ...rest,
85
+ ];
86
+ }
87
+ return finalizeTopics(clusters);
88
+ }
89
+ /** Build topics from model JSON; null if invalid. Exported for tests. */
90
+ export function topicsFromIntentJson(summary, raw) {
91
+ const items = summary.items;
92
+ if (!raw || typeof raw !== "object")
93
+ return null;
94
+ const topics = raw.topics;
95
+ if (!Array.isArray(topics) || topics.length === 0)
96
+ return null;
97
+ if (topics.length > MAX_VISUAL_TOPICS)
98
+ return null;
99
+ const used = new Set();
100
+ const built = [];
101
+ for (const t of topics) {
102
+ if (!t || typeof t !== "object")
103
+ return null;
104
+ const title = String(t.title ?? "").trim();
105
+ const indices = t.item_indices;
106
+ if (!title || !Array.isArray(indices) || indices.length === 0)
107
+ return null;
108
+ const groupItems = [];
109
+ for (const idx of indices) {
110
+ if (typeof idx !== "number" || !Number.isInteger(idx))
111
+ return null;
112
+ if (idx < 0 || idx >= items.length || used.has(idx))
113
+ return null;
114
+ used.add(idx);
115
+ groupItems.push(items[idx]);
116
+ }
117
+ built.push({
118
+ id: built.length + 1,
119
+ title: title.slice(0, 80),
120
+ items: groupItems,
121
+ files: uniqueFiles(groupItems),
122
+ });
123
+ }
124
+ // Every item must appear exactly once.
125
+ if (used.size !== items.length)
126
+ return null;
127
+ return finalizeTopics(built);
128
+ }
129
+ async function clusterByIntent(summary, provider, model) {
130
+ const listed = summary.items
131
+ .map((item, i) => `${i}. [${item.category}] ${item.title} — ${item.description} (files: ${item.files.join(", ") || "none"})`)
132
+ .join("\n");
133
+ const prompt = `You group change-summary items into visual topics for before/after diagrams.
134
+
135
+ Rules:
136
+ - 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.
137
+ - Example: clustering + pipeline + PR render + asset upload + README + prompt polish for "multi-image visuals" → one topic.
138
+ - Only create multiple topics when a reviewer would treat them as separate product changes (e.g. a UI redesign AND an unrelated billing API).
139
+ - Prompt/style tweaks, tests, and docs for a feature stay in that feature's topic.
140
+ - Maximum ${MAX_VISUAL_TOPICS} topics. Prefer fewer; one is best when unsure.
141
+ - Every item index must appear in exactly one topic. Use 0-based indices.
142
+ - Topic titles: short, human, <=80 chars.
143
+
144
+ Return JSON only:
145
+ {"topics":[{"title":"...","item_indices":[0,1,2]}]}
146
+
147
+ Headline: ${summary.headline}
148
+ Overview: ${summary.overview}
149
+
150
+ Items:
151
+ ${listed}
152
+ `;
153
+ const raw = await provider.complete(prompt, { model });
154
+ const json = extractJson(raw);
155
+ return topicsFromIntentJson(summary, json);
156
+ }
157
+ function finalizeTopics(clusters) {
158
+ return clusters.map((c, i) => ({
159
+ ...c,
160
+ id: i + 1,
161
+ title: c.title.slice(0, 80),
162
+ }));
163
+ }
164
+ function topicTitle(items) {
165
+ if (items.length === 1)
166
+ return items[0].title;
167
+ if (items.length === 2)
168
+ return `${items[0].title}; ${items[1].title}`;
169
+ return `${items[0].title} (+${items.length - 1} more)`;
170
+ }
171
+ function uniqueFiles(items) {
172
+ return [...new Set(items.flatMap((i) => i.files))];
173
+ }
174
+ function filesOverlap(a, b) {
175
+ const set = new Set(a.map(normalizePath));
176
+ return b.some((f) => set.has(normalizePath(f)));
177
+ }
178
+ function normalizePath(p) {
179
+ return p.replaceAll("\\", "/").replace(/^\.\//, "");
180
+ }
@@ -6,6 +6,8 @@ export type ComposeOptions = {
6
6
  afterPath: string;
7
7
  badge?: "generated preview" | "before / after";
8
8
  chromePath?: string;
9
+ /** Output PNG basename (default before-after.png). */
10
+ outFileName?: string;
9
11
  };
10
12
  export declare function buildComposeHtml(options: {
11
13
  beforeBase64: string;
@@ -3,6 +3,7 @@ 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 { DEFAULT_VIEWPORT } from "./viewport.js";
6
7
  export function buildComposeHtml(options) {
7
8
  const { beforeBase64, afterBase64, badge } = options;
8
9
  return `<!doctype html>
@@ -43,16 +44,17 @@ export async function composeBeforeAfter(options) {
43
44
  afterBase64: after.toString("base64"),
44
45
  badge,
45
46
  });
46
- const composePath = join(outDir, "compose.html");
47
+ const stem = (options.outFileName ?? "before-after.png").replace(/\.png$/i, "");
48
+ const composePath = join(outDir, `${stem}-compose.html`);
47
49
  writeFileSync(composePath, html, "utf8");
48
- const outPng = join(outDir, "before-after.png");
50
+ const outPng = join(outDir, `${stem}.png`);
49
51
  const chromePath = options.chromePath ?? resolveChrome();
50
52
  await screenshotHtml({
51
53
  chromePath,
52
54
  url: pathToFileURL(composePath).href,
53
55
  outPath: outPng,
54
- width: 1400,
55
- height: 900,
56
+ width: DEFAULT_VIEWPORT.width,
57
+ height: DEFAULT_VIEWPORT.height,
56
58
  });
57
59
  return outPng;
58
60
  }
@@ -14,4 +14,5 @@ export declare function generateDiagram(options: {
14
14
  outDir: string;
15
15
  model?: string;
16
16
  log?: (msg: string) => void;
17
+ filePrefix?: string;
17
18
  }): Promise<DiagramResult>;
@@ -1,15 +1,18 @@
1
1
  import { mkdirSync, writeFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import { extractJson } from "../extract-json.js";
4
+ import { VIEWPORT_MAX_HEIGHT, VIEWPORT_MAX_WIDTH } from "./viewport.js";
4
5
  export async function generateDiagram(options) {
5
6
  const prompt = `You create a conceptual before/after flowchart as one self-contained HTML document.
6
7
 
7
8
  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.
8
9
 
9
10
  Rules:
10
- - Pure HTML/CSS, no libraries, no external requests, system fonts.
11
+ - Pure HTML/CSS, no libraries, no external requests except optional @import fonts from fonts.googleapis.com or fonts.bunny.net.
11
12
  - Two labeled columns (Before / After) with simple boxes/arrows (flowchart style).
12
13
  - Concept-level only: field/endpoint/step names OK. NO code, NO syntax, NO JSON dumps, NO walls of text.
14
+ - FRAME LIMIT (hard): the screenshot is exactly ${VIEWPORT_MAX_WIDTH}x${VIEWPORT_MAX_HEIGHT}px. Set html,body { margin:0; width:100%; height:100%; overflow:hidden }. The entire flowchart must fit in that frame with no scrolling and no clipped boxes or footer text. Prefer fewer/shorter steps over a tall diagram.
15
+ - Visual craft: one clear composition, expressive typography, subtle atmospheric background (gradient or soft pattern), high contrast. Avoid purple-gradient clichés, neon glow, and cluttered sticker UI.
13
16
  - Include a small corner badge text: generated preview
14
17
  - If you truly cannot produce a faithful conceptual visual from the diff alone, return {"feasible": false, "reason": "..."}. Prefer a simple flowchart over declining.
15
18
 
@@ -30,7 +33,8 @@ ${options.diffText}
30
33
  return { feasible: false, reason: json.reason ?? "not feasible" };
31
34
  }
32
35
  mkdirSync(options.outDir, { recursive: true });
33
- const htmlPath = join(options.outDir, "diagram.html");
36
+ const prefix = options.filePrefix ?? "";
37
+ const htmlPath = join(options.outDir, `${prefix}diagram.html`);
34
38
  writeFileSync(htmlPath, json.html, "utf8");
35
39
  return { feasible: true, htmlPath };
36
40
  }
@@ -23,4 +23,6 @@ export declare function generateMockup(options: {
23
23
  outDir: string;
24
24
  model?: string;
25
25
  log?: (msg: string) => void;
26
+ /** Prefix for written HTML files (e.g. visual-1-). */
27
+ filePrefix?: string;
26
28
  }): Promise<MockupResult>;
@@ -1,18 +1,21 @@
1
1
  import { mkdirSync, writeFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import { extractJson } from "../extract-json.js";
4
+ import { DEFAULT_VIEWPORT, VIEWPORT_MAX_HEIGHT, VIEWPORT_MAX_WIDTH, clampViewport, } from "./viewport.js";
4
5
  export async function generateMockup(options) {
5
6
  const prompt = `You create faithful before/after HTML mockups from a git diff.
6
7
 
7
8
  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
+ - The diff contains exact before/after markup/styles. Emit two fully self-contained HTML documents (inline CSS, no external requests) that render this exact markup with these exact styles.
9
10
  - Stub only the minimum: container width, placeholder text where dynamic props appear (realistic neutral placeholders).
10
11
  - Do not invent UI that is not present in the code.
11
12
  - Never show raw code listings.
13
+ - FRAME LIMIT (hard): design for exactly ${VIEWPORT_MAX_WIDTH}x${VIEWPORT_MAX_HEIGHT}px. html/body must be width/height 100% with overflow:hidden. All content must fit in that single screen — no scrolling, no content cut off at the bottom. Prefer denser layout over tall pages.
14
+ - Visual craft: one clear composition, strong typographic hierarchy, purposeful (non-default) web fonts via @import from fonts.googleapis.com or fonts.bunny.net if helpful, atmospheric background (subtle gradient or pattern — not flat single-color), high contrast for text. No purple-on-white clichés, no glow spam, no floating badge stickers on the mockup itself.
12
15
  - If you cannot render faithfully, return {"feasible": false, "reason": "..."}.
13
16
 
14
17
  Return JSON only:
15
- {"feasible": true, "before_html": "...", "after_html": "...", "viewport": {"width":1280,"height":900}}
18
+ {"feasible": true, "before_html": "...", "after_html": "...", "viewport": {"width":${DEFAULT_VIEWPORT.width},"height":${DEFAULT_VIEWPORT.height}}}
16
19
  or {"feasible": false, "reason": "..."}
17
20
 
18
21
  Change summary context:
@@ -33,14 +36,15 @@ ${options.files
33
36
  return { feasible: false, reason: "missing before_html/after_html" };
34
37
  }
35
38
  mkdirSync(options.outDir, { recursive: true });
36
- const beforePath = join(options.outDir, "before.html");
37
- const afterPath = join(options.outDir, "after.html");
39
+ const prefix = options.filePrefix ?? "";
40
+ const beforePath = join(options.outDir, `${prefix}before.html`);
41
+ const afterPath = join(options.outDir, `${prefix}after.html`);
38
42
  writeFileSync(beforePath, json.before_html, "utf8");
39
43
  writeFileSync(afterPath, json.after_html, "utf8");
40
44
  return {
41
45
  feasible: true,
42
46
  beforePath,
43
47
  afterPath,
44
- viewport: json.viewport,
48
+ viewport: clampViewport(json.viewport),
45
49
  };
46
50
  }
@@ -1,9 +1,14 @@
1
1
  import type { ChangeSummary } from "../schema.js";
2
2
  import type { Provider } from "../providers/index.js";
3
3
  import type { GatherDiffResult } from "../git.js";
4
+ export type VisualImage = {
5
+ path: string;
6
+ title: string;
7
+ };
4
8
  export type VisualPipelineResult = {
5
9
  imagePath: string | null;
6
10
  images: string[];
11
+ imageMeta: VisualImage[];
7
12
  softDegraded: boolean;
8
13
  warning?: string;
9
14
  };
@@ -1,13 +1,15 @@
1
1
  import { copyFileSync, mkdirSync } from "node:fs";
2
- import { join, resolve } from "node:path";
2
+ import { basename, join, resolve } from "node:path";
3
3
  import { pathToFileURL } from "node:url";
4
4
  import { gatherVisualFileContents } from "../git.js";
5
5
  import { decideGate } from "./gate.js";
6
6
  import { generateMockup } from "./mockup.js";
7
7
  import { generateDiagram } from "./diagram.js";
8
+ import { clusterVisualTopics } from "./cluster.js";
8
9
  import { defaultOutDir } from "../paths.js";
9
10
  import { composeBeforeAfter, ChromeError } from "./compose.js";
10
11
  import { screenshotHtml, resolveChrome } from "./chrome.js";
12
+ import { DEFAULT_VIEWPORT, clampViewport } from "./viewport.js";
11
13
  export async function runVisualPipeline(options) {
12
14
  const cwd = options.cwd ?? process.cwd();
13
15
  const outDir = resolve(cwd, options.outDir ?? defaultOutDir(cwd));
@@ -17,9 +19,8 @@ export async function runVisualPipeline(options) {
17
19
  log(msg);
18
20
  };
19
21
  if (options.noImages) {
20
- return { imagePath: null, images: [], softDegraded: false };
22
+ return emptyResult(false);
21
23
  }
22
- // Manual screenshots skip generation
23
24
  if (options.before && options.after) {
24
25
  try {
25
26
  mkdirSync(outDir, { recursive: true });
@@ -33,95 +34,180 @@ export async function runVisualPipeline(options) {
33
34
  beforePath: beforeCopy,
34
35
  afterPath: afterCopy,
35
36
  badge: "before / after",
37
+ outFileName: "visual-1.png",
36
38
  });
37
- return { imagePath: composed, images: [composed], softDegraded: false };
39
+ return singleResult(composed, "before / after");
38
40
  }
39
41
  catch (err) {
40
42
  const msg = err instanceof Error ? err.message : String(err);
41
43
  throw new ChromeError(msg);
42
44
  }
43
45
  }
44
- const gate = decideGate(options.diff.files);
45
- vlog(`visual gate: ${gate}`);
46
- if (gate === "none") {
47
- return { imagePath: null, images: [], softDegraded: false };
48
- }
49
46
  try {
50
47
  resolveChrome();
51
48
  }
52
49
  catch (err) {
53
50
  const msg = err instanceof Error ? err.message : String(err);
54
51
  return {
55
- imagePath: null,
56
- images: [],
57
- softDegraded: true,
52
+ ...emptyResult(true),
58
53
  warning: msg,
59
54
  };
60
55
  }
61
- try {
62
- if (gate === "mockup") {
63
- const files = await gatherVisualFileContents(cwd, options.diff.files, options.diff.baseRef);
64
- const mockup = await generateMockup({
65
- provider: options.provider,
66
- summary: options.summary,
67
- files,
56
+ const topics = await clusterVisualTopics(options.summary, {
57
+ provider: options.provider,
58
+ model: options.modelCheap,
59
+ log: vlog,
60
+ });
61
+ vlog(`visual topics: ${topics.length}`);
62
+ if (topics.length === 0) {
63
+ return emptyResult(false);
64
+ }
65
+ const imageMeta = [];
66
+ const warnings = [];
67
+ for (const topic of topics) {
68
+ try {
69
+ const path = await renderTopic({
70
+ topic,
71
+ cwd,
68
72
  outDir,
69
- model: options.modelCheap,
70
- log: vlog,
73
+ summary: options.summary,
74
+ diff: options.diff,
75
+ provider: options.provider,
76
+ modelCheap: options.modelCheap,
77
+ vlog,
71
78
  });
72
- if (mockup.feasible) {
73
- const beforePng = join(outDir, "before.png");
74
- const afterPng = join(outDir, "after.png");
75
- await screenshotHtml({
76
- url: pathToFileURL(mockup.beforePath).href,
77
- outPath: beforePng,
78
- width: mockup.viewport?.width,
79
- height: mockup.viewport?.height,
80
- });
81
- await screenshotHtml({
82
- url: pathToFileURL(mockup.afterPath).href,
83
- outPath: afterPng,
84
- width: mockup.viewport?.width,
85
- height: mockup.viewport?.height,
86
- });
87
- const composed = await composeBeforeAfter({
88
- cwd,
89
- outDir,
90
- beforePath: beforePng,
91
- afterPath: afterPng,
92
- badge: "generated preview",
93
- });
94
- return { imagePath: composed, images: [composed], softDegraded: false };
79
+ if (path) {
80
+ imageMeta.push({ path, title: topic.title });
95
81
  }
96
- vlog(`mockup infeasible: ${mockup.reason}; trying diagram`);
97
82
  }
98
- // diagram path (gate=diagram or mockup downgrade)
99
- const diagram = await generateDiagram({
100
- provider: options.provider,
101
- summary: options.summary,
102
- diffText: options.diff.diffText,
83
+ catch (err) {
84
+ const msg = err instanceof Error ? err.message : String(err);
85
+ warnings.push(`topic "${topic.title}": ${msg}`);
86
+ vlog(`topic failed: ${msg}`);
87
+ }
88
+ }
89
+ if (imageMeta.length === 0) {
90
+ return {
91
+ ...emptyResult(warnings.length > 0),
92
+ warning: warnings[0],
93
+ };
94
+ }
95
+ return {
96
+ imagePath: imageMeta[0].path,
97
+ images: imageMeta.map((i) => i.path),
98
+ imageMeta,
99
+ softDegraded: warnings.length > 0,
100
+ warning: warnings.length > 0 ? warnings.join("; ") : undefined,
101
+ };
102
+ }
103
+ async function renderTopic(options) {
104
+ const { topic, cwd, outDir, provider, modelCheap, vlog } = options;
105
+ const prefix = `visual-${topic.id}-`;
106
+ const stem = `visual-${topic.id}`;
107
+ const topicSummary = scopeSummary(options.summary, topic);
108
+ const topicFiles = filterDiffFiles(options.diff.files, topic.files);
109
+ const gate = topicFiles.length > 0
110
+ ? decideGate(topicFiles)
111
+ : decideGate(options.diff.files);
112
+ vlog(`visual gate [${topic.id} ${topic.title}]: ${gate}`);
113
+ if (gate === "none")
114
+ return null;
115
+ if (gate === "mockup") {
116
+ const files = await gatherVisualFileContents(cwd, topicFiles.length > 0 ? topicFiles : options.diff.files, options.diff.baseRef);
117
+ const mockup = await generateMockup({
118
+ provider,
119
+ summary: topicSummary,
120
+ files,
103
121
  outDir,
104
- model: options.modelCheap,
122
+ model: modelCheap,
105
123
  log: vlog,
124
+ filePrefix: prefix,
106
125
  });
107
- if (!diagram.feasible) {
108
- vlog(`diagram infeasible: ${diagram.reason}`);
109
- return { imagePath: null, images: [], softDegraded: false };
126
+ if (mockup.feasible) {
127
+ const beforePng = join(outDir, `${prefix}before.png`);
128
+ const afterPng = join(outDir, `${prefix}after.png`);
129
+ const vp = clampViewport(mockup.viewport);
130
+ await screenshotHtml({
131
+ url: pathToFileURL(mockup.beforePath).href,
132
+ outPath: beforePng,
133
+ width: vp.width,
134
+ height: vp.height,
135
+ });
136
+ await screenshotHtml({
137
+ url: pathToFileURL(mockup.afterPath).href,
138
+ outPath: afterPng,
139
+ width: vp.width,
140
+ height: vp.height,
141
+ });
142
+ return composeBeforeAfter({
143
+ cwd,
144
+ outDir,
145
+ beforePath: beforePng,
146
+ afterPath: afterPng,
147
+ badge: "generated preview",
148
+ outFileName: `${stem}.png`,
149
+ });
110
150
  }
111
- const outPng = join(outDir, "before-after.png");
112
- await screenshotHtml({
113
- url: pathToFileURL(diagram.htmlPath).href,
114
- outPath: outPng,
115
- });
116
- return { imagePath: outPng, images: [outPng], softDegraded: false };
151
+ vlog(`mockup infeasible [${topic.id}]: ${mockup.reason}; trying diagram`);
117
152
  }
118
- catch (err) {
119
- const msg = err instanceof Error ? err.message : String(err);
120
- return {
121
- imagePath: null,
122
- images: [],
123
- softDegraded: true,
124
- warning: msg,
125
- };
153
+ const diagram = await generateDiagram({
154
+ provider,
155
+ summary: topicSummary,
156
+ diffText: scopeDiffText(options.diff.diffText, topic.files),
157
+ outDir,
158
+ model: modelCheap,
159
+ log: vlog,
160
+ filePrefix: prefix,
161
+ });
162
+ if (!diagram.feasible) {
163
+ vlog(`diagram infeasible [${topic.id}]: ${diagram.reason}`);
164
+ return null;
126
165
  }
166
+ const outPng = join(outDir, `${stem}.png`);
167
+ await screenshotHtml({
168
+ url: pathToFileURL(diagram.htmlPath).href,
169
+ outPath: outPng,
170
+ width: DEFAULT_VIEWPORT.width,
171
+ height: DEFAULT_VIEWPORT.height,
172
+ });
173
+ return outPng;
174
+ }
175
+ function scopeSummary(summary, topic) {
176
+ return {
177
+ ...summary,
178
+ headline: topic.title.slice(0, 140),
179
+ overview: topic.items.map((i) => i.description).join(" "),
180
+ items: topic.items,
181
+ };
182
+ }
183
+ function filterDiffFiles(files, topicFiles) {
184
+ if (topicFiles.length === 0)
185
+ return files;
186
+ const set = new Set(topicFiles.map((f) => f.replaceAll("\\", "/")));
187
+ return files.filter((f) => set.has(f.path.replaceAll("\\", "/")));
188
+ }
189
+ function scopeDiffText(diffText, topicFiles) {
190
+ if (topicFiles.length === 0 || !diffText)
191
+ return diffText;
192
+ // Keep hunks whose path header mentions a topic file; else full diff.
193
+ const norms = topicFiles.map((f) => f.replaceAll("\\", "/"));
194
+ const parts = diffText.split(/(?=^diff --git )/m);
195
+ const kept = parts.filter((p) => norms.some((f) => p.includes(f) || p.includes(basename(f))));
196
+ return kept.length > 0 ? kept.join("") : diffText;
197
+ }
198
+ function emptyResult(soft) {
199
+ return {
200
+ imagePath: null,
201
+ images: [],
202
+ imageMeta: [],
203
+ softDegraded: soft,
204
+ };
205
+ }
206
+ function singleResult(path, title) {
207
+ return {
208
+ imagePath: path,
209
+ images: [path],
210
+ imageMeta: [{ path, title }],
211
+ softDegraded: false,
212
+ };
127
213
  }
@@ -0,0 +1,16 @@
1
+ /** Hard screenshot frame — Chrome captures the window only, so HTML must fit. */
2
+ export declare const VIEWPORT_MAX_WIDTH = 1280;
3
+ export declare const VIEWPORT_MAX_HEIGHT = 720;
4
+ export declare const DEFAULT_VIEWPORT: {
5
+ readonly width: 1280;
6
+ readonly height: 720;
7
+ };
8
+ export type ViewportSize = {
9
+ width: number;
10
+ height: number;
11
+ };
12
+ /** Clamp to max frame; invalid/missing values fall back to defaults. */
13
+ export declare function clampViewport(input?: {
14
+ width?: number;
15
+ height?: number;
16
+ } | null): ViewportSize;
@@ -0,0 +1,19 @@
1
+ /** Hard screenshot frame — Chrome captures the window only, so HTML must fit. */
2
+ export const VIEWPORT_MAX_WIDTH = 1280;
3
+ export const VIEWPORT_MAX_HEIGHT = 720;
4
+ export const DEFAULT_VIEWPORT = {
5
+ width: VIEWPORT_MAX_WIDTH,
6
+ height: VIEWPORT_MAX_HEIGHT,
7
+ };
8
+ /** Clamp to max frame; invalid/missing values fall back to defaults. */
9
+ export function clampViewport(input) {
10
+ const width = clampDim(input?.width, VIEWPORT_MAX_WIDTH);
11
+ const height = clampDim(input?.height, VIEWPORT_MAX_HEIGHT);
12
+ return { width, height };
13
+ }
14
+ function clampDim(value, max) {
15
+ if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) {
16
+ return max;
17
+ }
18
+ return Math.min(Math.round(value), max);
19
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ahmedalbarghouti/farq",
3
- "version": "0.0.2",
3
+ "version": "0.0.3",
4
4
  "description": "Turn git branch changes into paste-ready PR/Slack updates with optional before/after visuals",
5
5
  "type": "module",
6
6
  "engines": {