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
@@ -423,11 +423,61 @@
423
423
  .btn-nav:focus-visible,
424
424
  .btn-gen-more:focus-visible,
425
425
  .btn-generate:focus-visible,
426
+ .deck-save-btn:focus-visible,
426
427
  .confirm-bar-btn:focus-visible {
427
428
  outline: 2px solid var(--dk-accent);
428
429
  outline-offset: 2px;
429
430
  }
430
431
 
432
+ /* ─────────────────────────────────────────────────────────────
433
+ SAVE CONTROLS
434
+ ───────────────────────────────────────────────────────────── */
435
+
436
+ .deck-save-btn {
437
+ display: inline-flex;
438
+ align-items: center;
439
+ justify-content: center;
440
+ padding: 6px 12px;
441
+ border-radius: 999px;
442
+ border: 1px solid rgba(var(--dk-ink),0.16);
443
+ background: rgba(var(--dk-ink),0.06);
444
+ color: var(--dk-text-secondary);
445
+ font: 11px var(--dk-font-mono);
446
+ cursor: pointer;
447
+ transition: border-color 0.15s, color 0.15s, background 0.15s;
448
+ }
449
+
450
+ .deck-save-btn:hover {
451
+ border-color: rgba(138,190,183,0.28);
452
+ color: var(--dk-accent-text);
453
+ background: rgba(138,190,183,0.08);
454
+ }
455
+
456
+ .deck-save-btn.dirty {
457
+ border-color: rgba(251,191,36,0.28);
458
+ color: var(--dk-status-warn);
459
+ background: rgba(251,191,36,0.08);
460
+ }
461
+
462
+ .deck-save-btn:disabled {
463
+ opacity: 0.45;
464
+ cursor: not-allowed;
465
+ }
466
+
467
+ .deck-save-status {
468
+ font: 11px var(--dk-font-mono);
469
+ color: var(--dk-text-hint);
470
+ white-space: nowrap;
471
+ }
472
+
473
+ .deck-save-status.saved {
474
+ color: var(--dk-status-success);
475
+ }
476
+
477
+ .deck-save-status.dirty {
478
+ color: var(--dk-status-warn);
479
+ }
480
+
431
481
  .deck {
432
482
  touch-action: manipulation;
433
483
  overscroll-behavior: contain;
@@ -451,6 +501,7 @@
451
501
  .slide { padding: 20px 16px 16px; }
452
502
  .summary-grid { grid-template-columns: 1fr; }
453
503
  .pv-layout-sidebar { grid-template-columns: 120px 1fr; }
504
+ .deck-save-status { display: none; }
454
505
  }
455
506
 
456
507
  /* ─────────────────────────────────────────────────────────────
package/form/deck.html CHANGED
@@ -45,6 +45,8 @@
45
45
  <span class="deck-key"><kbd>1</kbd><kbd>2</kbd><kbd>3</kbd> Select</span>
46
46
  <span class="deck-key"><kbd>Enter</kbd> Confirm</span>
47
47
  <span class="deck-key"><kbd class="mod-key">⌘</kbd><kbd>S</kbd> Save</span>
48
+ <button class="deck-save-btn" id="btn-save" type="button">Save</button>
49
+ <span class="deck-save-status" id="save-status" role="status" aria-live="polite">No unsaved changes</span>
48
50
  <span class="deck-key hidden" id="theme-shortcut"></span>
49
51
  </div>
50
52
  <button class="btn-nav primary" id="btn-next" type="button">Next &rarr;</button>
@@ -21,6 +21,9 @@ let events = null;
21
21
  let heartbeatTimer = null;
22
22
  let isClosed = false;
23
23
  let isSubmitting = false;
24
+ let isDirty = false;
25
+ let lastSavedLabel = "";
26
+ let isRestoringSelections = false;
24
27
 
25
28
  const selections = {};
26
29
  const optionNotes = {};
@@ -35,6 +38,8 @@ const progressFill = document.getElementById("progress-fill");
35
38
  const slidesWrap = document.getElementById("slides-wrap");
36
39
  const btnBack = document.getElementById("btn-back");
37
40
  const btnNext = document.getElementById("btn-next");
41
+ const btnSave = document.getElementById("btn-save");
42
+ const saveStatus = document.getElementById("save-status");
38
43
 
39
44
  // ─── UTILITIES ───────────────────────────────────────────────
40
45
 
@@ -74,6 +79,47 @@ function setMetaLabel() {
74
79
  deckTitle.textContent = title;
75
80
  }
76
81
 
82
+ function formatSavedTimestamp(value) {
83
+ try {
84
+ return new Date(value).toLocaleTimeString([], { hour: "numeric", minute: "2-digit" });
85
+ } catch {
86
+ return "";
87
+ }
88
+ }
89
+
90
+ function updateSaveStatus() {
91
+ if (saveStatus) {
92
+ if (isDirty) {
93
+ saveStatus.textContent = "Unsaved changes";
94
+ saveStatus.classList.add("dirty");
95
+ saveStatus.classList.remove("saved");
96
+ } else if (lastSavedLabel) {
97
+ saveStatus.textContent = `Saved ${lastSavedLabel}`;
98
+ saveStatus.classList.add("saved");
99
+ saveStatus.classList.remove("dirty");
100
+ } else {
101
+ saveStatus.textContent = "No unsaved changes";
102
+ saveStatus.classList.remove("dirty", "saved");
103
+ }
104
+ }
105
+ if (btnSave) {
106
+ btnSave.disabled = isClosed;
107
+ btnSave.classList.toggle("dirty", isDirty);
108
+ }
109
+ }
110
+
111
+ function markDirty() {
112
+ if (isClosed) return;
113
+ isDirty = true;
114
+ updateSaveStatus();
115
+ }
116
+
117
+ function markSaved(savedAt) {
118
+ isDirty = false;
119
+ lastSavedLabel = formatSavedTimestamp(savedAt || new Date().toISOString());
120
+ updateSaveStatus();
121
+ }
122
+
77
123
  // ─── THEME SYSTEM ────────────────────────────────────────────
78
124
 
79
125
  const themeConfig = deckData && typeof deckData === "object" && deckData.theme && typeof deckData.theme === "object" ? deckData.theme : {};
@@ -2,16 +2,21 @@
2
2
 
3
3
  function applySavedSelections(savedSelections, savedNotes) {
4
4
  if (!savedSelections || typeof savedSelections !== "object") return;
5
- for (const [slideId, label] of Object.entries(savedSelections)) {
6
- if (typeof label !== "string") continue;
7
- const slideEl = document.querySelector(`.slide[data-id="${CSS.escape(slideId)}"]`);
8
- if (!slideEl) continue;
9
- for (const card of slideEl.querySelectorAll(".option")) {
10
- if (card.dataset.value === label) {
11
- selectOption(card);
12
- break;
5
+ isRestoringSelections = true;
6
+ try {
7
+ for (const [slideId, label] of Object.entries(savedSelections)) {
8
+ if (typeof label !== "string") continue;
9
+ const slideEl = document.querySelector(`.slide[data-id="${CSS.escape(slideId)}"]`);
10
+ if (!slideEl) continue;
11
+ for (const card of slideEl.querySelectorAll(".option")) {
12
+ if (card.dataset.value === label) {
13
+ selectOption(card);
14
+ break;
15
+ }
13
16
  }
14
17
  }
18
+ } finally {
19
+ isRestoringSelections = false;
15
20
  }
16
21
  // Restore notes
17
22
  if (savedNotes && typeof savedNotes === "object") {
@@ -28,8 +33,16 @@ function applySavedSelections(savedSelections, savedNotes) {
28
33
 
29
34
  function restoreSelections() {
30
35
  const serverSaved = deckData.savedSelections;
31
- if (serverSaved && typeof serverSaved === "object" && Object.keys(serverSaved).length > 0) {
32
- applySavedSelections(serverSaved, null);
36
+ const hasServerSavedSelections = serverSaved && typeof serverSaved === "object" && Object.keys(serverSaved).length > 0;
37
+ const hasServerSavedNotes = deckData.savedNotes && typeof deckData.savedNotes === "object" && Object.keys(deckData.savedNotes).length > 0;
38
+ const hasServerFinalNotes = typeof deckData.savedFinalNotes === "string" && deckData.savedFinalNotes.trim() !== "";
39
+ if (hasServerSavedSelections || hasServerSavedNotes || hasServerFinalNotes) {
40
+ applySavedSelections(hasServerSavedSelections ? serverSaved : null, deckData.savedNotes || null);
41
+ if (deckData.savedFinalNotes) {
42
+ finalNotes = deckData.savedFinalNotes;
43
+ const input = document.getElementById("final-notes-input");
44
+ if (input) input.value = deckData.savedFinalNotes;
45
+ }
33
46
  return;
34
47
  }
35
48
  const stored = loadSelectionsFromStorage();
@@ -59,10 +72,15 @@ function selectOption(optionElement) {
59
72
  if (!slideElement) return;
60
73
  const slideId = slideElement.dataset.id;
61
74
  if (!slideId || slideId === "summary") return;
75
+ const nextValue = optionElement.dataset.value || "";
76
+ if (selections[slideId] === nextValue) return;
62
77
 
63
78
  applySelectionClasses(slideElement, optionElement);
64
- selections[slideId] = optionElement.dataset.value || "";
65
- saveSelectionsToStorage();
79
+ selections[slideId] = nextValue;
80
+ if (!isRestoringSelections) {
81
+ saveSelectionsToStorage();
82
+ markDirty();
83
+ }
66
84
  }
67
85
 
68
86
  // ─── NAVIGATION ──────────────────────────────────────────────
@@ -222,6 +222,7 @@ function createOptionCard(option, slideId, generatedBy) {
222
222
  delete optionNotes[slideId];
223
223
  }
224
224
  saveSelectionsToStorage();
225
+ markDirty();
225
226
  });
226
227
 
227
228
  // Prevent click from bubbling to card (which would select it)
@@ -375,6 +376,7 @@ function createSummarySlide(index) {
375
376
  finalNotesInput.addEventListener("input", (e) => {
376
377
  finalNotes = e.target.value.trim();
377
378
  saveSelectionsToStorage();
379
+ markDirty();
378
380
  });
379
381
  finalNotesContainer.appendChild(finalNotesLabel);
380
382
  finalNotesContainer.appendChild(finalNotesInput);
@@ -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,6 +86,7 @@ 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
  });
@@ -98,13 +114,7 @@ async function submitDeck() {
98
114
  }
99
115
 
100
116
  try {
101
- // Build notes object with only notes for selected options
102
- const notes = {};
103
- for (const [slideId, noteData] of Object.entries(optionNotes)) {
104
- if (noteData && selections[slideId] === noteData.label && noteData.notes) {
105
- notes[slideId] = noteData.notes;
106
- }
107
- }
117
+ const notes = buildSelectedNotes();
108
118
  const payload = { token: sessionToken, selections, notes };
109
119
  if (finalNotes) payload.finalNotes = finalNotes;
110
120
  await postJson("/submit", payload);
@@ -307,6 +317,7 @@ function insertGeneratedOption(slideId, option, model) {
307
317
  if (current === totalSlides - 1) {
308
318
  updateSummary();
309
319
  }
320
+ markDirty();
310
321
  }
311
322
 
312
323
  function replaceSlideOptions(slideId, newOptions) {
@@ -355,6 +366,7 @@ function replaceSlideOptions(slideId, newOptions) {
355
366
  if (current === totalSlides - 1) {
356
367
  updateSummary();
357
368
  }
369
+ markDirty();
358
370
  }
359
371
 
360
372
  function connectEvents() {
@@ -636,6 +648,9 @@ function initSaveShortcut() {
636
648
  document.querySelectorAll(".mod-key").forEach((el) => {
637
649
  el.textContent = isMac ? "⌘" : "Ctrl";
638
650
  });
651
+ if (btnSave) {
652
+ btnSave.addEventListener("click", () => saveDeck());
653
+ }
639
654
  document.addEventListener("keydown", (e) => {
640
655
  const mod = isMac ? e.metaKey : e.ctrlKey;
641
656
  if (mod && e.key === "s") {
@@ -667,6 +682,7 @@ function init() {
667
682
  fetchModels().then((data) => {
668
683
  if (data) initModelBar(data);
669
684
  });
685
+ updateSaveStatus();
670
686
 
671
687
  document.addEventListener("keydown", handleKeydown);
672
688
  window.addEventListener("beforeunload", sendCancelBeacon);
@@ -28,8 +28,8 @@ 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
  }
@@ -56,20 +56,18 @@ export function buildGenerateMoreResult(slideId: string, slide: DeckSlide | unde
56
56
  const userInstructions = prompt ? `\nUser instructions: "${prompt}"` : "";
57
57
 
58
58
  const optionWord = count === 1 ? "option" : "options";
59
- const callInstructions = count === 1
60
- ? `YOU MUST generate one distinctive additional option and call design_deck with add-option.`
61
- : `YOU MUST generate ${count} distinctive additional options. Call design_deck with add-option ${count} times (once per option).`;
62
59
 
63
60
  return (
64
61
  "The design deck is still open and waiting for your response.\n\n" +
65
62
  `User clicked "Generate ${count} ${optionWord}" for slide \"${title}\".${context}${userInstructions}\n\n` +
66
63
  `Existing options:\n${existingText}\n\n` +
67
- `${callInstructions} ` +
68
- `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-option")}\n\n` +
69
- `design_deck({\"action\":\"add-option\",\"slideId\":\"${slideId}\",\"option\":${JSON.stringify(template)}})\n\n` +
70
- `The option field must be a JSON string with: label, optional description, optional aside (explanatory notes below preview), and optional recommended.\n` +
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` +
71
69
  `${formatHint}` +
72
- (count > 1 ? `\n\nRemember: Call add-option ${count} times, each with a different option. Make each option distinctive.` : "")
70
+ (count > 1 ? `\n\nMake each option distinctive they should represent genuinely different approaches.` : "")
73
71
  );
74
72
  }
75
73