pi-design-deck 0.2.0 → 0.3.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 (29) hide show
  1. package/README.md +49 -11
  2. package/deck-schema.ts +30 -0
  3. package/deck-server.ts +64 -10
  4. package/export-html.ts +329 -0
  5. package/form/css/controls.css +51 -0
  6. package/form/deck.html +2 -0
  7. package/form/js/deck-core.js +46 -0
  8. package/form/js/deck-interact.js +30 -12
  9. package/form/js/deck-render.js +2 -0
  10. package/form/js/deck-session.js +25 -9
  11. package/generate-prompts.ts +8 -10
  12. package/index.ts +317 -41
  13. package/package.json +2 -1
  14. package/prompts/deck-discover.md +3 -1
  15. package/prompts/deck-plan.md +3 -1
  16. package/prompts/deck.md +3 -1
  17. package/skills/design-deck/SKILL.md +44 -8
  18. package/skills/design-deck/references/component-gallery/INDEX.md +88 -0
  19. package/skills/design-deck/references/component-gallery/LOOKUP.md +592 -0
  20. package/skills/design-deck/references/component-gallery/components/INDEX.md +106 -0
  21. package/skills/design-deck/references/component-gallery/components/actions.md +354 -0
  22. package/skills/design-deck/references/component-gallery/components/data-display.md +812 -0
  23. package/skills/design-deck/references/component-gallery/components/feedback.md +513 -0
  24. package/skills/design-deck/references/component-gallery/components/inputs.md +921 -0
  25. package/skills/design-deck/references/component-gallery/components/layout.md +167 -0
  26. package/skills/design-deck/references/component-gallery/components/navigation.md +350 -0
  27. package/skills/design-deck/references/component-gallery/components/overlays.md +208 -0
  28. package/skills/design-deck/references/component-gallery/components/utilities.md +29 -0
  29. package/skills/design-deck/references/component-gallery/components.md +1383 -0
package/index.ts CHANGED
@@ -6,10 +6,11 @@ import * as os from "node:os";
6
6
  import * as fs from "node:fs";
7
7
  import { randomUUID } from "node:crypto";
8
8
  import { loadSettings } from "./settings.js";
9
- import { startDeckServer, type DeckServerHandle, type ModelInfo } from "./deck-server.js";
10
- import { isDeckOption, validateDeckConfig, validateSavedDeck } from "./deck-schema.js";
9
+ import { getDefaultSnapshotDir, startDeckServer, type DeckServerHandle, type ModelInfo } from "./deck-server.js";
10
+ import { deriveDeckStatusFromFolderName, isDeckOption, validateDeckConfig, validateSavedDeck, type SavedDeckData, type SavedDeckStatus } from "./deck-schema.js";
11
11
  import { buildGenerateMoreResult, buildRegenerateResult } from "./generate-prompts.js";
12
12
  import { generateWithModel } from "./model-runner.js";
13
+ import { buildStandaloneDeckHtml } from "./export-html.js";
13
14
 
14
15
  async function openUrl(pi: ExtensionAPI, url: string, browser?: string): Promise<void> {
15
16
  const platform = os.platform();
@@ -64,6 +65,24 @@ let restoreDeckThinking: (() => void) | null = null;
64
65
 
65
66
  const DECK_IDLE_TIMEOUT_MS = 5 * 60 * 1000;
66
67
 
68
+ interface SavedDeckListItem {
69
+ id: string;
70
+ title: string;
71
+ savedAt: string;
72
+ modifiedAt: string;
73
+ status: SavedDeckStatus;
74
+ cwd?: string;
75
+ branch?: string | null;
76
+ slideCount: number;
77
+ }
78
+
79
+ interface LoadedDeckSource {
80
+ configData: unknown;
81
+ savedSelections?: Record<string, string>;
82
+ savedNotes?: Record<string, { label: string; notes: string }>;
83
+ savedFinalNotes?: string;
84
+ }
85
+
67
86
  const DeckParams = Type.Object(
68
87
  {
69
88
  slides: Type.Optional(
@@ -78,11 +97,15 @@ const DeckParams = Type.Object(
78
97
  action: Type.Optional(
79
98
  Type.Union([
80
99
  Type.Literal("add-option", { description: "Push a single generated option into a running deck session" }),
100
+ Type.Literal("add-options", { description: "Push multiple generated options into a running deck session (blocks until next user action)" }),
81
101
  Type.Literal("replace-options", { description: "Replace all options for a slide with fresh alternatives" }),
102
+ Type.Literal("list", { description: "List saved decks from the snapshot directory" }),
103
+ Type.Literal("open", { description: "Open a saved deck by deck ID" }),
104
+ Type.Literal("export", { description: "Export a saved deck as standalone HTML" }),
82
105
  ])
83
106
  ),
84
107
  slideId: Type.Optional(
85
- Type.String({ description: "Target slide ID (required with action: 'add-option' or 'replace-options')" })
108
+ Type.String({ description: "Target slide ID (required with action: 'add-option', 'add-options', or 'replace-options')" })
86
109
  ),
87
110
  option: Type.Optional(
88
111
  Type.String({
@@ -93,9 +116,15 @@ const DeckParams = Type.Object(
93
116
  options: Type.Optional(
94
117
  Type.String({
95
118
  description:
96
- "JSON string of array of deck options (required with action: 'replace-options')",
119
+ "JSON string of array of deck options (required with action: 'add-options' or 'replace-options')",
97
120
  })
98
121
  ),
122
+ deckId: Type.Optional(
123
+ Type.String({ description: "Deck ID for open/export actions (folder name from list)" })
124
+ ),
125
+ format: Type.Optional(
126
+ Type.String({ description: "Export format: 'html' (default)" })
127
+ ),
99
128
  },
100
129
  { additionalProperties: false }
101
130
  );
@@ -111,6 +140,104 @@ function expandHome(value: string): string {
111
140
  return value;
112
141
  }
113
142
 
143
+ function resolveSnapshotDir(snapshotDir?: string): string {
144
+ return snapshotDir ? expandHome(snapshotDir) : getDefaultSnapshotDir();
145
+ }
146
+
147
+ function listSavedDecks(snapshotDir: string): { decks: SavedDeckListItem[]; warnings: string[] } {
148
+ if (!fs.existsSync(snapshotDir)) {
149
+ return { decks: [], warnings: [] };
150
+ }
151
+
152
+ const entries = fs.readdirSync(snapshotDir, { withFileTypes: true });
153
+ const decks: SavedDeckListItem[] = [];
154
+ const warnings: string[] = [];
155
+
156
+ for (const entry of entries) {
157
+ if (!entry.isDirectory()) continue;
158
+ const deckJsonPath = path.join(snapshotDir, entry.name, "deck.json");
159
+ if (!fs.existsSync(deckJsonPath)) continue;
160
+
161
+ try {
162
+ const raw = JSON.parse(fs.readFileSync(deckJsonPath, "utf-8"));
163
+ const saved = validateSavedDeck(raw);
164
+ decks.push({
165
+ id: saved.id ?? entry.name,
166
+ title: saved.config.title || "Design Deck",
167
+ savedAt: saved.savedAt,
168
+ modifiedAt: saved.modifiedAt ?? saved.savedAt,
169
+ status: saved.status ?? deriveDeckStatusFromFolderName(entry.name),
170
+ cwd: saved.savedFrom?.cwd,
171
+ branch: saved.savedFrom?.branch,
172
+ slideCount: saved.config.slides.length,
173
+ });
174
+ } catch (err) {
175
+ const message = err instanceof Error ? err.message : String(err);
176
+ warnings.push(`${entry.name}: ${message}`);
177
+ }
178
+ }
179
+
180
+ return { decks, warnings };
181
+ }
182
+
183
+ function resolveDeckFilePath(snapshotDir: string, deckId: string): string | null {
184
+ if (!deckId || deckId === "." || deckId === ".." || deckId.includes("/") || deckId.includes("\\")) {
185
+ return null;
186
+ }
187
+ const root = path.resolve(snapshotDir);
188
+ const deckDir = path.resolve(snapshotDir, deckId);
189
+ if (deckDir !== root && !deckDir.startsWith(root + path.sep)) {
190
+ return null;
191
+ }
192
+ return path.join(deckDir, "deck.json");
193
+ }
194
+
195
+ function buildSavedNotesForClient(saved: SavedDeckData): Record<string, { label: string; notes: string }> | undefined {
196
+ const savedNotesForClient: Record<string, { label: string; notes: string }> = {};
197
+ for (const [slideId, noteString] of Object.entries(saved.notes ?? {})) {
198
+ const label = saved.selections[slideId];
199
+ if (label && noteString) {
200
+ savedNotesForClient[slideId] = { label, notes: noteString };
201
+ }
202
+ }
203
+ return Object.keys(savedNotesForClient).length > 0 ? savedNotesForClient : undefined;
204
+ }
205
+
206
+ function loadDeckFile(absolutePath: string): LoadedDeckSource {
207
+ const content = fs.readFileSync(absolutePath, "utf-8");
208
+ let fileData: unknown;
209
+ try {
210
+ fileData = JSON.parse(content);
211
+ } catch (parseErr) {
212
+ const message = parseErr instanceof Error ? parseErr.message : String(parseErr);
213
+ throw new Error(`Invalid JSON in saved deck file: ${message}`);
214
+ }
215
+
216
+ const raw = fileData as Record<string, unknown>;
217
+ if (raw.config && typeof raw.config === "object") {
218
+ const saved = validateSavedDeck(fileData);
219
+ const snapshotDir = path.dirname(absolutePath);
220
+ for (const slide of saved.config.slides) {
221
+ for (const option of slide.options) {
222
+ if (!option.previewBlocks) continue;
223
+ for (const block of option.previewBlocks) {
224
+ if (block.type === "image" && !path.isAbsolute(block.src)) {
225
+ block.src = path.join(snapshotDir, block.src);
226
+ }
227
+ }
228
+ }
229
+ }
230
+ return {
231
+ configData: saved.config,
232
+ savedSelections: Object.keys(saved.selections).length > 0 ? saved.selections : undefined,
233
+ savedNotes: buildSavedNotesForClient(saved),
234
+ savedFinalNotes: saved.finalNotes,
235
+ };
236
+ }
237
+
238
+ return { configData: fileData };
239
+ }
240
+
114
241
  const DEFAULT_THEME_HOTKEY = "mod+shift+l";
115
242
 
116
243
  function clearDeckIdleTimer(): void {
@@ -207,19 +334,32 @@ export default function (pi: ExtensionAPI) {
207
334
  "Present a multi-slide design deck with visual options for decisions. " +
208
335
  "Slides JSON: { title?, slides: [{ id, title, context?, columns?, options }] }. " +
209
336
  "When the user requests more options, tool returns generate-more instructions — " +
210
- 'call design_deck with action:"add-option" to push into the live deck. ' +
337
+ 'call design_deck with action:"add-options" to push all new options at once. ' +
211
338
  "previewBlocks for code/architecture comparisons, previewHtml for custom UI mockups.",
212
339
  parameters: DeckParams,
213
340
 
214
341
  async execute(_toolCallId, params, signal, onUpdate, ctx) {
215
- if (!ctx.hasUI) {
342
+ const p = params as Record<string, unknown>;
343
+
344
+ if (!ctx.hasUI && p.action !== "list" && p.action !== "export") {
216
345
  throw new Error(
217
346
  "design_deck requires interactive mode with browser support. " +
218
347
  "Cannot run in headless/RPC/print mode."
219
348
  );
220
349
  }
221
350
 
222
- const p = params as Record<string, unknown>;
351
+ if (p.action === "list") {
352
+ const settings = loadSettings();
353
+ const snapshotDir = resolveSnapshotDir(settings.snapshotDir);
354
+ const { decks, warnings } = listSavedDecks(snapshotDir);
355
+ const content: Array<{ type: "text"; text: string }> = [
356
+ { type: "text", text: JSON.stringify(decks, null, 2) },
357
+ ];
358
+ if (warnings.length > 0) {
359
+ content.push({ type: "text", text: `Warnings:\n${warnings.map((w) => `- ${w}`).join("\n")}` });
360
+ }
361
+ return { content };
362
+ }
223
363
 
224
364
  if (p.action === "add-option") {
225
365
  if (typeof p.slideId !== "string" || p.slideId.trim() === "") {
@@ -236,6 +376,21 @@ export default function (pi: ExtensionAPI) {
236
376
  details: { status: "error", url: activeDeckServer?.handle.url ?? "" },
237
377
  };
238
378
  }
379
+ } else if (p.action === "add-options") {
380
+ if (typeof p.slideId !== "string" || p.slideId.trim() === "") {
381
+ activeDeckServer?.handle.cancelGenerate();
382
+ return {
383
+ content: [{ type: "text", text: 'add-options requires slideId (string).' }],
384
+ details: { status: "error", url: activeDeckServer?.handle.url ?? "" },
385
+ };
386
+ }
387
+ if (typeof p.options !== "string" || p.options.trim() === "") {
388
+ activeDeckServer?.handle.cancelGenerate();
389
+ return {
390
+ content: [{ type: "text", text: 'add-options requires options (JSON array string).' }],
391
+ details: { status: "error", url: activeDeckServer?.handle.url ?? "" },
392
+ };
393
+ }
239
394
  } else if (p.action === "replace-options") {
240
395
  if (typeof p.slideId !== "string" || p.slideId.trim() === "") {
241
396
  activeDeckServer?.handle.cancelGenerate();
@@ -251,6 +406,13 @@ export default function (pi: ExtensionAPI) {
251
406
  details: { status: "error", url: activeDeckServer?.handle.url ?? "" },
252
407
  };
253
408
  }
409
+ } else if (p.action === "open" || p.action === "export") {
410
+ if (typeof p.deckId !== "string" || p.deckId.trim() === "") {
411
+ return {
412
+ content: [{ type: "text", text: `${p.action} requires deckId (string). Example: { action: "${p.action}", deckId: "tabs-component-myapp-main-2026-03-01-103045-submitted" }` }],
413
+ details: { status: "error", url: activeDeckServer?.handle.url ?? "" },
414
+ };
415
+ }
254
416
  } else if (typeof p.slides !== "string" || p.slides.trim() === "") {
255
417
  return {
256
418
  content: [{
@@ -258,7 +420,10 @@ export default function (pi: ExtensionAPI) {
258
420
  text:
259
421
  "design_deck requires one of:\n\n" +
260
422
  '1. Start a new deck: { slides: "<JSON string of { title?, slides: [{ id, title, options }] }>" }\n' +
261
- '2. Add option to running deck: { action: "add-option", slideId: "...", option: "<JSON string>" }\n\n' +
423
+ '2. Open a saved deck: { action: "open", deckId: "..." }\n' +
424
+ '3. Export a saved deck: { action: "export", deckId: "...", format: "html" }\n' +
425
+ '4. Add options to running deck: { action: "add-options", slideId: "...", options: "[<JSON array>]" }\n' +
426
+ '5. Add single option: { action: "add-option", slideId: "...", option: "<JSON string>" }\n\n' +
262
427
  "Each option needs label + either previewHtml (raw HTML) or previewBlocks (array of {type, ...} blocks).\n" +
263
428
  "Block types: html, mermaid, code, image.",
264
429
  }],
@@ -332,6 +497,83 @@ export default function (pi: ExtensionAPI) {
332
497
  };
333
498
  }
334
499
 
500
+ if (p.action === "add-options") {
501
+ if (pendingDeckResult) {
502
+ const result = pendingDeckResult;
503
+ pendingDeckResult = null;
504
+ return result;
505
+ }
506
+
507
+ if (!activeDeckServer) {
508
+ return {
509
+ content: [{ type: "text", text: "No active design deck session. Start a new deck before adding options." }],
510
+ details: { status: "error", url: "" },
511
+ };
512
+ }
513
+
514
+ if (activeDeckServer.currentResolve !== null) {
515
+ return {
516
+ content: [{ type: "text", text: "Design deck is not waiting for new options right now." }],
517
+ details: { status: "error", url: activeDeckServer.handle.url },
518
+ };
519
+ }
520
+
521
+ const slideId = p.slideId as string;
522
+ const optionsStr = p.options as string;
523
+
524
+ let parsedOptions: unknown;
525
+ try {
526
+ parsedOptions = JSON.parse(optionsStr);
527
+ } catch (err) {
528
+ activeDeckServer.handle.cancelGenerate();
529
+ const message = err instanceof Error ? err.message : String(err);
530
+ const snippet = optionsStr.length > 300 ? optionsStr.slice(0, 300) + "..." : optionsStr;
531
+ return {
532
+ content: [{ type: "text", text: `Invalid options JSON: ${message}\n\nReceived:\n${snippet}\n\nFix the JSON and call design_deck add-options again.` }],
533
+ details: { status: "error", url: activeDeckServer.handle.url },
534
+ };
535
+ }
536
+
537
+ if (!Array.isArray(parsedOptions)) {
538
+ activeDeckServer.handle.cancelGenerate();
539
+ return {
540
+ content: [{ type: "text", text: "options must be a JSON array of deck options. Fix and call design_deck add-options again." }],
541
+ details: { status: "error", url: activeDeckServer.handle.url },
542
+ };
543
+ }
544
+
545
+ for (const opt of parsedOptions) {
546
+ if (!isDeckOption(opt)) {
547
+ activeDeckServer.handle.cancelGenerate();
548
+ return {
549
+ content: [{ type: "text", text: "One or more options in the array are invalid — each needs label and either previewHtml or previewBlocks. Fix and call design_deck add-options again." }],
550
+ details: { status: "error", url: activeDeckServer.handle.url },
551
+ };
552
+ }
553
+ }
554
+
555
+ try {
556
+ for (const opt of parsedOptions) {
557
+ activeDeckServer.handle.pushOption(slideId, opt);
558
+ }
559
+ } catch (err) {
560
+ const message = err instanceof Error ? err.message : String(err);
561
+ return {
562
+ content: [{ type: "text", text: `Failed to push options: ${message}` }],
563
+ details: { status: "error", url: activeDeckServer.handle.url },
564
+ };
565
+ }
566
+
567
+ if (onUpdate) {
568
+ onUpdate({
569
+ content: [{ type: "text", text: `Pushed ${parsedOptions.length} options to slide ${slideId}.` }],
570
+ details: { status: "generate-more", url: activeDeckServer.handle.url, slideId },
571
+ });
572
+ }
573
+ attachDeckAbortHandler(signal);
574
+ return blockOnDeck();
575
+ }
576
+
335
577
  if (p.action === "replace-options") {
336
578
  if (pendingDeckResult) {
337
579
  const result = pendingDeckResult;
@@ -409,7 +651,7 @@ export default function (pi: ExtensionAPI) {
409
651
 
410
652
  pendingDeckResult = null;
411
653
 
412
- if (activeDeckServer) {
654
+ if (activeDeckServer && p.action !== "export") {
413
655
  return {
414
656
  content: [
415
657
  {
@@ -421,10 +663,61 @@ export default function (pi: ExtensionAPI) {
421
663
  };
422
664
  }
423
665
 
424
- const slides = p.slides as string;
666
+ const settings = loadSettings();
667
+ const snapshotDir = resolveSnapshotDir(settings.snapshotDir);
668
+ let slides = p.slides as string;
669
+ if (p.action === "open" || p.action === "export") {
670
+ const deckId = (p.deckId as string).trim();
671
+ const deckPath = resolveDeckFilePath(snapshotDir, deckId);
672
+ if (!deckPath || !fs.existsSync(deckPath)) {
673
+ const { decks } = listSavedDecks(snapshotDir);
674
+ const availableHint = decks.length > 0
675
+ ? ` Available deck IDs: ${decks.slice(0, 10).map((deck) => deck.id).join(", ")}`
676
+ : " Snapshot directory is empty.";
677
+ return {
678
+ content: [{ type: "text", text: `Saved deck "${deckId}" not found in ${snapshotDir}.${availableHint}` }],
679
+ details: { status: "error", url: "" },
680
+ };
681
+ }
682
+ if (p.action === "export") {
683
+ const format = typeof p.format === "string" && p.format.trim() !== "" ? p.format.trim().toLowerCase() : "html";
684
+ if (format !== "html") {
685
+ return {
686
+ content: [{ type: "text", text: `Unsupported export format: ${format}` }],
687
+ details: { status: "error", url: "" },
688
+ };
689
+ }
690
+ try {
691
+ const saved = validateSavedDeck(JSON.parse(fs.readFileSync(deckPath, "utf-8")));
692
+ const enrichedSaved: SavedDeckData = {
693
+ ...saved,
694
+ id: saved.id ?? deckId,
695
+ status: saved.status ?? deriveDeckStatusFromFolderName(deckId),
696
+ };
697
+ const html = buildStandaloneDeckHtml(deckPath, enrichedSaved);
698
+ const exportPath = path.join(path.dirname(deckPath), "export.html");
699
+ fs.writeFileSync(exportPath, html, "utf-8");
700
+ const relativePath = exportPath.startsWith(os.homedir())
701
+ ? "~" + exportPath.slice(os.homedir().length)
702
+ : exportPath;
703
+ return {
704
+ content: [{ type: "text", text: `Exported HTML to ${relativePath}` }],
705
+ };
706
+ } catch (err) {
707
+ const message = err instanceof Error ? err.message : String(err);
708
+ return {
709
+ content: [{ type: "text", text: `Failed to export deck "${deckId}": ${message}` }],
710
+ details: { status: "error", url: "" },
711
+ };
712
+ }
713
+ }
714
+ slides = deckPath;
715
+ }
425
716
 
426
717
  let configData: unknown;
427
718
  let savedSelections: Record<string, string> | undefined;
719
+ let savedNotes: Record<string, { label: string; notes: string }> | undefined;
720
+ let savedFinalNotes: string | undefined;
428
721
  try {
429
722
  configData = JSON.parse(slides);
430
723
  } catch {
@@ -433,37 +726,13 @@ export default function (pi: ExtensionAPI) {
433
726
  if (!fs.existsSync(absolutePath)) {
434
727
  throw new Error(`Invalid slides: not valid JSON and file not found at ${absolutePath}`);
435
728
  }
436
- const content = fs.readFileSync(absolutePath, "utf-8");
437
- let fileData: unknown;
438
- try {
439
- fileData = JSON.parse(content);
440
- } catch (parseErr) {
441
- const message = parseErr instanceof Error ? parseErr.message : String(parseErr);
442
- throw new Error(`Invalid JSON in saved deck file: ${message}`);
443
- }
444
- const raw = fileData as Record<string, unknown>;
445
- if (raw.config && typeof raw.config === "object") {
446
- const saved = validateSavedDeck(fileData);
447
- configData = saved.config;
448
- savedSelections = Object.keys(saved.selections).length > 0 ? saved.selections : undefined;
449
- const snapshotDir = path.dirname(absolutePath);
450
- for (const slide of saved.config.slides) {
451
- for (const option of slide.options) {
452
- if (!option.previewBlocks) continue;
453
- for (const block of option.previewBlocks) {
454
- if (block.type === "image" && !path.isAbsolute(block.src)) {
455
- block.src = path.join(snapshotDir, block.src);
456
- }
457
- }
458
- }
459
- }
460
- } else {
461
- configData = fileData;
462
- }
729
+ const loaded = loadDeckFile(absolutePath);
730
+ configData = loaded.configData;
731
+ savedSelections = loaded.savedSelections;
732
+ savedNotes = loaded.savedNotes;
733
+ savedFinalNotes = loaded.savedFinalNotes;
463
734
  }
464
735
  const config = validateDeckConfig(configData);
465
-
466
- const settings = loadSettings();
467
736
  const sessionId = randomUUID();
468
737
  const sessionToken = randomUUID();
469
738
 
@@ -567,8 +836,6 @@ export default function (pi: ExtensionAPI) {
567
836
  };
568
837
 
569
838
  const themeConfig = settings.theme ?? {};
570
- const snapshotDir = settings.snapshotDir ? expandHome(settings.snapshotDir) : undefined;
571
-
572
839
  const currentModelStr = ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : null;
573
840
  const availableModels: ModelInfo[] = ctx.modelRegistry.getAvailable().map((m) => ({
574
841
  provider: m.provider,
@@ -592,6 +859,8 @@ export default function (pi: ExtensionAPI) {
592
859
  toggleHotkey: themeConfig.toggleHotkey ?? DEFAULT_THEME_HOTKEY,
593
860
  },
594
861
  savedSelections,
862
+ savedNotes,
863
+ savedFinalNotes,
595
864
  snapshotDir,
596
865
  autoSaveOnSubmit: settings.autoSaveOnSubmit ?? true,
597
866
  models: {
@@ -640,6 +909,13 @@ export default function (pi: ExtensionAPI) {
640
909
  0
641
910
  );
642
911
  }
912
+ if (data.action === "add-options") {
913
+ return new Text(
914
+ theme.fg("toolTitle", theme.bold(`Design Deck: add options (${data.slideId || "unknown"})`)),
915
+ 0,
916
+ 0
917
+ );
918
+ }
643
919
  if (data.action === "replace-options") {
644
920
  return new Text(
645
921
  theme.fg("toolTitle", theme.bold(`Design Deck: replace options (${data.slideId || "unknown"})`)),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-design-deck",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Visual design deck for presenting multi-slide options with high-fidelity previews",
5
5
  "author": "Nico Bailon",
6
6
  "license": "MIT",
@@ -16,6 +16,7 @@
16
16
  "model-runner.ts",
17
17
  "server-utils.ts",
18
18
  "settings.ts",
19
+ "export-html.ts",
19
20
  "index.ts"
20
21
  ],
21
22
  "scripts": {
@@ -1,7 +1,9 @@
1
1
  ---
2
2
  description: Interview to gather requirements, then present design options
3
3
  ---
4
- Load the `design-deck` skill for the full format reference. This is a two-phase flow.
4
+ Load the `design-deck` skill for the full format reference. If presenting UI component options (tabs, trees, buttons, accordions, etc.), also read the relevant file from the skill's `references/component-gallery/` directory — it has visual vocabulary across design systems plus guidance on when to use distinct systems vs variations of the same approach.
5
+
6
+ This is a two-phase flow.
5
7
 
6
8
  **Phase 1 — Discovery.** Before proposing anything, interview me in depth. Read relevant codebase context first so your questions are informed. Then hit me with detailed questions about goals, constraints, audience, aesthetics, technical requirements, trade-offs, edge cases, and anything else that would affect the design options. No obvious questions. Keep interviewing in multiple rounds if earlier answers surface new dimensions worth exploring — only move on when you have enough to generate genuinely distinct options.
7
9
 
@@ -1,7 +1,9 @@
1
1
  ---
2
2
  description: Analyze a plan/PRD and present design & architecture options
3
3
  ---
4
- Load the `design-deck` skill for the full format reference. Then read and analyze the plan or PRD at `$1`. Also read the actual codebase files it references — in full to understand the real state of the code, not just what the plan assumes.
4
+ Load the `design-deck` skill for the full format reference. If presenting UI component options (tabs, trees, buttons, accordions, etc.), also read the relevant file from the skill's `references/component-gallery/` directory it has visual vocabulary across design systems plus guidance on when to use distinct systems vs variations of the same approach.
5
+
6
+ Then read and analyze the plan or PRD at `$1`. Also read the actual codebase files it references — in full — to understand the real state of the code, not just what the plan assumes.
5
7
 
6
8
  Identify the key design and architecture decisions embedded in the plan. For each decision point, build a slide with 2-4 concrete options that are faithful to the plan's goals but offer genuinely different approaches. Mix preview types as appropriate: mermaid diagrams for architecture, code blocks for API design, HTML for UI mockups, images if available.
7
9
 
package/prompts/deck.md CHANGED
@@ -1,7 +1,9 @@
1
1
  ---
2
2
  description: Present visual design options via design deck
3
3
  ---
4
- Load the `design-deck` skill for the full format reference. Analyze the current codebase and context, and present concrete visual options using `design_deck`.
4
+ Load the `design-deck` skill for the full format reference. If presenting UI component options (tabs, trees, buttons, accordions, etc.), also read the relevant file from the skill's `references/component-gallery/` directory — it has visual vocabulary across design systems plus guidance on when to use distinct systems vs variations of the same approach.
5
+
6
+ Analyze the current codebase and context, and present concrete visual options using `design_deck`.
5
7
 
6
8
  Read relevant files aggressively first so you understand the real constraints before generating options. If there's a plan or PRD, read it in full along with every codebase file it references — understand the actual state of the code, not just what the document assumes. Then identify the key design and architecture decisions and present options for each.
7
9
 
@@ -3,6 +3,8 @@ name: design-deck
3
3
  description: Present visual options for architecture, UI, and code decisions with high-fidelity side-by-side previews. For comparing approaches visually — code diffs, diagrams, UI mockups, images — not for gathering structured input (use interview for that). Supports previewBlocks (code, mermaid, image, html), previewHtml, generate-more loops, and plan/PRD-driven flows.
4
4
  ---
5
5
 
6
+ > **`design_deck` is a direct tool — call it directly, not via MCP.**
7
+
6
8
  # Design Deck Workflow
7
9
 
8
10
  Use this skill when the task requires presenting multiple visual directions and collecting explicit user choices. Load this skill before building any deck to get the full format reference.
@@ -201,6 +203,40 @@ surf gemini "isometric database cluster, dark theme, blue nodes" \
201
203
  **Combine images with other blocks:**
202
204
  An image showing the visual direction paired with a code block showing the implementation approach, or a mermaid diagram showing data flow alongside a generated image showing the UI that flow produces.
203
205
 
206
+ ### Component Gallery Reference
207
+
208
+ When generating UI component options (tabs, accordions, tree views, buttons, etc.), read `./references/component-gallery/components.md` for best practices, common layouts, and aliases for 60 UI components.
209
+
210
+ The reference enables three things:
211
+ - **Discovery** — list, find, and suggest components for a use case ("I need expandable content" → accordion, disclosure, details)
212
+ - **Cross-referencing** — connect related terms (collapse = accordion = disclosure = expander; notification = alert = banner)
213
+ - **Design vocabulary** — know what design systems look like (Blueprint = dense, dark-native; Ant = clean, blue primary)
214
+
215
+ The `INDEX.md` provides a design system vocabulary table (Ant Design, Blueprint, Carbon, Material, etc.) and guidance on when to use distinct systems vs variations.
216
+
217
+ **Decide based on context:**
218
+ - **Distinct systems** (Blueprint vs Ant vs 98.css) when exploring the design space with no established aesthetic
219
+ - **Variations within a system** when the project already has a design direction or the user specifies a style
220
+
221
+ See `./references/component-gallery/INDEX.md` for the decision table and examples.
222
+
223
+ **Proactively browse real examples:** When you need visual inspiration for a component, fetch the component.gallery page directly:
224
+
225
+ - https://component.gallery/components/tabs/ — 80+ real tab implementations
226
+ - https://component.gallery/components/accordion/ — 100+ accordion examples
227
+ - https://component.gallery/components/button/ — 120+ button styles
228
+
229
+ Each page shows real screenshots from Ant Design, Blueprint, Carbon, Material, Shopify Polaris, and 90+ other design systems. Useful when you need concrete visual references — not required for every deck.
230
+
231
+ **When user vocabulary is unclear or ambiguous:** Read `./references/component-gallery/LOOKUP.md` to resolve user terms to canonical component names. The lookup file provides:
232
+
233
+ 1. **Alias Index** — Direct mappings like `collapse → Accordion`, `snackbar → Toast`
234
+ 2. **Disambiguation** — Rules for ambiguous terms like `dropdown` (Select? Combobox? Dropdown menu?) or `popup` (Modal? Popover? Tooltip?)
235
+ 3. **Intent Clusters** — When users describe what they're trying to do ("I need users to pick from options"), maps constraints to components
236
+ 4. **Clarification Templates** — Suggested questions when disambiguation is needed
237
+
238
+ Use the lookup before generating options if the user's component vocabulary seems imprecise or you're unsure which component they mean.
239
+
204
240
  ### For Mixed Previews
205
241
 
206
242
  Many slides benefit from combining block types — a mermaid diagram showing structure alongside a code block showing usage, or an HTML mockup with a code block showing the component API.
@@ -220,27 +256,27 @@ Use these selections as the implementation contract for the final build.
220
256
 
221
257
  ## Generate-More Loop
222
258
 
223
- When the user clicks generate-more, `design_deck` returns a result instructing which slide needs another option and listing existing option labels.
259
+ When the user clicks generate-more, `design_deck` returns a result instructing which slide needs options and how many, along with the existing option labels.
224
260
 
225
- Generate one new option that is meaningfully distinct from the listed options.
261
+ Generate the requested number of new options, each meaningfully distinct from the existing ones.
226
262
 
227
- Re-invoke:
263
+ Re-invoke with all options in a single call:
228
264
 
229
- `design_deck({ action: "add-option", slideId: "...", option: "{...json string...}" })`
265
+ `design_deck({ action: "add-options", slideId: "...", options: "[{...}, {...}]" })`
230
266
 
231
- The `add-option` call pushes the new option into the live deck and blocks again for the next user action.
267
+ The `add-options` call pushes all options into the live deck and blocks for the next user action. Use `add-options` (plural) for generate-more requests — it takes an array and handles blocking automatically.
232
268
 
233
269
  ### Model Override
234
270
 
235
271
  The deck shows a model dropdown below the header (when 2+ models are available). Users can pick which model generates new options and optionally save it as the default.
236
272
 
237
- When the generate-more result includes a model instruction (e.g. "Generate using model X via deck_generate"), use the built-in `deck_generate` tool to generate the option with that model:
273
+ When the generate-more result includes a model instruction (e.g. "Generate using model X via deck_generate"), use the built-in `deck_generate` tool to generate the options with that model:
238
274
 
239
275
  ```
240
- deck_generate({ model: "google/gemini-3.1-pro", task: "Generate a JSON deck option..." })
276
+ deck_generate({ model: "google/gemini-3.1-pro", task: "Generate JSON deck options..." })
241
277
  ```
242
278
 
243
- Parse the output as the option JSON and pass it to `add-option`.
279
+ Parse the output as the options JSON array and pass it to `add-options`.
244
280
 
245
281
  The default model can also be set in `~/.pi/agent/settings.json`:
246
282