pi-design-deck 0.2.0 → 0.3.1

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 (30) hide show
  1. package/README.md +51 -13
  2. package/deck-schema.ts +33 -3
  3. package/deck-server.ts +64 -10
  4. package/export-html.ts +329 -0
  5. package/form/css/controls.css +152 -16
  6. package/form/css/layout.css +7 -0
  7. package/form/deck.html +16 -0
  8. package/form/js/deck-core.js +118 -0
  9. package/form/js/deck-interact.js +30 -12
  10. package/form/js/deck-render.js +2 -0
  11. package/form/js/deck-session.js +31 -12
  12. package/generate-prompts.ts +8 -10
  13. package/index.ts +318 -42
  14. package/package.json +2 -1
  15. package/prompts/deck-discover.md +3 -1
  16. package/prompts/deck-plan.md +3 -1
  17. package/prompts/deck.md +3 -1
  18. package/skills/design-deck/SKILL.md +45 -9
  19. package/skills/design-deck/references/component-gallery/INDEX.md +88 -0
  20. package/skills/design-deck/references/component-gallery/LOOKUP.md +592 -0
  21. package/skills/design-deck/references/component-gallery/components/INDEX.md +106 -0
  22. package/skills/design-deck/references/component-gallery/components/actions.md +354 -0
  23. package/skills/design-deck/references/component-gallery/components/data-display.md +812 -0
  24. package/skills/design-deck/references/component-gallery/components/feedback.md +513 -0
  25. package/skills/design-deck/references/component-gallery/components/inputs.md +921 -0
  26. package/skills/design-deck/references/component-gallery/components/layout.md +167 -0
  27. package/skills/design-deck/references/component-gallery/components/navigation.md +350 -0
  28. package/skills/design-deck/references/component-gallery/components/overlays.md +208 -0
  29. package/skills/design-deck/references/component-gallery/components/utilities.md +29 -0
  30. package/skills/design-deck/references/component-gallery/components.md +1383 -0
@@ -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 : {};
@@ -181,6 +227,78 @@ function initTheme() {
181
227
  }
182
228
  }
183
229
 
230
+ // ─── LAYOUT TOGGLE ───────────────────────────────────────────
231
+
232
+ const LAYOUT_KEY = "pi-deck-layout";
233
+
234
+ function getStoredLayout() {
235
+ try {
236
+ const value = localStorage.getItem(LAYOUT_KEY);
237
+ return value === "1" || value === "2" || value === "3" || value === "4" ? value : null;
238
+ } catch { return null; }
239
+ }
240
+
241
+ function setStoredLayout(value) {
242
+ try {
243
+ if (!value) {
244
+ localStorage.removeItem(LAYOUT_KEY);
245
+ } else {
246
+ localStorage.setItem(LAYOUT_KEY, value);
247
+ }
248
+ } catch {}
249
+ }
250
+
251
+ function applyLayout(cols) {
252
+ const deck = document.querySelector(".deck");
253
+ if (!deck) return;
254
+ if (cols) {
255
+ deck.dataset.layout = cols;
256
+ } else {
257
+ delete deck.dataset.layout;
258
+ }
259
+ }
260
+
261
+ function updateLayoutButtons(activeCols) {
262
+ const toggle = document.getElementById("layout-toggle");
263
+ if (!toggle) return;
264
+ toggle.querySelectorAll(".layout-btn").forEach((btn) => {
265
+ const isActive = btn.dataset.cols === activeCols;
266
+ btn.classList.toggle("active", isActive);
267
+ btn.setAttribute("aria-pressed", isActive ? "true" : "false");
268
+ // Update title to show "Auto" hint when clicking would reset
269
+ const cols = btn.dataset.cols;
270
+ btn.title = isActive ? `${cols} column${cols === "1" ? "" : "s"} (click for auto)` : `${cols} column${cols === "1" ? "" : "s"}`;
271
+ });
272
+ }
273
+
274
+ function initLayoutToggle() {
275
+ const stored = getStoredLayout();
276
+ if (stored) {
277
+ applyLayout(stored);
278
+ updateLayoutButtons(stored);
279
+ }
280
+
281
+ const toggle = document.getElementById("layout-toggle");
282
+ if (!toggle) return;
283
+
284
+ toggle.addEventListener("click", (event) => {
285
+ const btn = event.target.closest(".layout-btn");
286
+ if (!btn) return;
287
+ const cols = btn.dataset.cols;
288
+ const current = getStoredLayout();
289
+ if (cols === current) {
290
+ // Clicking active button toggles back to auto
291
+ setStoredLayout(null);
292
+ applyLayout(null);
293
+ updateLayoutButtons(null);
294
+ } else {
295
+ setStoredLayout(cols);
296
+ applyLayout(cols);
297
+ updateLayoutButtons(cols);
298
+ }
299
+ });
300
+ }
301
+
184
302
  // ─── SELECTION PERSISTENCE ────────────────────────────────────
185
303
 
186
304
  const SELECTIONS_KEY = `pi-deck-${typeof deckData.sessionId === "string" ? deckData.sessionId : "unknown"}`;
@@ -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() {
@@ -495,6 +507,7 @@ async function generateMore(button, slideId, input, countSelect) {
495
507
  const skeletons = [];
496
508
  for (let i = 0; i < count; i++) {
497
509
  const skeleton = createElement("div", "option-skeleton");
510
+ skeleton.innerHTML = '<div class="spinner"></div>';
498
511
  optionsGrid.appendChild(skeleton);
499
512
  skeletons.push(skeleton);
500
513
  }
@@ -551,7 +564,7 @@ async function regenerateSlide(button, slideId) {
551
564
  const prompt = input ? input.value.trim() : "";
552
565
  if (input) input.value = "";
553
566
 
554
- // Create skeleton overlay
567
+ // Create skeleton overlay with centered spinner
555
568
  const optionCount = slide.options?.length || 2;
556
569
  const colClass = optionsGrid.classList.contains("cols-3") ? "cols-3" :
557
570
  optionsGrid.classList.contains("cols-1") ? "cols-1" : "cols-2";
@@ -559,8 +572,9 @@ async function regenerateSlide(button, slideId) {
559
572
  for (let i = 0; i < optionCount; i++) {
560
573
  overlay.appendChild(createElement("div", "regen-skeleton"));
561
574
  }
562
- const status = createElement("div", "regen-status", "Regenerating options...");
563
- overlay.appendChild(status);
575
+ const center = createElement("div", "regen-center");
576
+ center.innerHTML = '<div class="spinner"></div><div class="regen-center-text">Regenerating options...</div>';
577
+ overlay.appendChild(center);
564
578
 
565
579
  // Position overlay relative to options grid
566
580
  optionsGrid.style.position = "relative";
@@ -636,6 +650,9 @@ function initSaveShortcut() {
636
650
  document.querySelectorAll(".mod-key").forEach((el) => {
637
651
  el.textContent = isMac ? "⌘" : "Ctrl";
638
652
  });
653
+ if (btnSave) {
654
+ btnSave.addEventListener("click", () => saveDeck());
655
+ }
639
656
  document.addEventListener("keydown", (e) => {
640
657
  const mod = isMac ? e.metaKey : e.ctrlKey;
641
658
  if (mod && e.key === "s") {
@@ -655,6 +672,7 @@ function hideLoadingOverlay() {
655
672
 
656
673
  function init() {
657
674
  initTheme();
675
+ initLayoutToggle();
658
676
  setMetaLabel();
659
677
  renderSlides();
660
678
  restoreSelections();
@@ -667,6 +685,7 @@ function init() {
667
685
  fetchModels().then((data) => {
668
686
  if (data) initModelBar(data);
669
687
  });
688
+ updateSaveStatus();
670
689
 
671
690
  document.addEventListener("keydown", handleKeydown);
672
691
  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