@calo-design/cli 0.6.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/cli.js CHANGED
@@ -20,6 +20,7 @@ const { cmdArtPush } = require("./art-push");
20
20
  const { cmdShare } = require("./share");
21
21
  const { cmdImage } = require("./imagegen");
22
22
  const { cmdLogin, cmdLogout, ensureLoggedIn, githubToken, reportEvent, ensureSession, loadSession, BROKER } = require("./login");
23
+ const { cmdFeedback, cmdTasks } = require("./feedback");
23
24
 
24
25
  const ORG = "Calo-Design";
25
26
  const SKILL_REPO = `${ORG}/calo-design`;
@@ -773,12 +774,17 @@ function help() {
773
774
  ${c.dim("art push <dir>")} upload a bulk image set to the CDN + emit a remote-source manifest (login only)
774
775
  ${c.dim(" art push ./art/byo --set byo --manifest src/byo-art.ts --dry-run --force --direct")}
775
776
  ${c.dim("image \"<prompt>\"")} AI image generation via the broker (metered per user; login only)
776
- ${c.dim(" image \"…\" --out hero.png --ref photo.jpg --model google/gemini-3-pro-image --json")}
777
+ ${c.dim(" image \"…\" --out hero.png --ref photo-or-url --model google/gemini-3-pro-image --json")}
778
+ ${c.dim(" image \"…\" --ref <vessel-url> --ref-sheet <comp1> --ref-sheet <comp2> food refs: vessel + composed component sheet")}
777
779
  ${c.dim("image usage")} your monthly image-generation metering (images + USD)
778
780
  ${c.dim("share")} publish THIS web prototype (React/Vite/CRA/Next) to Cloudflare Pages → share link
779
781
  ${c.dim(" share --slug x --dir dist --build --project calo-prototypes --dry-run")}
780
782
  ${c.dim("gallery")} serve the @calo/design-system component gallery locally (live from the runtime)
781
783
  ${c.dim(" gallery --port 8091")}
784
+ ${c.dim("feedback [slug]")} feedback inbox for shared prototypes (open threads; --all, --json)
785
+ ${c.dim(" feedback new <slug> <text> comment|resolve|reopen <threadId> shot <slug> <png>")}
786
+ ${c.dim("tasks [slug]")} triaged work items (--status todo|in_progress|done|wontfix, --json)
787
+ ${c.dim(" tasks new <slug> <title> --from <threadId> start|done|wontfix <id> done <id> --version v9")}
782
788
 
783
789
  Login is required once before init; the session refreshes automatically.
784
790
  init always installs the skill and ensures the shared ds/flows runtime on your machine;
@@ -803,6 +809,8 @@ function help() {
803
809
  else if (cmd === "insights") await cmdInsights();
804
810
  else if (cmd === "whoami") await cmdWhoami();
805
811
  else if (cmd === "gallery") await cmdGallery();
812
+ else if (cmd === "feedback") await cmdFeedback(args.slice(1));
813
+ else if (cmd === "tasks") await cmdTasks(args.slice(1));
806
814
  else help();
807
815
  } catch (err) {
808
816
  // Diagnostics go to stderr so stdout stays pure for machine callers (The Pass parses
@@ -0,0 +1,193 @@
1
+ "use strict";
2
+
3
+ /**
4
+ * `calo-design feedback …` / `calo-design tasks …` — prototype feedback threads and
5
+ * triaged tasks, stored broker-side (calo-broker docs/feedback-plan.md).
6
+ *
7
+ * Triple duty: humans in the terminal, Claude working the task queue, and Design
8
+ * Kitchen shelling out with --json (same bridge pattern as `feed --json`). All calls
9
+ * ride the existing broker session (ensureSession → Bearer).
10
+ *
11
+ * feedback [slug] inbox (open threads; --all for resolved too)
12
+ * feedback new <slug> <text…> plain thread (no pin)
13
+ * feedback comment <threadId> <text…>
14
+ * feedback resolve <threadId> | reopen <threadId>
15
+ * feedback shot <slug> <file.png> upload a pin screenshot → prints URL
16
+ * tasks [slug] list (--status todo|in_progress|done|wontfix)
17
+ * tasks new <slug> <title…> (--from <threadId> converts a thread, --body <text>)
18
+ * tasks start|done|wontfix <id> (done: --version <v> stamps closed_in_version)
19
+ */
20
+
21
+ const fs = require("node:fs");
22
+ const { ensureSession, BROKER } = require("./login");
23
+
24
+ const c = { dim: (s) => `\x1b[2m${s}\x1b[0m`, b: (s) => `\x1b[1m${s}\x1b[0m`, g: (s) => `\x1b[32m${s}\x1b[0m`, y: (s) => `\x1b[33m${s}\x1b[0m` };
25
+ const log = (s = "") => console.log(s);
26
+
27
+ // Bounded fetch that speaks the broker's JSON envelope. GET+POST (login.js api() is
28
+ // POST-only); --json callers get the raw response text so the bridge never re-encodes.
29
+ async function call(method, pathname, { body, raw } = {}) {
30
+ const session = await ensureSession();
31
+ let res;
32
+ try {
33
+ res = await fetch(BROKER + pathname, {
34
+ method,
35
+ headers: {
36
+ authorization: `Bearer ${session}`,
37
+ ...(body !== undefined && !Buffer.isBuffer(body) ? { "content-type": "application/json" } : {}),
38
+ ...(Buffer.isBuffer(body) ? { "content-type": "image/png" } : {}),
39
+ },
40
+ body: body === undefined ? undefined : Buffer.isBuffer(body) ? body : JSON.stringify(body),
41
+ signal: AbortSignal.timeout(30000),
42
+ });
43
+ } catch (e) {
44
+ throw new Error(`can't reach the Calo broker at ${BROKER} (${e.message})`);
45
+ }
46
+ const text = await res.text();
47
+ let json;
48
+ try { json = text ? JSON.parse(text) : {}; } catch { json = { raw: text }; }
49
+ if (!res.ok) throw new Error(json.error || `${pathname} → HTTP ${res.status}`);
50
+ return raw ? text : json;
51
+ }
52
+
53
+ const flagVal = (args, name) => {
54
+ const i = args.indexOf(`--${name}`);
55
+ return i >= 0 && args[i + 1] && !args[i + 1].startsWith("--") ? args[i + 1] : undefined;
56
+ };
57
+ const stripFlags = (args) => args.filter((a, i, all) => !a.startsWith("--") && !(all[i - 1] || "").match(/^--(status|version|from|body|route|anchor|shot)$/));
58
+
59
+ const ago = (ts) => {
60
+ const s = Math.max(1, Math.floor(Date.now() / 1000) - ts);
61
+ if (s < 3600) return `${Math.floor(s / 60)}m`;
62
+ if (s < 86400) return `${Math.floor(s / 3600)}h`;
63
+ return `${Math.floor(s / 86400)}d`;
64
+ };
65
+ const short = (id) => id.slice(0, 8);
66
+ // A thread's version is the deploy it was left on; slugs.last_version is current.
67
+ const stale = (t) => t.version && t.current_version && t.version !== t.current_version;
68
+
69
+ function printThread(t, { comments = false } = {}) {
70
+ const state = t.status === "resolved" ? c.g("✓ resolved") : c.y("● open");
71
+ const pin = t.anchor_json ? " 📌" : "";
72
+ const staleTag = stale(t) ? c.y(" [stale]") : "";
73
+ const task = t.task ? c.dim(` → task ${short(t.task.id)} (${t.task.status})`) : "";
74
+ log(`${state} ${c.b(t.slug)}${t.route ? c.dim(" " + t.route) : ""}${pin}${staleTag} ${c.dim(short(t.id))} ${c.dim(ago(t.created_at) + " ago")}${task}`);
75
+ const list = comments ? t.comments : t.comments.slice(0, 1);
76
+ for (const cm of list) {
77
+ const who = cm.kind === "system" ? c.dim("system") : cm.author.split("@")[0];
78
+ log(` ${who}: ${cm.kind === "system" ? c.dim(cm.body) : cm.body}`);
79
+ }
80
+ if (!comments && t.comments.length > 1) log(c.dim(` … ${t.comments.length - 1} more — calo-design feedback ${t.slug}`));
81
+ }
82
+
83
+ async function cmdFeedback(args = []) {
84
+ const json = args.includes("--json");
85
+ const sub = args[0] && !args[0].startsWith("--") ? args[0] : "";
86
+
87
+ if (sub === "new") {
88
+ const [slug, ...words] = stripFlags(args.slice(1));
89
+ const body = words.join(" ").trim();
90
+ if (!slug || !body) throw new Error("usage: calo-design feedback new <slug> <text…> [--shot <png>] [--anchor <json>] [--route <path>] [--version <v>]");
91
+ // Pin threads (Kitchen Review view): upload the frozen frame first, then attach
92
+ // its URL — /v1/feedback/thread only accepts screenshot URLs minted by /shot.
93
+ let screenshotUrl;
94
+ const shotFile = flagVal(args, "shot");
95
+ if (shotFile) {
96
+ const png = fs.readFileSync(shotFile);
97
+ const up = await call("POST", `/v1/feedback/shot?slug=${encodeURIComponent(slug)}`, { body: png });
98
+ screenshotUrl = up.url;
99
+ }
100
+ let anchor;
101
+ const anchorRaw = flagVal(args, "anchor");
102
+ if (anchorRaw) {
103
+ try { anchor = JSON.parse(anchorRaw); } catch { throw new Error("--anchor must be a JSON object"); }
104
+ }
105
+ const r = await call("POST", "/v1/feedback/thread", {
106
+ body: { slug, body, screenshotUrl, anchor, route: flagVal(args, "route"), version: flagVal(args, "version") },
107
+ raw: json,
108
+ });
109
+ if (json) return process.stdout.write(r + "\n");
110
+ log(`${c.g("✓")} thread ${short(r.thread.id)} opened on ${c.b(slug)}${screenshotUrl ? c.dim(" (with screenshot)") : ""}`);
111
+ return;
112
+ }
113
+
114
+ if (sub === "comment" || sub === "resolve" || sub === "reopen") {
115
+ const [threadId, ...words] = stripFlags(args.slice(1));
116
+ if (!threadId) throw new Error(`usage: calo-design feedback ${sub} <threadId>${sub === "comment" ? " <text…>" : ""}`);
117
+ const path = { comment: "/v1/feedback/comment", resolve: "/v1/feedback/resolve", reopen: "/v1/feedback/reopen" }[sub];
118
+ const body = sub === "comment" ? { threadId, body: words.join(" ").trim() } : { threadId };
119
+ if (sub === "comment" && !body.body) throw new Error("comment text required");
120
+ const r = await call("POST", path, { body, raw: json });
121
+ if (json) return process.stdout.write(r + "\n");
122
+ log(`${c.g("✓")} ${sub === "comment" ? "commented" : sub + "ed"}`);
123
+ return;
124
+ }
125
+
126
+ if (sub === "shot") {
127
+ const [slug, file] = stripFlags(args.slice(1));
128
+ if (!slug || !file) throw new Error("usage: calo-design feedback shot <slug> <file.png>");
129
+ const png = fs.readFileSync(file);
130
+ const r = await call("POST", `/v1/feedback/shot?slug=${encodeURIComponent(slug)}`, { body: png, raw: json });
131
+ if (json) return process.stdout.write(r + "\n");
132
+ log(`${c.g("✓")} ${r.url}`);
133
+ return;
134
+ }
135
+
136
+ // list: `feedback` (inbox: open everywhere) or `feedback <slug>` (--all incl. resolved)
137
+ const slug = sub || undefined;
138
+ const all = args.includes("--all");
139
+ const qs = new URLSearchParams();
140
+ if (slug) qs.set("slug", slug);
141
+ if (!all) qs.set("status", "open");
142
+ const r = await call("GET", `/v1/feedback?${qs}`, { raw: json });
143
+ if (json) return process.stdout.write(r + "\n");
144
+ const threads = r.threads;
145
+ if (!threads.length) return log(c.dim(slug ? `no ${all ? "" : "open "}feedback on ${slug}` : "inbox zero 🎉"));
146
+ log(c.b(`\n${threads.length} thread(s)${slug ? ` on ${slug}` : ""}${all ? "" : " (open)"}\n`));
147
+ for (const t of threads) printThread(t, { comments: !!slug });
148
+ }
149
+
150
+ async function cmdTasks(args = []) {
151
+ const json = args.includes("--json");
152
+ const sub = args[0] && !args[0].startsWith("--") ? args[0] : "";
153
+
154
+ if (sub === "new") {
155
+ const [slug, ...words] = stripFlags(args.slice(1));
156
+ const title = words.join(" ").trim();
157
+ if (!slug || !title) throw new Error("usage: calo-design tasks new <slug> <title…> [--from <threadId>] [--body <text>]");
158
+ const body = { slug, title, sourceThread: flagVal(args, "from"), body: flagVal(args, "body") };
159
+ const r = await call("POST", "/v1/tasks", { body, raw: json });
160
+ if (json) return process.stdout.write(r + "\n");
161
+ log(`${c.g("✓")} task ${short(r.task.id)}: ${r.task.title}${r.task.source_thread ? c.dim(` (from thread ${short(r.task.source_thread)})`) : ""}`);
162
+ return;
163
+ }
164
+
165
+ if (["start", "done", "wontfix"].includes(sub)) {
166
+ const [id] = stripFlags(args.slice(1));
167
+ if (!id) throw new Error(`usage: calo-design tasks ${sub} <id>`);
168
+ const status = sub === "start" ? "in_progress" : sub;
169
+ const body = { id, status, ...(sub === "done" && flagVal(args, "version") ? { closedInVersion: flagVal(args, "version") } : {}) };
170
+ const r = await call("POST", "/v1/tasks/update", { body, raw: json });
171
+ if (json) return process.stdout.write(r + "\n");
172
+ log(`${c.g("✓")} ${r.task.title} → ${c.b(r.task.status)}${r.task.closed_in_version ? c.dim(` (${r.task.closed_in_version})`) : ""}`);
173
+ if (sub === "done") log(c.dim(" source thread (if any) auto-resolved"));
174
+ return;
175
+ }
176
+
177
+ const slug = sub || undefined;
178
+ const qs = new URLSearchParams();
179
+ if (slug) qs.set("slug", slug);
180
+ const status = flagVal(args, "status");
181
+ if (status) qs.set("status", status);
182
+ const r = await call("GET", `/v1/tasks?${qs}`, { raw: json });
183
+ if (json) return process.stdout.write(r + "\n");
184
+ const list = r.tasks;
185
+ if (!list.length) return log(c.dim("no tasks" + (slug ? ` on ${slug}` : "")));
186
+ log(c.b(`\n${list.length} task(s)${slug ? ` on ${slug}` : ""}\n`));
187
+ const badge = { todo: c.y("○ todo"), in_progress: c.b("◐ in progress"), done: c.g("● done"), wontfix: c.dim("✕ wontfix") };
188
+ for (const t of list) {
189
+ log(`${badge[t.status] || t.status} ${c.b(t.slug)} ${t.title} ${c.dim(short(t.id))}${t.source_thread ? c.dim(" ← " + short(t.source_thread)) : ""}${t.closed_in_version ? c.dim(" " + t.closed_in_version) : ""}`);
190
+ }
191
+ }
192
+
193
+ module.exports = { cmdFeedback, cmdTasks };
package/bin/imagegen.js CHANGED
@@ -5,11 +5,20 @@
5
5
  *
6
6
  * calo-design image "prompt" generate → writes ./<slug>.png, prints path + URL + cost
7
7
  * calo-design image "prompt" --out hero.png choose the output path
8
- * calo-design image "prompt" --ref photo.jpg reference/edit input (repeatable, ≤4)
8
+ * calo-design image "prompt" --ref photo.jpg reference/edit input (repeatable; file or https URL)
9
+ * calo-design image "prompt" --ref-sheet a.jpg --ref-sheet b.jpg
10
+ * composite component photos into ONE tile-grid
11
+ * reference sheet (row-major order — the food
12
+ * register's component sheet); counts as one ref
13
+ * calo-design image "prompt" --sheet-out s.jpg also save the composed sheet for inspection
9
14
  * calo-design image "prompt" --model <id> pick an allowed model (default: broker's)
10
15
  * calo-design image usage your monthly metering (images + USD, caps)
11
16
  * --json on either form for machine callers (the design/marketing skills use this)
12
17
  *
18
+ * Refs cap at 4 per generation (broker limit). https refs are fetched (≤6 MB) —
19
+ * this is how CDN reference art (e.g. the food-refs set) attaches without a
20
+ * local copy.
21
+ *
13
22
  * The OpenRouter key lives on the broker only; identity is the normal CLI session,
14
23
  * and the broker meters every generated image per user per month. The broker also
15
24
  * mirrors each image to the CDN (content-hashed, immutable) and returns that URL —
@@ -52,19 +61,40 @@ async function apiGet(pathname, session) {
52
61
  return json;
53
62
  }
54
63
 
55
- function refToDataUrl(file) {
56
- const mime = MIMES[path.extname(file).toLowerCase()];
57
- if (!mime) throw new Error(`--ref ${file}: must be a .png/.jpg/.jpeg/.webp file`);
64
+ const MAGIC_MIMES = [
65
+ [(b) => b.length > 2 && b[0] === 0xff && b[1] === 0xd8, "image/jpeg"],
66
+ [(b) => b.length > 7 && b.readUInt32BE(0) === 0x89504e47, "image/png"],
67
+ [(b) => b.length > 11 && b.toString("ascii", 8, 12) === "WEBP", "image/webp"],
68
+ ];
69
+
70
+ // A ref is a local file or an https URL (CDN reference art). Returns { buf, mime }.
71
+ async function loadRef(spec, flagName = "--ref") {
58
72
  let buf;
59
- try {
60
- buf = fs.readFileSync(file);
61
- } catch {
62
- throw new Error(`--ref ${file}: can't read file`);
73
+ if (/^https?:\/\//i.test(spec)) {
74
+ let res;
75
+ try {
76
+ res = await fetch(spec, { signal: AbortSignal.timeout(20000) });
77
+ } catch (e) {
78
+ throw new Error(`${flagName} ${spec}: fetch failed (${e.message})`);
79
+ }
80
+ if (!res.ok) throw new Error(`${flagName} ${spec}: HTTP ${res.status}`);
81
+ buf = Buffer.from(await res.arrayBuffer());
82
+ } else {
83
+ try {
84
+ buf = fs.readFileSync(spec);
85
+ } catch {
86
+ throw new Error(`${flagName} ${spec}: can't read file`);
87
+ }
63
88
  }
64
- if (buf.length > 6e6) throw new Error(`--ref ${file}: over 6 MB — resize it first`);
65
- return `data:${mime};base64,${buf.toString("base64")}`;
89
+ if (buf.length > 6e6) throw new Error(`${flagName} ${spec}: over 6 MB — resize it first`);
90
+ const magic = MAGIC_MIMES.find(([test]) => test(buf));
91
+ const mime = magic ? magic[1] : MIMES[path.extname(new URL(spec, "file:///").pathname).toLowerCase()];
92
+ if (!mime) throw new Error(`${flagName} ${spec}: must be a png/jpg/webp image`);
93
+ return { buf, mime };
66
94
  }
67
95
 
96
+ const toDataUrl = ({ buf, mime }) => `data:${mime};base64,${buf.toString("base64")}`;
97
+
68
98
  // "A hero shot of a grain bowl!" → "a-hero-shot-of-a-grain"
69
99
  const promptSlug = (p) =>
70
100
  p.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").split("-").slice(0, 5).join("-") || "image";
@@ -105,7 +135,7 @@ async function cmdImage(args) {
105
135
 
106
136
  // First positional = the prompt. Walk the args so a space-separated flag value
107
137
  // (`--ref img.png`) is never mistaken for it.
108
- const VALUE_FLAGS = new Set(["--out", "--model", "--ref"]);
138
+ const VALUE_FLAGS = new Set(["--out", "--model", "--ref", "--ref-sheet", "--sheet-out"]);
109
139
  let prompt;
110
140
  for (let i = 0; i < args.length; i++) {
111
141
  if (VALUE_FLAGS.has(args[i])) i++; // skip the flag's value
@@ -113,14 +143,39 @@ async function cmdImage(args) {
113
143
  }
114
144
  if (!prompt) {
115
145
  throw new Error(
116
- 'usage: calo-design image "<prompt>" [--out file.png] [--ref img.png]... [--model id] [--json]\n or: calo-design image usage'
146
+ 'usage: calo-design image "<prompt>" [--out file.png] [--ref img-or-url]... [--ref-sheet img-or-url]... [--model id] [--json]\n or: calo-design image usage'
117
147
  );
118
148
  }
119
- const refImages = flagAll(args, "ref").map(refToDataUrl);
149
+ const refImages = [];
150
+ for (const spec of flagAll(args, "ref")) refImages.push(toDataUrl(await loadRef(spec)));
151
+
152
+ // --ref-sheet: composite N component photos into ONE row-major tile grid that
153
+ // costs a single ref slot. Order of the flags = tile order = the order the
154
+ // prompt's sheet map must bind.
155
+ const sheetSpecs = flagAll(args, "ref-sheet");
156
+ let sheetInfo = null;
157
+ if (sheetSpecs.length) {
158
+ const { composeSheet } = require("./refsheet");
159
+ const inputs = [];
160
+ for (const spec of sheetSpecs) inputs.push({ ...(await loadRef(spec, "--ref-sheet")), label: spec });
161
+ const sheet = composeSheet(inputs);
162
+ if (sheet.buf.length > 6e6) throw new Error(`--ref-sheet: composed sheet is over 6 MB (${sheetSpecs.length} tiles) — send fewer tiles`);
163
+ const sheetOut = flag(args, "sheet-out");
164
+ if (sheetOut) {
165
+ fs.mkdirSync(path.dirname(path.resolve(sheetOut)), { recursive: true });
166
+ fs.writeFileSync(sheetOut, sheet.buf);
167
+ }
168
+ refImages.push(toDataUrl({ buf: sheet.buf, mime: "image/jpeg" }));
169
+ sheetInfo = { tiles: sheetSpecs.length, cols: sheet.cols, rows: sheet.rows, ...(sheetOut ? { file: path.resolve(sheetOut) } : {}) };
170
+ }
171
+ if (refImages.length > 4) {
172
+ throw new Error(`${refImages.length} reference images — the broker caps a generation at 4. Move component refs onto --ref-sheet (one composed sheet = one ref).`);
173
+ }
120
174
  const model = flag(args, "model");
121
175
  const out = flag(args, "out");
122
176
 
123
177
  const session = await ensureSession();
178
+ if (!jsonOut && sheetInfo) log(c.dim(`Composed reference sheet: ${sheetInfo.tiles} tiles, ${sheetInfo.cols}×${sheetInfo.rows} grid (row-major)${sheetInfo.file ? ` → ${sheetInfo.file}` : ""}`));
124
179
  if (!jsonOut) log(c.dim(`Generating${model ? ` with ${model}` : ""}… (typically 10–30s)`));
125
180
  let res;
126
181
  try {
@@ -156,7 +211,7 @@ async function cmdImage(args) {
156
211
 
157
212
  if (jsonOut) {
158
213
  const { images: _images, ...rest } = data; // drop the base64 payload from machine output
159
- process.stdout.write("\n" + JSON.stringify({ ...rest, files }) + "\n");
214
+ process.stdout.write("\n" + JSON.stringify({ ...rest, files, ...(sheetInfo ? { refSheet: sheetInfo } : {}) }) + "\n");
160
215
  return;
161
216
  }
162
217
  for (const f of files) {
@@ -0,0 +1,107 @@
1
+ "use strict";
2
+
3
+ /**
4
+ * Component reference sheet compositor for `calo-design image --ref-sheet`.
5
+ *
6
+ * Ported from the imagegen-lab collage builder: N component photos → one
7
+ * tile-grid JPEG (512px tiles, off-white gutterless grid, row-major order).
8
+ * The grid exists because the broker caps a generation at 4 reference images —
9
+ * a meal with more components sends ONE composed sheet instead, and the prompt
10
+ * binds each tile to its named component ("REFERENCE COMPONENT SHEET MAP").
11
+ *
12
+ * Pure-JS on purpose (jpeg-js + pngjs): the CLI stays native-dep-free so
13
+ * install never needs a compiler toolchain.
14
+ */
15
+
16
+ const jpeg = require("jpeg-js");
17
+ const { PNG } = require("pngjs");
18
+
19
+ const TILE_PX = 512; // longest edge of each tile (matches the lab's collage)
20
+ const BG = [248, 248, 246];
21
+
22
+ // Same grid ladder as the lab's _pick_grid: near-square, fills row-major.
23
+ function pickGrid(n) {
24
+ if (n <= 1) return [1, 1];
25
+ if (n <= 2) return [2, 1];
26
+ if (n <= 4) return [2, 2];
27
+ if (n <= 6) return [3, 2];
28
+ if (n <= 9) return [3, 3];
29
+ const cols = Math.ceil(Math.sqrt(n));
30
+ return [cols, Math.ceil(n / cols)];
31
+ }
32
+
33
+ function decode(buf, label) {
34
+ if (buf.length > 3 && buf[0] === 0xff && buf[1] === 0xd8) {
35
+ const { width, height, data } = jpeg.decode(buf, { maxMemoryUsageInMB: 256 });
36
+ return { width, height, data };
37
+ }
38
+ if (buf.length > 8 && buf.readUInt32BE(0) === 0x89504e47) {
39
+ const { width, height, data } = PNG.sync.read(buf);
40
+ return { width, height, data };
41
+ }
42
+ throw new Error(`--ref-sheet ${label}: only JPEG/PNG images can go on a reference sheet`);
43
+ }
44
+
45
+ // Bilinear resize, RGBA in/out. Never upscales (scale capped at 1).
46
+ function resize(img, maxEdge) {
47
+ const scale = Math.min(maxEdge / img.width, maxEdge / img.height, 1);
48
+ const w = Math.max(1, Math.round(img.width * scale));
49
+ const h = Math.max(1, Math.round(img.height * scale));
50
+ if (w === img.width && h === img.height) return img;
51
+ const out = Buffer.alloc(w * h * 4);
52
+ for (let y = 0; y < h; y++) {
53
+ const sy = ((y + 0.5) * img.height) / h - 0.5;
54
+ const y0 = Math.max(0, Math.floor(sy));
55
+ const y1 = Math.min(img.height - 1, y0 + 1);
56
+ const fy = sy - y0;
57
+ for (let x = 0; x < w; x++) {
58
+ const sx = ((x + 0.5) * img.width) / w - 0.5;
59
+ const x0 = Math.max(0, Math.floor(sx));
60
+ const x1 = Math.min(img.width - 1, x0 + 1);
61
+ const fx = sx - x0;
62
+ const di = (y * w + x) * 4;
63
+ for (let ch = 0; ch < 4; ch++) {
64
+ const p00 = img.data[(y0 * img.width + x0) * 4 + ch];
65
+ const p10 = img.data[(y0 * img.width + x1) * 4 + ch];
66
+ const p01 = img.data[(y1 * img.width + x0) * 4 + ch];
67
+ const p11 = img.data[(y1 * img.width + x1) * 4 + ch];
68
+ out[di + ch] = Math.round(
69
+ p00 * (1 - fx) * (1 - fy) + p10 * fx * (1 - fy) + p01 * (1 - fx) * fy + p11 * fx * fy
70
+ );
71
+ }
72
+ }
73
+ }
74
+ return { width: w, height: h, data: out };
75
+ }
76
+
77
+ /**
78
+ * Compose the sheet. `inputs` = [{ buf, label }] in the order the caller wants
79
+ * the tiles bound (row-major). Returns { buf (JPEG), width, height, cols, rows }.
80
+ */
81
+ function composeSheet(inputs) {
82
+ if (!inputs.length) throw new Error("--ref-sheet: no images to compose");
83
+ const tiles = inputs.map((i) => resize(decode(i.buf, i.label), TILE_PX));
84
+ const [cols, rows] = pickGrid(tiles.length);
85
+ const W = cols * TILE_PX;
86
+ const H = rows * TILE_PX;
87
+ const canvas = Buffer.alloc(W * H * 4);
88
+ for (let p = 0; p < W * H; p++) {
89
+ canvas[p * 4] = BG[0];
90
+ canvas[p * 4 + 1] = BG[1];
91
+ canvas[p * 4 + 2] = BG[2];
92
+ canvas[p * 4 + 3] = 255;
93
+ }
94
+ tiles.forEach((tile, idx) => {
95
+ const cx = (idx % cols) * TILE_PX + Math.floor((TILE_PX - tile.width) / 2);
96
+ const cy = Math.floor(idx / cols) * TILE_PX + Math.floor((TILE_PX - tile.height) / 2);
97
+ for (let y = 0; y < tile.height; y++) {
98
+ // Alpha is ignored on paste: reference photos are opaque; PNG alpha
99
+ // lands on the off-white background anyway after JPEG encode.
100
+ tile.data.copy(canvas, ((cy + y) * W + cx) * 4, y * tile.width * 4, (y + 1) * tile.width * 4);
101
+ }
102
+ });
103
+ const out = jpeg.encode({ data: canvas, width: W, height: H }, 85);
104
+ return { buf: out.data, width: W, height: H, cols, rows };
105
+ }
106
+
107
+ module.exports = { composeSheet, _pickGrid: pickGrid };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@calo-design/cli",
3
- "version": "0.6.0",
3
+ "version": "0.8.0",
4
4
  "description": "One-line setup for Calo design tooling: logs in with your Calo email and installs the calo-design skill + design-system packages. No GitHub account needed.",
5
5
  "bin": {
6
6
  "calo-design": "bin/cli.js"
@@ -16,6 +16,8 @@
16
16
  "node": ">=18"
17
17
  },
18
18
  "dependencies": {
19
+ "jpeg-js": "^0.4.4",
20
+ "pngjs": "^7.0.0",
19
21
  "qrcode-terminal": "^0.12.0"
20
22
  }
21
23
  }