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.
- package/README.md +51 -13
- package/deck-schema.ts +33 -3
- package/deck-server.ts +64 -10
- package/export-html.ts +329 -0
- package/form/css/controls.css +152 -16
- package/form/css/layout.css +7 -0
- package/form/deck.html +16 -0
- package/form/js/deck-core.js +118 -0
- package/form/js/deck-interact.js +30 -12
- package/form/js/deck-render.js +2 -0
- package/form/js/deck-session.js +31 -12
- package/generate-prompts.ts +8 -10
- package/index.ts +318 -42
- package/package.json +2 -1
- package/prompts/deck-discover.md +3 -1
- package/prompts/deck-plan.md +3 -1
- package/prompts/deck.md +3 -1
- package/skills/design-deck/SKILL.md +45 -9
- package/skills/design-deck/references/component-gallery/INDEX.md +88 -0
- package/skills/design-deck/references/component-gallery/LOOKUP.md +592 -0
- package/skills/design-deck/references/component-gallery/components/INDEX.md +106 -0
- package/skills/design-deck/references/component-gallery/components/actions.md +354 -0
- package/skills/design-deck/references/component-gallery/components/data-display.md +812 -0
- package/skills/design-deck/references/component-gallery/components/feedback.md +513 -0
- package/skills/design-deck/references/component-gallery/components/inputs.md +921 -0
- package/skills/design-deck/references/component-gallery/components/layout.md +167 -0
- package/skills/design-deck/references/component-gallery/components/navigation.md +350 -0
- package/skills/design-deck/references/component-gallery/components/overlays.md +208 -0
- package/skills/design-deck/references/component-gallery/components/utilities.md +29 -0
- package/skills/design-deck/references/component-gallery/components.md +1383 -0
package/form/js/deck-core.js
CHANGED
|
@@ -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"}`;
|
package/form/js/deck-interact.js
CHANGED
|
@@ -2,16 +2,21 @@
|
|
|
2
2
|
|
|
3
3
|
function applySavedSelections(savedSelections, savedNotes) {
|
|
4
4
|
if (!savedSelections || typeof savedSelections !== "object") return;
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
const
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
if (
|
|
11
|
-
|
|
12
|
-
|
|
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
|
-
|
|
32
|
-
|
|
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] =
|
|
65
|
-
|
|
79
|
+
selections[slideId] = nextValue;
|
|
80
|
+
if (!isRestoringSelections) {
|
|
81
|
+
saveSelectionsToStorage();
|
|
82
|
+
markDirty();
|
|
83
|
+
}
|
|
66
84
|
}
|
|
67
85
|
|
|
68
86
|
// ─── NAVIGATION ──────────────────────────────────────────────
|
package/form/js/deck-render.js
CHANGED
|
@@ -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);
|
package/form/js/deck-session.js
CHANGED
|
@@ -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
|
|
48
|
-
if (
|
|
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
|
-
|
|
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
|
|
563
|
-
|
|
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);
|
package/generate-prompts.ts
CHANGED
|
@@ -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-
|
|
32
|
-
let hint = `\nGenerate
|
|
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
|
-
|
|
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-
|
|
69
|
-
`design_deck({\"action\":\"add-
|
|
70
|
-
|
|
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\
|
|
70
|
+
(count > 1 ? `\n\nMake each option distinctive — they should represent genuinely different approaches.` : "")
|
|
73
71
|
);
|
|
74
72
|
}
|
|
75
73
|
|