like-sci-figures 0.0.0-stage → 1.0.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 (83) hide show
  1. package/LICENSE +56 -0
  2. package/README.md +93 -2
  3. package/cli.js +79 -0
  4. package/examples/area.json +9 -0
  5. package/examples/bar.json +15 -0
  6. package/examples/bland_altman.json +128 -0
  7. package/examples/bode.json +8 -0
  8. package/examples/box.json +8 -0
  9. package/examples/bubble.json +7 -0
  10. package/examples/contour.json +10 -0
  11. package/examples/corr_matrix.json +99 -0
  12. package/examples/dendrogram.json +5 -0
  13. package/examples/density.json +8 -0
  14. package/examples/dot_plot.json +8 -0
  15. package/examples/ecdf.json +377 -0
  16. package/examples/errorbar.json +7 -0
  17. package/examples/forest.json +12 -0
  18. package/examples/heatmap.json +10 -0
  19. package/examples/hexbin.json +6009 -0
  20. package/examples/histogram.json +6 -0
  21. package/examples/kaplan_meier.json +6 -0
  22. package/examples/line.json +11 -0
  23. package/examples/manhattan.json +7 -0
  24. package/examples/panels.json +28 -0
  25. package/examples/pca.json +12 -0
  26. package/examples/periodogram.json +7 -0
  27. package/examples/pie.json +4 -0
  28. package/examples/radar.json +7 -0
  29. package/examples/ridge.json +11 -0
  30. package/examples/roc.json +6 -0
  31. package/examples/sankey.json +9 -0
  32. package/examples/scatter3d.json +6 -0
  33. package/examples/slope.json +6 -0
  34. package/examples/stacked_bar.json +12 -0
  35. package/examples/surface.json +11 -0
  36. package/examples/taylor.json +9 -0
  37. package/examples/ternary.json +18 -0
  38. package/examples/venn.json +12 -0
  39. package/examples/violin.json +9 -0
  40. package/examples/volcano.json +7 -0
  41. package/examples/waterfall.json +5 -0
  42. package/examples/windrose.json +407 -0
  43. package/package.json +33 -4
  44. package/requirements.txt +9 -0
  45. package/scripts/add_chart.py +201 -0
  46. package/scripts/build_gallery.py +360 -0
  47. package/scripts/chart_plugin.py +153 -0
  48. package/scripts/data_loader.py +260 -0
  49. package/scripts/fig_3d.py +103 -0
  50. package/scripts/fig_area.py +57 -0
  51. package/scripts/fig_bar.py +117 -0
  52. package/scripts/fig_biomed.py +289 -0
  53. package/scripts/fig_box.py +60 -0
  54. package/scripts/fig_bubble.py +65 -0
  55. package/scripts/fig_core.py +195 -0
  56. package/scripts/fig_datasci.py +276 -0
  57. package/scripts/fig_distribution.py +59 -0
  58. package/scripts/fig_engineering.py +224 -0
  59. package/scripts/fig_environ.py +239 -0
  60. package/scripts/fig_errorbar.py +49 -0
  61. package/scripts/fig_forest.py +54 -0
  62. package/scripts/fig_heatmap.py +51 -0
  63. package/scripts/fig_line.py +39 -0
  64. package/scripts/fig_pca.py +101 -0
  65. package/scripts/fig_pie.py +52 -0
  66. package/scripts/fig_radar.py +32 -0
  67. package/scripts/fig_sankey.py +196 -0
  68. package/scripts/fig_scatter.py +44 -0
  69. package/scripts/fig_subplots.py +124 -0
  70. package/scripts/fig_taylor.py +80 -0
  71. package/scripts/fig_venn.py +80 -0
  72. package/scripts/fig_windrose.py +82 -0
  73. package/scripts/font_detector.py +85 -0
  74. package/scripts/gen_figure.py +583 -0
  75. package/scripts/generate_samples.py +322 -0
  76. package/scripts/palette_picker.py +244 -0
  77. package/scripts/palette_pref.py +179 -0
  78. package/scripts/palette_preview.py +334 -0
  79. package/scripts/palettes.py +461 -0
  80. package/scripts/registry.py +138 -0
  81. package/scripts/render_utils.py +233 -0
  82. package/scripts/showcase.py +179 -0
  83. package/scripts/style_manager.py +267 -0
@@ -0,0 +1,179 @@
1
+ #!/usr/bin/env python3
2
+ """Task-scoped colour-palette preference store for like-sci-figures.
3
+
4
+ Why this exists
5
+ ---------------
6
+ This skill ships 10 curated palettes. The workflow requires the *user* to pick
7
+ one the first time the skill is used in a task, and to keep using that same
8
+ palette for the rest of the task. An LLM agent may lose conversation context
9
+ (compaction, context switching, multi-turn gaps), so the choice is persisted
10
+ to disk next to the working directory — one record per task / project.
11
+
12
+ State file
13
+ ----------
14
+ <cwd>/.workbuddy/like-sci-figures/palette.json (override: --state)
15
+
16
+ {
17
+ "palette": "wx06_amber_navy", # slug, or the literal string "auto"
18
+ "chosen_at": "2026-10-16 18:20:00",
19
+ "cwd": "C:\\path\\to\\project"
20
+ }
21
+
22
+ Values
23
+ ------
24
+ <slug> a real palette, e.g. wx06_amber_navy / p6 / 6 / hex list
25
+ auto the user explicitly declined to pick one -> do NOT pass
26
+ --palette, let the journal style supply its own colours
27
+
28
+ Exit codes (read them — do not just check stdout)
29
+ -------------------------------------------------
30
+ get 0 = a choice is recorded (stdout = palette value)
31
+ 2 = no choice yet for this task -> the agent must ask the user
32
+ set 0 = saved, 1 = missing arg, 3 = unknown palette name
33
+ clear 0 = removed (or nothing to clear)
34
+ """
35
+ import argparse
36
+ import json
37
+ import os
38
+ import sys
39
+ import time
40
+
41
+ SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
42
+ sys.path.insert(0, SCRIPT_DIR)
43
+
44
+ # Where the record lives, relative to the working directory.
45
+ STATE_REL = os.path.join(".workbuddy", "like-sci-figures", "palette.json")
46
+ # Sentinel meaning "user opted out of choosing; use the style's own colours".
47
+ AUTO = "auto"
48
+ # What the agent should append to every gen_figure.py call once a choice exists.
49
+ FLAG = "--palette"
50
+
51
+
52
+ def state_path(override=None):
53
+ """Absolute path of the state file for the current task."""
54
+ return os.path.abspath(override or os.path.join(os.getcwd(), STATE_REL))
55
+
56
+
57
+ def _read(path):
58
+ try:
59
+ with open(path, "r", encoding="utf-8") as fh:
60
+ rec = json.load(fh)
61
+ if isinstance(rec, dict):
62
+ return rec
63
+ except Exception:
64
+ pass
65
+ return None
66
+
67
+
68
+ def _resolve(name):
69
+ """Validate a palette name and return its canonical slug, or None."""
70
+ if str(name).strip().lower() == AUTO:
71
+ return AUTO
72
+ try:
73
+ from palettes import get_palette, palette_info
74
+ except Exception:
75
+ return str(name).strip()
76
+ info = palette_info(name)
77
+ if info:
78
+ return info["slug"]
79
+ # Not a known slug/alias — allow an explicit hex list through.
80
+ try:
81
+ get_palette(name)
82
+ return str(name).strip()
83
+ except Exception:
84
+ return None
85
+
86
+
87
+ def cmd_get(args):
88
+ path = state_path(args.state)
89
+ rec = _read(path)
90
+ value = (rec or {}).get("palette")
91
+ if not value:
92
+ print("(no palette chosen yet for this task)", file=sys.stderr)
93
+ print("(ask the user to choose, then run: palette_pref.py set --palette <slug>)",
94
+ file=sys.stderr)
95
+ return 2
96
+ print(value)
97
+ return 0
98
+
99
+
100
+ def cmd_set(args):
101
+ raw = (args.palette or "").strip()
102
+ if not raw:
103
+ print("ERROR: --palette is required (a slug like wx06_amber_navy, an alias "
104
+ "like p6, a hex list, or 'auto' to opt out)", file=sys.stderr)
105
+ return 1
106
+ slug = _resolve(raw)
107
+ if slug is None:
108
+ print(f"ERROR: unknown palette '{raw}'.", file=sys.stderr)
109
+ print(" Available: wx01_blue_red ... wx10_vivid_contrast "
110
+ "(aliases p1-p10 / 1-10 / wx1-wx10), or 'auto'.", file=sys.stderr)
111
+ return 3
112
+ path = state_path(args.state)
113
+ os.makedirs(os.path.dirname(path), exist_ok=True)
114
+ rec = {
115
+ "palette": slug,
116
+ "chosen_at": time.strftime("%Y-%m-%d %H:%M:%S"),
117
+ "cwd": os.getcwd(),
118
+ }
119
+ with open(path, "w", encoding="utf-8") as fh:
120
+ json.dump(rec, fh, ensure_ascii=False, indent=2)
121
+ fh.write("\n")
122
+ if slug == AUTO:
123
+ print("Palette preference saved: auto (user opted out -> omit --palette)")
124
+ else:
125
+ print(f"Palette preference saved: {slug}")
126
+ print(f"State file: {path}")
127
+ print(f"All later figure calls in this task must append: {FLAG} {slug}")
128
+ return 0
129
+
130
+
131
+ def cmd_clear(args):
132
+ path = state_path(args.state)
133
+ if os.path.exists(path):
134
+ try:
135
+ os.remove(path)
136
+ except Exception as exc:
137
+ print(f"ERROR: could not remove {path}: {exc}", file=sys.stderr)
138
+ return 1
139
+ print(f"Removed {path}")
140
+ return 0
141
+ print("(nothing to clear)")
142
+ return 0
143
+
144
+
145
+ def cmd_path(args):
146
+ print(state_path(args.state))
147
+ return 0
148
+
149
+
150
+ def main():
151
+ ap = argparse.ArgumentParser(
152
+ prog="palette_pref.py",
153
+ description="Remember which colour palette the user chose for the current task.",
154
+ )
155
+ ap.add_argument("--state", default=None,
156
+ help="Explicit state file path (default: "
157
+ ".workbuddy/like-sci-figures/palette.json under cwd)")
158
+ sub = ap.add_subparsers(dest="cmd", required=True)
159
+
160
+ p_get = sub.add_parser("get", help="print the recorded palette (exit 2 if none)")
161
+ p_get.set_defaults(func=cmd_get)
162
+
163
+ p_set = sub.add_parser("set", help="record the user's palette choice")
164
+ p_set.add_argument("--palette", "-p", required=True,
165
+ help="slug / alias / hex list, or 'auto' to opt out")
166
+ p_set.set_defaults(func=cmd_set)
167
+
168
+ p_clear = sub.add_parser("clear", help="forget the recorded choice")
169
+ p_clear.set_defaults(func=cmd_clear)
170
+
171
+ p_path = sub.add_parser("path", help="print where the state file will be written")
172
+ p_path.set_defaults(func=cmd_path)
173
+
174
+ args = ap.parse_args()
175
+ return args.func(args) or 0
176
+
177
+
178
+ if __name__ == "__main__":
179
+ sys.exit(main())
@@ -0,0 +1,334 @@
1
+ #!/usr/bin/env python3
2
+ """Generate individual preview images for each palette.
3
+
4
+ Why this exists
5
+ ---------------
6
+ When the skill prompts the user to pick a palette, they need to *see* each
7
+ palette in action — not just a colour-swatch list. The existing palette-picker
8
+ poster crams all 34 palettes into one tall image, which is hard to scan.
9
+
10
+ This script renders one standalone preview PNG per palette, each containing:
11
+ 1. Green header with palette name + LIKE watermark
12
+ 2. Kind badge + domain tags
13
+ 3. Colour swatches strip (all colours side-by-side)
14
+ 4. Five representative chart thumbnails (bar, line, scatter, pie, heatmap)
15
+
16
+ The 34 individual previews make it easy to scroll through and pick the right
17
+ one at a glance.
18
+
19
+ Usage
20
+ -----
21
+ palette_preview.py # generate all 34 previews
22
+ palette_preview.py --style nature # different journal style
23
+ palette_preview.py --force # overwrite existing
24
+ palette_preview.py --palettes wx01,wx06 # only specific palettes
25
+ palette_preview.py --dry-run # print plan, render nothing
26
+ palette_preview.py --manifest-only # regenerate manifest only
27
+ """
28
+ import argparse
29
+ import json
30
+ import os
31
+ import sys
32
+ import tempfile
33
+ import time
34
+ from concurrent.futures import ThreadPoolExecutor
35
+
36
+ SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
37
+ sys.path.insert(0, SCRIPT_DIR)
38
+
39
+ from registry import example_path, ZH_NAME
40
+ from render_utils import render_chart, load_fonts
41
+ from palettes import list_palettes, palette_info
42
+
43
+ SKILL_DIR = os.path.dirname(SCRIPT_DIR)
44
+ PREVIEW_DIR = os.path.join(SKILL_DIR, "previews")
45
+
46
+ # Five representative chart types for the preview
47
+ CHARTS = ["bar", "line", "scatter", "pie", "heatmap"]
48
+
49
+ # ── geometry ───────────────────────────────────────────────────────────────
50
+ W = 800 # canvas width (px)
51
+ HEADER_H = 64
52
+ INFO_H = 44
53
+ SWATCH_H = 36
54
+ PAD = 16
55
+ GAP = 8
56
+ CELL_W = (W - 2 * PAD - 2 * GAP) // 3 # 3 columns → 252 px
57
+ CELL_H = 196
58
+ LABEL_H = 22
59
+ FOOTER_H = 22
60
+ ROWS = 2
61
+ TOTAL_H = (HEADER_H + INFO_H + SWATCH_H + GAP
62
+ + ROWS * (CELL_H + LABEL_H) + (ROWS - 1) * GAP
63
+ + PAD + FOOTER_H)
64
+
65
+ KIND_ZH = {"qualitative": "定性", "sequential": "顺序", "diverging": "发散"}
66
+ KIND_RGB = {"qualitative": (0, 102, 204),
67
+ "sequential": (46, 125, 50),
68
+ "diverging": (183, 28, 28)}
69
+
70
+
71
+ def _render_chart_thumbs(palette_slug, style, tmpdir, charts=CHARTS):
72
+ """Render representative chart thumbnails for one palette."""
73
+ thumbs = {}
74
+ def _do(chart):
75
+ out = os.path.join(tmpdir, "%s_%s.png" % (palette_slug, chart))
76
+ data = example_path(chart)
77
+ if not data:
78
+ return chart, None
79
+ png, err, _ = render_chart(
80
+ chart, data, out, style=style, palette=palette_slug,
81
+ width=3.2, height=2.4, dpi=120,
82
+ extra_args=["--no-legend"])
83
+ return chart, png
84
+
85
+ with ThreadPoolExecutor(max_workers=len(charts)) as pool:
86
+ for chart, png in pool.map(_do, charts):
87
+ if png:
88
+ thumbs[chart] = png
89
+ return thumbs
90
+
91
+
92
+ def _compose_preview(thumbs, palette_slug, style, tmpdir):
93
+ """Compose the final preview image for one palette."""
94
+ from PIL import Image, ImageDraw, ImageFont
95
+
96
+ info = palette_info(palette_slug)
97
+ if not info:
98
+ return None
99
+
100
+ colors = list(info.get("colors") or [])
101
+ zh = info.get("zh") or palette_slug
102
+ kind = info.get("kind") or ""
103
+ domain_list = info.get("domain") or []
104
+
105
+ width = W
106
+ height = TOTAL_H
107
+ canvas = Image.new("RGB", (width, height), "white")
108
+ d = ImageDraw.Draw(canvas)
109
+ fonts, font_used = load_fonts((22, 18, 14, 12, 10))
110
+ f22 = fonts.get(22) or ImageFont.load_default()
111
+ f18 = fonts.get(18) or ImageFont.load_default()
112
+ f14 = fonts.get(14) or ImageFont.load_default()
113
+ f12 = fonts.get(12) or ImageFont.load_default()
114
+
115
+ # ── header ───────────────────────────────────────────────────────────
116
+ d.rectangle([0, 0, width, HEADER_H], fill="#0B6E4F")
117
+ d.text((PAD + 2, 10), "LIKE Sci Figures — 配色预览",
118
+ fill="white", font=f22)
119
+ d.text((PAD + 2, 40), "%s · 风格: %s" % (zh, style),
120
+ fill="#D7E1EB", font=f14)
121
+
122
+ # LIKE watermark (right-aligned)
123
+ tag = "LIKE"
124
+ bbox = d.textbbox((0, 0), tag, font=f18)
125
+ tag_w = bbox[2] - bbox[0]
126
+ d.text((width - PAD - tag_w - 2, 14), tag,
127
+ fill="#D7E1EB", font=f18)
128
+
129
+ # ── info strip ───────────────────────────────────────────────────────
130
+ y = HEADER_H
131
+ d.rectangle([0, y, width, y + INFO_H], fill="#F5F7FA")
132
+
133
+ # kind badge
134
+ lx = PAD + 4
135
+ badge_w = 58
136
+ d.rounded_rectangle(
137
+ [lx, y + 10, lx + badge_w, y + 10 + 24],
138
+ radius=4, fill=KIND_RGB.get(kind, (90, 90, 90)))
139
+ d.text((lx + 8, y + 14), KIND_ZH.get(kind, kind),
140
+ fill="white", font=f14)
141
+
142
+ # colour count
143
+ d.text((lx + badge_w + 10, y + 14),
144
+ "%d 色" % len(colors), fill="#555555", font=f14)
145
+
146
+ # domain tags
147
+ if domain_list:
148
+ domain_text = "适用:" + "/".join(domain_list[:3])
149
+ d.text((lx + badge_w + 60, y + 14), domain_text[:40],
150
+ fill="#777777", font=f14)
151
+
152
+ # ── colour swatches ─────────────────────────────────────────────────
153
+ y += INFO_H
154
+ d.rectangle([0, y, width, y + SWATCH_H], fill="#FAFBFC")
155
+
156
+ swatch_x = PAD + 4
157
+ max_colors = 20
158
+ swatch_w = min(28, (width - 2 * PAD - 12) // max_colors)
159
+ swatch_h = 22
160
+ swatch_y = y + (SWATCH_H - swatch_h) // 2
161
+ shown = min(len(colors), max_colors)
162
+ for i, hexc in enumerate(colors[:shown]):
163
+ cx = swatch_x + i * swatch_w
164
+ d.rectangle([cx, swatch_y, cx + swatch_w, swatch_y + swatch_h],
165
+ fill=hexc, outline="#CCCCCC", width=1)
166
+ if len(colors) > shown:
167
+ d.text((swatch_x + shown * swatch_w + 6, swatch_y + 4),
168
+ "+%d" % (len(colors) - shown), fill="#999999", font=f12)
169
+
170
+ # ── chart grid ───────────────────────────────────────────────────────
171
+ y += SWATCH_H + GAP
172
+ for idx, chart_type in enumerate(CHARTS):
173
+ col = idx % 3
174
+ row = idx // 3
175
+ x = PAD + col * (CELL_W + GAP)
176
+ cy = y + row * (CELL_H + LABEL_H + GAP)
177
+
178
+ # cell border
179
+ d.rectangle([x, cy, x + CELL_W, cy + CELL_H],
180
+ fill="white", outline="#DDDDDD")
181
+
182
+ # paste thumbnail
183
+ png = thumbs.get(chart_type)
184
+ if png and os.path.exists(png):
185
+ im = Image.open(png).convert("RGB")
186
+ if im.width != CELL_W or im.height != CELL_H:
187
+ im = im.resize((CELL_W, CELL_H), Image.LANCZOS)
188
+ canvas.paste(im, (x, cy))
189
+ else:
190
+ d.text((x + 10, cy + CELL_H // 2 - 10), "渲染失败",
191
+ fill="#999999", font=f14)
192
+
193
+ # label
194
+ label = "%s (%s)" % (ZH_NAME.get(chart_type, chart_type),
195
+ chart_type)
196
+ d.text((x + 4, cy + CELL_H + 4), label, fill="#111111", font=f14)
197
+
198
+ # ── footer ───────────────────────────────────────────────────────────
199
+ fy = height - FOOTER_H
200
+ bbox = d.textbbox((0, 0), tag, font=f12)
201
+ d.text((width - PAD - (bbox[2] - bbox[0]) - 2, fy + 4),
202
+ tag, fill="#999999", font=f12)
203
+
204
+ return canvas
205
+
206
+
207
+ def generate(style="nature", workers=4, force=False, dry_run=False,
208
+ manifest_only=False, palette_filter=None):
209
+ """Generate preview PNGs for all (or selected) palettes."""
210
+ all_slugs = [s for s in list_palettes() if palette_info(s)]
211
+
212
+ if palette_filter:
213
+ all_slugs = [s for s in all_slugs if s in palette_filter]
214
+
215
+ if dry_run:
216
+ print("would render %d previews -> %s" % (len(all_slugs), PREVIEW_DIR))
217
+ for slug in all_slugs:
218
+ print(" %-30s -> previews/%s.png" % (slug, slug))
219
+ return 0
220
+
221
+ if manifest_only:
222
+ # Just regenerate manifest from existing files
223
+ os.makedirs(PREVIEW_DIR, exist_ok=True)
224
+ manifest = _write_manifest(all_slugs, style)
225
+ print("Manifest regenerated: %s" % manifest)
226
+ return 0
227
+
228
+ os.makedirs(PREVIEW_DIR, exist_ok=True)
229
+ results = {}
230
+ errors = []
231
+ t0 = time.time()
232
+
233
+ for slug in all_slugs:
234
+ out_path = os.path.join(PREVIEW_DIR, "%s.png" % slug)
235
+ if os.path.exists(out_path) and not force:
236
+ print(" %-30s (exists, skipped)" % slug)
237
+ results[slug] = out_path
238
+ continue
239
+
240
+ print(" Rendering: %s ..." % slug)
241
+ tmpdir = tempfile.mkdtemp(prefix="sf_prev_%s_" % slug)
242
+ try:
243
+ thumbs = _render_chart_thumbs(slug, style, tmpdir)
244
+ canvas = _compose_preview(thumbs, slug, style, tmpdir)
245
+ if canvas:
246
+ canvas.save(out_path, dpi=(160, 160))
247
+ results[slug] = out_path
248
+ else:
249
+ errors.append("%s: compose returned None" % slug)
250
+ except Exception as exc:
251
+ errors.append("%s: %s" % (slug, str(exc)))
252
+
253
+ # cleanup
254
+ import shutil
255
+ shutil.rmtree(tmpdir, ignore_errors=True)
256
+
257
+ elapsed = time.time() - t0
258
+
259
+ # Write manifest
260
+ manifest = _write_manifest(all_slugs, style)
261
+
262
+ print("\nPreviews: %d ok, %d failed (%.1fs) -> %s"
263
+ % (len(results), len(errors), elapsed, PREVIEW_DIR))
264
+ if errors:
265
+ for e in errors:
266
+ print(" FAIL %s" % e, file=sys.stderr)
267
+ print("Manifest: %s" % manifest)
268
+ return 0 if not errors else 1
269
+
270
+
271
+ def _write_manifest(all_slugs, style):
272
+ """Write or update the preview manifest."""
273
+ from palettes import palette_info as _pi
274
+ manifest = {
275
+ "generated_at": time.strftime("%Y-%m-%d %H:%M:%S"),
276
+ "style": style,
277
+ "charts": CHARTS,
278
+ "previews": [],
279
+ }
280
+ for slug in all_slugs:
281
+ info = _pi(slug)
282
+ path = os.path.join(PREVIEW_DIR, "%s.png" % slug)
283
+ entry = {
284
+ "palette": slug,
285
+ "zh": info.get("zh", "") if info else "",
286
+ "kind": info.get("kind", "") if info else "",
287
+ "domain": info.get("domain", []) if info else [],
288
+ "file": "%s.png" % slug,
289
+ "status": "ok" if os.path.exists(path) else "missing",
290
+ }
291
+ if os.path.exists(path):
292
+ entry["bytes"] = os.path.getsize(path)
293
+ manifest["previews"].append(entry)
294
+
295
+ manifest_path = os.path.join(PREVIEW_DIR, "manifest.json")
296
+ with open(manifest_path, "w", encoding="utf-8") as fh:
297
+ json.dump(manifest, fh, ensure_ascii=False, indent=2)
298
+ fh.write("\n")
299
+ return manifest_path
300
+
301
+
302
+ def main():
303
+ ap = argparse.ArgumentParser(
304
+ prog="palette_preview.py",
305
+ description="Generate individual preview PNGs for each palette, "
306
+ "showing representative charts + colour swatches.")
307
+ ap.add_argument("--style", default="nature",
308
+ help="Journal style for chart thumbnails (default: %(default)s)")
309
+ ap.add_argument("--workers", type=int, default=4,
310
+ help="Parallel render workers (default: %(default)s)")
311
+ ap.add_argument("--force", action="store_true",
312
+ help="Overwrite existing preview images")
313
+ ap.add_argument("--dry-run", action="store_true",
314
+ help="Print plan without rendering")
315
+ ap.add_argument("--manifest-only", action="store_true",
316
+ help="Only regenerate manifest, skip rendering")
317
+ ap.add_argument("--palettes", default=None,
318
+ help="Comma-separated palette slugs to preview (default: all)")
319
+ args = ap.parse_args()
320
+
321
+ palette_filter = None
322
+ if args.palettes:
323
+ palette_filter = set(c.strip() for c in args.palettes.split(",") if c.strip())
324
+
325
+ return generate(
326
+ style=args.style, workers=args.workers,
327
+ force=args.force, dry_run=args.dry_run,
328
+ manifest_only=args.manifest_only,
329
+ palette_filter=palette_filter,
330
+ ) or 0
331
+
332
+
333
+ if __name__ == "__main__":
334
+ sys.exit(main())