pi-design-deck 0.1.1 → 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 (31) hide show
  1. package/README.md +54 -13
  2. package/deck-schema.ts +30 -0
  3. package/deck-server.ts +76 -19
  4. package/export-html.ts +329 -0
  5. package/form/css/controls.css +171 -0
  6. package/form/css/layout.css +56 -0
  7. package/form/css/preview.css +60 -6
  8. package/form/deck.html +2 -0
  9. package/form/js/deck-core.js +60 -2
  10. package/form/js/deck-interact.js +63 -19
  11. package/form/js/deck-render.js +95 -6
  12. package/form/js/deck-session.js +140 -27
  13. package/generate-prompts.ts +18 -12
  14. package/index.ts +364 -66
  15. package/package.json +2 -1
  16. package/prompts/deck-discover.md +3 -1
  17. package/prompts/deck-plan.md +3 -1
  18. package/prompts/deck.md +3 -1
  19. package/skills/design-deck/SKILL.md +44 -8
  20. package/skills/design-deck/references/component-gallery/INDEX.md +88 -0
  21. package/skills/design-deck/references/component-gallery/LOOKUP.md +592 -0
  22. package/skills/design-deck/references/component-gallery/components/INDEX.md +106 -0
  23. package/skills/design-deck/references/component-gallery/components/actions.md +354 -0
  24. package/skills/design-deck/references/component-gallery/components/data-display.md +812 -0
  25. package/skills/design-deck/references/component-gallery/components/feedback.md +513 -0
  26. package/skills/design-deck/references/component-gallery/components/inputs.md +921 -0
  27. package/skills/design-deck/references/component-gallery/components/layout.md +167 -0
  28. package/skills/design-deck/references/component-gallery/components/navigation.md +350 -0
  29. package/skills/design-deck/references/component-gallery/components/overlays.md +208 -0
  30. package/skills/design-deck/references/component-gallery/components/utilities.md +29 -0
  31. package/skills/design-deck/references/component-gallery/components.md +1383 -0
@@ -41,11 +41,26 @@ function showSaveToast(message, isError) {
41
41
  saveToastTimer = setTimeout(() => { toast.classList.add("hidden"); saveToastTimer = null; }, 3000);
42
42
  }
43
43
 
44
+ function buildSelectedNotes() {
45
+ const notes = {};
46
+ for (const [slideId, noteData] of Object.entries(optionNotes)) {
47
+ if (noteData && selections[slideId] === noteData.label && noteData.notes) {
48
+ notes[slideId] = noteData.notes;
49
+ }
50
+ }
51
+ return notes;
52
+ }
53
+
44
54
  async function saveDeck() {
45
55
  if (isClosed) return;
46
56
  try {
47
- const result = await postJson("/save", { token: sessionToken, selections });
48
- if (result.ok) showSaveToast(`Saved to ${result.relativePath}`);
57
+ const payload = { token: sessionToken, selections, notes: buildSelectedNotes() };
58
+ if (finalNotes) payload.finalNotes = finalNotes;
59
+ const result = await postJson("/save", payload);
60
+ if (result.ok) {
61
+ markSaved(new Date().toISOString());
62
+ showSaveToast(`Saved to ${result.relativePath}`);
63
+ }
49
64
  else showSaveToast(result.error || "Save failed", true);
50
65
  } catch {
51
66
  showSaveToast("Save failed", true);
@@ -71,10 +86,11 @@ function stopHeartbeat() {
71
86
  function disableDeckInteractions() {
72
87
  if (btnBack) btnBack.disabled = true;
73
88
  if (btnNext) btnNext.disabled = true;
89
+ if (btnSave) btnSave.disabled = true;
74
90
  document.querySelectorAll(".btn-gen-more, .btn-regen").forEach((button) => {
75
91
  button.disabled = true;
76
92
  });
77
- document.querySelectorAll(".gen-prompt").forEach((input) => {
93
+ document.querySelectorAll(".gen-prompt, .gen-count").forEach((input) => {
78
94
  input.disabled = true;
79
95
  });
80
96
  document.querySelectorAll(".model-pill, .model-list-item, .model-default-check").forEach((el) => {
@@ -98,7 +114,10 @@ async function submitDeck() {
98
114
  }
99
115
 
100
116
  try {
101
- await postJson("/submit", { token: sessionToken, selections });
117
+ const notes = buildSelectedNotes();
118
+ const payload = { token: sessionToken, selections, notes };
119
+ if (finalNotes) payload.finalNotes = finalNotes;
120
+ await postJson("/submit", payload);
102
121
  clearSelectionsStorage();
103
122
  isClosed = true;
104
123
  if (submitButton) {
@@ -242,16 +261,22 @@ function restoreGenerateButton(slideId) {
242
261
  const pending = pendingGenerate.get(slideId);
243
262
  if (!pending || pending.isRegen) return;
244
263
 
245
- if (pending.skeleton && pending.skeleton.parentElement) {
246
- pending.skeleton.remove();
264
+ // Clear timeout if any
265
+ if (pending.timeoutId) clearTimeout(pending.timeoutId);
266
+
267
+ // Remove all skeletons from DOM (query directly, don't rely on array)
268
+ const slideElement = document.querySelector(`.slide[data-id="${CSS.escape(slideId)}"]`);
269
+ if (slideElement) {
270
+ slideElement.querySelectorAll(".option-skeleton").forEach((skel) => skel.remove());
247
271
  }
272
+
248
273
  pending.button.classList.remove("loading");
249
274
  const plus = pending.button.querySelector(".btn-gen-plus");
250
275
  if (plus) plus.textContent = "+";
251
- if (pending.button.childNodes[1]) {
252
- pending.button.childNodes[1].textContent = pending.originalText;
253
- }
276
+ pending.button.lastChild.textContent = "Generate";
277
+
254
278
  if (pending.input && !isClosed) pending.input.disabled = false;
279
+ if (pending.countSelect && !isClosed) pending.countSelect.disabled = false;
255
280
 
256
281
  pendingGenerate.delete(slideId);
257
282
  }
@@ -292,6 +317,7 @@ function insertGeneratedOption(slideId, option, model) {
292
317
  if (current === totalSlides - 1) {
293
318
  updateSummary();
294
319
  }
320
+ markDirty();
295
321
  }
296
322
 
297
323
  function replaceSlideOptions(slideId, newOptions) {
@@ -301,6 +327,7 @@ function replaceSlideOptions(slideId, newOptions) {
301
327
  slide.options = newOptions;
302
328
 
303
329
  delete selections[slideId];
330
+ delete optionNotes[slideId];
304
331
  saveSelectionsToStorage();
305
332
 
306
333
  const slideElement = document.querySelector(`.slide[data-id="${CSS.escape(slideId)}"]`);
@@ -309,16 +336,28 @@ function replaceSlideOptions(slideId, newOptions) {
309
336
  const optionsGrid = slideElement.querySelector(".options");
310
337
  if (!optionsGrid) return;
311
338
 
339
+ // Remove regeneration overlay and state
340
+ const overlay = optionsGrid.querySelector(".regen-overlay");
341
+ if (overlay) overlay.remove();
342
+ optionsGrid.classList.remove("regenerating");
343
+ optionsGrid.style.position = "";
344
+
312
345
  optionsGrid.innerHTML = "";
313
- optionsGrid.style.opacity = "";
314
- optionsGrid.style.pointerEvents = "";
315
346
  optionsGrid.className = `options ${optionCountClass(newOptions.length, slide.columns)}`;
316
347
 
317
348
  newOptions.forEach((option) => {
318
349
  const card = createOptionCard(option, slideId, false);
350
+ card.classList.add("option-regenerated");
319
351
  optionsGrid.appendChild(card);
320
352
  });
321
353
 
354
+ // Remove animation class after animation completes
355
+ setTimeout(() => {
356
+ optionsGrid.querySelectorAll(".option-regenerated").forEach((el) => {
357
+ el.classList.remove("option-regenerated");
358
+ });
359
+ }, 500);
360
+
322
361
  equalizeBlockHeights(slideElement);
323
362
 
324
363
  const pick = slideElement.querySelector(".slide-pick");
@@ -327,6 +366,7 @@ function replaceSlideOptions(slideId, newOptions) {
327
366
  if (current === totalSlides - 1) {
328
367
  updateSummary();
329
368
  }
369
+ markDirty();
330
370
  }
331
371
 
332
372
  function connectEvents() {
@@ -343,9 +383,37 @@ function connectEvents() {
343
383
  if (!payload || typeof payload.slideId !== "string" || !payload.option) {
344
384
  return;
345
385
  }
346
- const model = pendingGenerate.get(payload.slideId)?.model || null;
347
- restoreGenerateButton(payload.slideId);
386
+ const pending = pendingGenerate.get(payload.slideId);
387
+ const model = pending?.model || null;
388
+
389
+ // Remove one skeleton
390
+ if (pending?.skeletons?.length > 0) {
391
+ const skel = pending.skeletons.shift();
392
+ if (skel.parentElement) skel.remove();
393
+ }
394
+
348
395
  insertGeneratedOption(payload.slideId, payload.option, model);
396
+
397
+ // Track received count and restore button when all received
398
+ if (pending) {
399
+ pending.receivedCount = (pending.receivedCount || 0) + 1;
400
+ if (pending.receivedCount >= (pending.expectedCount || 1)) {
401
+ restoreGenerateButton(payload.slideId);
402
+ } else {
403
+ // Reset timeout for next option
404
+ if (pending.timeoutId) clearTimeout(pending.timeoutId);
405
+ pending.timeoutId = setTimeout(() => {
406
+ const current = pendingGenerate.get(payload.slideId);
407
+ if (!current || current.isRegen) return;
408
+ const received = current.receivedCount || 0;
409
+ const expected = current.expectedCount || 1;
410
+ restoreGenerateButton(payload.slideId);
411
+ if (received < expected) {
412
+ showSaveToast(`Generated ${received} of ${expected} options`, true);
413
+ }
414
+ }, 30000);
415
+ }
416
+ }
349
417
  });
350
418
 
351
419
  events.addEventListener("generate-failed", (event) => {
@@ -359,6 +427,8 @@ function connectEvents() {
359
427
  restoreGenerateButton(payload.slideId);
360
428
  if (payload.reason === "timeout") {
361
429
  showSaveToast("Generation timed out — try again", true);
430
+ } else {
431
+ showSaveToast("Generation failed", true);
362
432
  }
363
433
  }
364
434
  });
@@ -388,6 +458,8 @@ function connectEvents() {
388
458
  restoreRegenButton(payload.slideId);
389
459
  if (payload.reason === "timeout") {
390
460
  showSaveToast("Regeneration timed out — try again", true);
461
+ } else {
462
+ showSaveToast("Regeneration failed", true);
391
463
  }
392
464
  }
393
465
  });
@@ -415,7 +487,7 @@ function connectEvents() {
415
487
  });
416
488
  }
417
489
 
418
- async function generateMore(button, slideId, input) {
490
+ async function generateMore(button, slideId, input, countSelect) {
419
491
  if (isClosed || pendingGenerate.size > 0) return;
420
492
 
421
493
  const slideElement = document.querySelector(`.slide[data-id="${CSS.escape(slideId)}"]`);
@@ -428,21 +500,42 @@ async function generateMore(button, slideId, input) {
428
500
 
429
501
  const prompt = input ? input.value.trim() : "";
430
502
  if (input) input.value = "";
503
+
504
+ const count = countSelect ? parseInt(countSelect.value, 10) || 1 : 1;
505
+
506
+ // Create skeletons for each option being generated
507
+ const skeletons = [];
508
+ for (let i = 0; i < count; i++) {
509
+ const skeleton = createElement("div", "option-skeleton");
510
+ optionsGrid.appendChild(skeleton);
511
+ skeletons.push(skeleton);
512
+ }
431
513
 
432
- const skeleton = createElement("div", "option-skeleton");
433
- optionsGrid.appendChild(skeleton);
434
-
435
- const originalText = button.childNodes[1] ? button.childNodes[1].textContent || "" : "";
436
514
  button.classList.add("loading");
437
515
  const plus = button.querySelector(".btn-gen-plus");
438
516
  if (plus) plus.textContent = "";
439
- if (button.childNodes[1]) button.childNodes[1].textContent = " Generating...";
517
+ button.lastChild.textContent = "Generating...";
440
518
  if (input) input.disabled = true;
519
+ if (countSelect) countSelect.disabled = true;
520
+
521
+ // Set timeout for generation (30s per option)
522
+ const timeoutId = setTimeout(() => {
523
+ const pending = pendingGenerate.get(slideId);
524
+ if (pending && !pending.isRegen) {
525
+ const received = pending.receivedCount || 0;
526
+ restoreGenerateButton(slideId);
527
+ if (received === 0) {
528
+ showSaveToast("Generation timed out — try again", true);
529
+ } else {
530
+ showSaveToast(`Generated ${received} of ${count} options`, true);
531
+ }
532
+ }
533
+ }, 30000 * count);
441
534
 
442
- pendingGenerate.set(slideId, { button, skeleton, originalText, input, model: selectedModel || null });
535
+ pendingGenerate.set(slideId, { button, skeletons, input, countSelect, model: selectedModel || null, expectedCount: count, receivedCount: 0, timeoutId });
443
536
 
444
537
  try {
445
- const body = { token: sessionToken, slideId };
538
+ const body = { token: sessionToken, slideId, count };
446
539
  if (prompt) body.prompt = prompt;
447
540
  if (hasModelBar) {
448
541
  body.model = selectedModel;
@@ -470,8 +563,21 @@ async function regenerateSlide(button, slideId) {
470
563
  const prompt = input ? input.value.trim() : "";
471
564
  if (input) input.value = "";
472
565
 
473
- optionsGrid.style.opacity = "0.4";
474
- optionsGrid.style.pointerEvents = "none";
566
+ // Create skeleton overlay
567
+ const optionCount = slide.options?.length || 2;
568
+ const colClass = optionsGrid.classList.contains("cols-3") ? "cols-3" :
569
+ optionsGrid.classList.contains("cols-1") ? "cols-1" : "cols-2";
570
+ const overlay = createElement("div", `regen-overlay ${colClass}`);
571
+ for (let i = 0; i < optionCount; i++) {
572
+ overlay.appendChild(createElement("div", "regen-skeleton"));
573
+ }
574
+ const status = createElement("div", "regen-status", "Regenerating options...");
575
+ overlay.appendChild(status);
576
+
577
+ // Position overlay relative to options grid
578
+ optionsGrid.style.position = "relative";
579
+ optionsGrid.classList.add("regenerating");
580
+ optionsGrid.appendChild(overlay);
475
581
 
476
582
  const originalText = button.textContent || "";
477
583
  button.classList.add("loading");
@@ -482,7 +588,7 @@ async function regenerateSlide(button, slideId) {
482
588
  const genMoreBtn = slideElement.querySelector(".btn-gen-more");
483
589
  if (genMoreBtn) genMoreBtn.disabled = true;
484
590
 
485
- pendingGenerate.set(slideId, { button, originalText, input, isRegen: true, optionsGrid, genMoreBtn });
591
+ pendingGenerate.set(slideId, { button, originalText, input, isRegen: true, optionsGrid, genMoreBtn, overlay });
486
592
 
487
593
  try {
488
594
  const body = { token: sessionToken, slideId };
@@ -502,7 +608,7 @@ function restoreRegenButton(slideId) {
502
608
  if (!pending || !pending.isRegen) return;
503
609
  pendingGenerate.delete(slideId);
504
610
 
505
- const { button, originalText, input, optionsGrid, genMoreBtn } = pending;
611
+ const { button, originalText, input, optionsGrid, genMoreBtn, overlay } = pending;
506
612
  if (button) {
507
613
  button.classList.remove("loading");
508
614
  button.disabled = false;
@@ -510,8 +616,11 @@ function restoreRegenButton(slideId) {
510
616
  }
511
617
  if (input) input.disabled = false;
512
618
  if (optionsGrid) {
513
- optionsGrid.style.opacity = "";
514
- optionsGrid.style.pointerEvents = "";
619
+ optionsGrid.classList.remove("regenerating");
620
+ optionsGrid.style.position = "";
621
+ }
622
+ if (overlay && overlay.parentElement) {
623
+ overlay.remove();
515
624
  }
516
625
  if (genMoreBtn) genMoreBtn.disabled = false;
517
626
  }
@@ -539,6 +648,9 @@ function initSaveShortcut() {
539
648
  document.querySelectorAll(".mod-key").forEach((el) => {
540
649
  el.textContent = isMac ? "⌘" : "Ctrl";
541
650
  });
651
+ if (btnSave) {
652
+ btnSave.addEventListener("click", () => saveDeck());
653
+ }
542
654
  document.addEventListener("keydown", (e) => {
543
655
  const mod = isMac ? e.metaKey : e.ctrlKey;
544
656
  if (mod && e.key === "s") {
@@ -570,6 +682,7 @@ function init() {
570
682
  fetchModels().then((data) => {
571
683
  if (data) initModelBar(data);
572
684
  });
685
+ updateSaveStatus();
573
686
 
574
687
  document.addEventListener("keydown", handleKeydown);
575
688
  window.addEventListener("beforeunload", sendCancelBeacon);
@@ -28,15 +28,15 @@ function optionTemplate(hasBlocks: boolean): string {
28
28
 
29
29
  function modelHints(generateModel?: string, thinking?: string, action?: string): string {
30
30
  if (!generateModel) return "";
31
- const verb = action === "replace-options" ? "replace-options" : "add-option";
32
- let hint = `\nGenerate ${verb === "replace-options" ? "options " : ""}using deck_generate({ model: "${generateModel}", task: "..." }), then push with ${verb}.`;
31
+ const verb = action === "replace-options" ? "replace-options" : "add-options";
32
+ let hint = `\nGenerate options using deck_generate({ model: "${generateModel}", task: "..." }), then push with ${verb}.`;
33
33
  if (thinking && thinking !== "off") {
34
34
  hint += `\nUse thinking level: "${thinking}".`;
35
35
  }
36
36
  return hint;
37
37
  }
38
38
 
39
- export function buildGenerateMoreResult(slideId: string, slide: DeckSlide | undefined, prompt?: string, generateModel?: string, thinking?: string): string {
39
+ export function buildGenerateMoreResult(slideId: string, slide: DeckSlide | undefined, prompt?: string, generateModel?: string, thinking?: string, count: number = 1): string {
40
40
  const title = slide?.title ?? slideId;
41
41
  const context = slide?.context ? `\nContext: ${slide.context}` : "";
42
42
 
@@ -54,15 +54,20 @@ export function buildGenerateMoreResult(slideId: string, slide: DeckSlide | unde
54
54
  const { hasBlocks, formatHint } = formatHintForSlide(slide);
55
55
  const template = optionTemplate(hasBlocks);
56
56
  const userInstructions = prompt ? `\nUser instructions: "${prompt}"` : "";
57
+
58
+ const optionWord = count === 1 ? "option" : "options";
57
59
 
58
60
  return (
59
- "The design deck is still open.\n\n" +
60
- `User requested another option for slide \"${title}\".${context}${userInstructions}\n\n` +
61
+ "The design deck is still open and waiting for your response.\n\n" +
62
+ `User clicked "Generate ${count} ${optionWord}" for slide \"${title}\".${context}${userInstructions}\n\n` +
61
63
  `Existing options:\n${existingText}\n\n` +
62
- `Generate one distinctive additional option and call design_deck again:${modelHints(generateModel, thinking, "add-option")}\n\n` +
63
- `design_deck({\"action\":\"add-option\",\"slideId\":\"${slideId}\",\"option\":${JSON.stringify(template)}})\n\n` +
64
- `The option field must be a JSON string with: label, optional description, optional aside (explanatory notes below preview), and optional recommended.\n` +
65
- `${formatHint}`
64
+ `YOU MUST generate ${count} distinctive additional ${optionWord} and call design_deck with add-options (one call with all options in an array). ` +
65
+ `Do not skip this step or decide the user has enough options — they explicitly requested ${count === 1 ? "another one" : `${count} more`}.${modelHints(generateModel, thinking, "add-options")}\n\n` +
66
+ `design_deck({\"action\":\"add-options\",\"slideId\":\"${slideId}\",\"options\":\"[${template}${count > 1 ? ", ..." : ""}]\"})` +
67
+ `\n\nThe options field must be a JSON string containing an array of ${count} option object${count > 1 ? "s" : ""}.\n` +
68
+ `Each option needs: label, optional description, optional aside (explanatory notes below preview), optional recommended, and either previewHtml or previewBlocks.\n` +
69
+ `${formatHint}` +
70
+ (count > 1 ? `\n\nMake each option distinctive — they should represent genuinely different approaches.` : "")
66
71
  );
67
72
  }
68
73
 
@@ -75,9 +80,10 @@ export function buildRegenerateResult(slideId: string, slide: DeckSlide | undefi
75
80
  const userInstructions = prompt ? `\nUser instructions: "${prompt}"` : "";
76
81
 
77
82
  return (
78
- "The design deck is still open.\n\n" +
79
- `User requested ALL options be regenerated for slide \"${title}\".${context}${userInstructions}\n\n` +
80
- `Generate ${optionCount} fresh, distinctive options and call design_deck to replace all existing options:${modelHints(generateModel, thinking, "replace-options")}\n\n` +
83
+ "The design deck is still open and waiting for your response.\n\n" +
84
+ `User clicked "Regenerate all" for slide \"${title}\".${context}${userInstructions}\n\n` +
85
+ `YOU MUST generate ${optionCount} fresh, distinctive options and call design_deck with replace-options. ` +
86
+ `Do not skip this step — the user explicitly requested regeneration.${modelHints(generateModel, thinking, "replace-options")}\n\n` +
81
87
  `design_deck({\"action\":\"replace-options\",\"slideId\":\"${slideId}\",\"options\":\"[${template}, ...]\"})` +
82
88
  `\n\nThe options field must be a JSON string containing an array of ${optionCount} option objects.\n` +
83
89
  `Each option needs: label, optional description, optional aside, optional recommended, and either previewHtml or previewBlocks.\n` +