pi-design-deck 0.1.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.
- package/README.md +329 -0
- package/banner.png +0 -0
- package/deck-schema.ts +262 -0
- package/deck-server.ts +675 -0
- package/form/css/controls.css +340 -0
- package/form/css/layout.css +338 -0
- package/form/css/preview.css +357 -0
- package/form/css/variables.css +54 -0
- package/form/deck.html +83 -0
- package/form/js/deck-core.js +199 -0
- package/form/js/deck-interact.js +400 -0
- package/form/js/deck-render.js +411 -0
- package/form/js/deck-session.js +582 -0
- package/generate-prompts.ts +87 -0
- package/index.ts +671 -0
- package/package.json +37 -0
- package/prompts/deck-discover.md +12 -0
- package/prompts/deck-plan.md +14 -0
- package/prompts/deck.md +16 -0
- package/server-utils.ts +197 -0
- package/settings.ts +65 -0
- package/skills/design-deck/SKILL.md +292 -0
package/README.md
ADDED
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
<p>
|
|
2
|
+
<img src="banner.png" alt="pi-design-deck" width="1100">
|
|
3
|
+
</p>
|
|
4
|
+
|
|
5
|
+
# Design Deck
|
|
6
|
+
|
|
7
|
+
A tool for [Pi coding agent](https://github.com/badlogic/pi-mono/) that presents multi-slide visual decision decks in the browser. Each slide shows 2-4 high-fidelity previews — code diffs, architecture diagrams, UI mockups — and you pick one per slide. The agent gets back a clean selection map and moves on to implementation.
|
|
8
|
+
|
|
9
|
+
## Usage
|
|
10
|
+
|
|
11
|
+
Just ask. The agent reaches for the design deck when visual comparison makes sense.
|
|
12
|
+
|
|
13
|
+
```
|
|
14
|
+
show me 3 architecture options for the backend
|
|
15
|
+
present a few UI directions for the settings page
|
|
16
|
+
what are my options for the auth flow? show me visually
|
|
17
|
+
read the PRD at docs/api-plan.md and present the key decisions
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
Three slash commands are also available for more structured flows:
|
|
21
|
+
|
|
22
|
+
- **`/deck`** — general purpose. Give it a topic or run it bare.
|
|
23
|
+
- **`/deck-plan docs/plan.md`** — reads a plan or PRD, identifies decision points, builds slides for each.
|
|
24
|
+
- **`/deck-discover`** — interviews you first to gather requirements, then builds a deck from what it learned.
|
|
25
|
+
|
|
26
|
+
## Why
|
|
27
|
+
|
|
28
|
+
The interview tool gathers structured input — you answer questions. The design deck is the other direction: the agent shows you visual options and you pick. They work together — interview discovers requirements, deck presents the resulting options — but they're distinct tools for distinct jobs.
|
|
29
|
+
|
|
30
|
+
The persistent server architecture means the browser stays open across tool re-invocations. When you click "Generate another option," the agent creates it and pushes it into the live deck via SSE — no page reloads, no lost state.
|
|
31
|
+
|
|
32
|
+
## Install
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
pi install npm:pi-design-deck
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Restart pi to load the extension and the bundled `design-deck` skill.
|
|
39
|
+
|
|
40
|
+
**Requirements:**
|
|
41
|
+
- pi-agent v0.35.0 or later (extensions API)
|
|
42
|
+
|
|
43
|
+
## Quick Start
|
|
44
|
+
|
|
45
|
+
The agent builds slides as JSON. Each slide is one decision, each option is one approach:
|
|
46
|
+
|
|
47
|
+
```json
|
|
48
|
+
{
|
|
49
|
+
"title": "API Design",
|
|
50
|
+
"slides": [
|
|
51
|
+
{
|
|
52
|
+
"id": "auth",
|
|
53
|
+
"title": "Authentication Strategy",
|
|
54
|
+
"context": "Choose how users authenticate with the API.",
|
|
55
|
+
"columns": 2,
|
|
56
|
+
"options": [
|
|
57
|
+
{
|
|
58
|
+
"label": "JWT + Refresh Tokens",
|
|
59
|
+
"description": "Stateless, horizontally scalable",
|
|
60
|
+
"aside": "Tokens are self-contained — no session store needed.\nWatch for token size with many claims.",
|
|
61
|
+
"previewBlocks": [
|
|
62
|
+
{ "type": "code", "code": "const token = jwt.sign({ sub: user.id }, SECRET, { expiresIn: '15m' });\nres.cookie('refresh', refreshToken, { httpOnly: true });", "lang": "ts" },
|
|
63
|
+
{ "type": "mermaid", "content": "sequenceDiagram\n Client->>API: POST /login\n API->>Client: JWT + refresh cookie\n Client->>API: GET /data (Bearer JWT)\n API->>Client: 200 OK" }
|
|
64
|
+
],
|
|
65
|
+
"recommended": true
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
"label": "Session Cookies",
|
|
69
|
+
"description": "Server-side sessions with Redis backing",
|
|
70
|
+
"aside": "Simple mental model. Session invalidation is instant.\nRequires sticky sessions or shared session store.",
|
|
71
|
+
"previewBlocks": [
|
|
72
|
+
{ "type": "code", "code": "app.use(session({ store: new RedisStore({ client }), secret: SECRET }));", "lang": "ts" },
|
|
73
|
+
{ "type": "mermaid", "content": "sequenceDiagram\n Client->>API: POST /login\n API->>Redis: Store session\n API->>Client: Set-Cookie: sid=...\n Client->>API: GET /data (Cookie)\n API->>Redis: Lookup session" }
|
|
74
|
+
]
|
|
75
|
+
}
|
|
76
|
+
]
|
|
77
|
+
}
|
|
78
|
+
]
|
|
79
|
+
}
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
The browser opens, the user picks "JWT + Refresh Tokens", and the agent receives:
|
|
83
|
+
|
|
84
|
+
```
|
|
85
|
+
{ "auth": "JWT + Refresh Tokens" }
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
## Features
|
|
89
|
+
|
|
90
|
+
- **Preview blocks**: Four typed block types — `code` (Prism.js syntax highlighting), `mermaid` (Mermaid.js diagrams), `html` (raw HTML), and `image` (served from disk). Mix freely within one option.
|
|
91
|
+
- **Raw HTML previews**: Full `previewHtml` support for custom UI mockups with inline styles. Use when blocks aren't enough.
|
|
92
|
+
- **Generate-more loop**: Users click "Generate another option" and the agent pushes a new option into the live deck via SSE. No page reload.
|
|
93
|
+
- **Model selector**: Dropdown to pick which model generates new options. Save as default, or override per-request.
|
|
94
|
+
- **Thinking level**: Adjust reasoning effort for option generation when the selected model supports it.
|
|
95
|
+
- **Slide columns**: `columns` property (1, 2, or 3) per slide. Auto-detected from option count if omitted.
|
|
96
|
+
- **Smart rebalancing**: Grid layout recalculates after generate-more adds options to minimize orphans.
|
|
97
|
+
- **Option aside**: Explanatory text rendered below the preview. Supports `\n` for line breaks.
|
|
98
|
+
- **Save/load snapshots**: `Cmd+S` saves the deck to disk. Pass a file path to `slides` to reload a saved deck with selections pre-populated.
|
|
99
|
+
- **Light/dark/auto theme**: Full theme toggle with `Cmd+Shift+L` (configurable). Persists in localStorage.
|
|
100
|
+
- **Heartbeat watchdog**: Server detects lost browser connections (60s grace) and cleans up.
|
|
101
|
+
- **Idle timeout**: 5-minute inactivity timer after generate-more. Closes the deck if the agent doesn't respond.
|
|
102
|
+
- **Escape confirmation**: Pressing Escape with existing selections shows a confirmation bar before cancelling.
|
|
103
|
+
- **ARIA / keyboard**: `role="radiogroup"` on options, arrow key navigation, Space/Enter to select, number keys for quick pick.
|
|
104
|
+
|
|
105
|
+
## How It Works
|
|
106
|
+
|
|
107
|
+
```
|
|
108
|
+
┌─────────┐ ┌────────────────────────────────────────────┐ ┌─────────┐
|
|
109
|
+
│ Agent │ │ Browser Deck │ │ Agent │
|
|
110
|
+
│ invokes ├─────►│ ├─────►│ receives │
|
|
111
|
+
│design_ │ │ pick → pick → generate more → pick → submit │selections│
|
|
112
|
+
│ deck │ │ ↑ ↑ │ └─────────┘
|
|
113
|
+
└─────────┘ │ │ SSE push ─┘ heartbeat ────────────┤
|
|
114
|
+
└────────────────────────────────────────────┘
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
**Lifecycle:**
|
|
118
|
+
1. Agent calls `design_deck()` with slides JSON — local HTTP server starts, browser opens
|
|
119
|
+
2. User navigates slides, picks one option per slide
|
|
120
|
+
3. Optionally clicks "Generate another option" — agent generates and pushes via `add-option`, deck stays open
|
|
121
|
+
4. User submits — selections returned to agent as `{ slideId: "selected label" }`
|
|
122
|
+
5. Tab auto-closes, thinking level restored to pre-deck value
|
|
123
|
+
|
|
124
|
+
The server persists across tool re-invocations. When generate-more fires, the tool resolves with instructions for the agent to create a new option and call `design_deck({ action: "add-option", slideId: "...", option: "..." })`. The browser shows a skeleton placeholder with shimmer animation until the new option arrives via SSE.
|
|
125
|
+
|
|
126
|
+
## Slides
|
|
127
|
+
|
|
128
|
+
### previewBlocks vs previewHtml
|
|
129
|
+
|
|
130
|
+
Every option needs exactly one of `previewBlocks` or `previewHtml` (not both, not neither).
|
|
131
|
+
|
|
132
|
+
**previewBlocks** — structured array of typed blocks, rendered in order:
|
|
133
|
+
|
|
134
|
+
| Block | Required Fields | Description |
|
|
135
|
+
|-------|----------------|-------------|
|
|
136
|
+
| `code` | `code`, `lang` | Syntax-highlighted code (Prism.js + autoloader) |
|
|
137
|
+
| `mermaid` | `content` | Mermaid diagram. Optional `theme` object for per-block overrides |
|
|
138
|
+
| `html` | `content` | Raw HTML snippet |
|
|
139
|
+
| `image` | `src`, `alt` | Image from disk (absolute path). Optional `caption` |
|
|
140
|
+
|
|
141
|
+
```json
|
|
142
|
+
{
|
|
143
|
+
"previewBlocks": [
|
|
144
|
+
{ "type": "mermaid", "content": "graph TD\n A-->B-->C" },
|
|
145
|
+
{ "type": "code", "code": "export default router;", "lang": "ts" },
|
|
146
|
+
{ "type": "html", "content": "<div style='color:#888'>Implementation notes...</div>" }
|
|
147
|
+
]
|
|
148
|
+
}
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
**previewHtml** — raw HTML string injected directly into the preview container. Full control over styling:
|
|
152
|
+
|
|
153
|
+
```json
|
|
154
|
+
{
|
|
155
|
+
"previewHtml": "<div style='font-family: system-ui; padding: 16px'><h3>Dashboard Layout</h3><div style='display: grid; grid-template-columns: 200px 1fr'>...</div></div>"
|
|
156
|
+
}
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
### Image Blocks
|
|
160
|
+
|
|
161
|
+
Image blocks reference absolute file paths. The server copies each file into a temp directory and serves it via `/assets/` — the browser never sees the original path. Cleanup happens when the deck closes.
|
|
162
|
+
|
|
163
|
+
### Columns
|
|
164
|
+
|
|
165
|
+
Each slide supports `columns: 1 | 2 | 3` to control the grid layout. Omit it and the deck auto-detects based on option count. Use `columns: 1` for wide architecture diagrams, `columns: 2` for side-by-side comparisons.
|
|
166
|
+
|
|
167
|
+
### Aside
|
|
168
|
+
|
|
169
|
+
The `aside` field renders explanatory text below the preview with styled typography. Use `\n` for line breaks. Good for trade-off summaries, pros/cons, or implementation notes that complement the visual preview.
|
|
170
|
+
|
|
171
|
+
### Reserved IDs
|
|
172
|
+
|
|
173
|
+
The slide ID `"summary"` is reserved for the built-in summary slide that appears after all user slides. Don't use it.
|
|
174
|
+
|
|
175
|
+
## Generate-More Loop
|
|
176
|
+
|
|
177
|
+
When the user clicks "Generate another option," the tool resolves with a structured prompt telling the agent which slide needs a new option, what options already exist, and what format to use. The agent generates one new option and pushes it:
|
|
178
|
+
|
|
179
|
+
```typescript
|
|
180
|
+
design_deck({
|
|
181
|
+
action: "add-option",
|
|
182
|
+
slideId: "arch",
|
|
183
|
+
option: '{"label": "Serverless", "previewBlocks": [{"type": "code", "code": "...", "lang": "ts"}]}'
|
|
184
|
+
})
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
The browser shows the new option with an entry animation. The tool blocks again, waiting for the next user action (submit, cancel, or another generate-more).
|
|
188
|
+
|
|
189
|
+
### Model Override
|
|
190
|
+
|
|
191
|
+
The deck shows a model dropdown when 2+ models are available. Users pick which model generates new options. When a model other than the current one is selected, the generate-more result instructs the agent to delegate to a subagent with that model.
|
|
192
|
+
|
|
193
|
+
The default model can be set in the UI (saved to settings) or in `settings.json`:
|
|
194
|
+
|
|
195
|
+
```json
|
|
196
|
+
{
|
|
197
|
+
"designDeck": {
|
|
198
|
+
"generateModel": "google/gemini-3.1-pro"
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
Priority: browser selection > settings default > current model.
|
|
204
|
+
|
|
205
|
+
### Prompt Input
|
|
206
|
+
|
|
207
|
+
An optional text input next to the generate button lets users provide instructions that flow through to the agent (e.g., "make it more minimal" or "use WebSockets instead").
|
|
208
|
+
|
|
209
|
+
## Saving and Loading
|
|
210
|
+
|
|
211
|
+
**Manual save:** Press `Cmd+S` (or `Ctrl+S`) at any time to save the current deck state to disk.
|
|
212
|
+
|
|
213
|
+
**Auto-save on submit:** Enabled by default. Saves a snapshot after successful submission with a `-submitted` suffix.
|
|
214
|
+
|
|
215
|
+
**Auto-save on cancel:** If you cancel a deck that has selections, it's automatically saved with a `-cancelled` suffix. This makes it easy to recover work if you accidentally close the tab or change your mind.
|
|
216
|
+
|
|
217
|
+
**Loading a saved deck:**
|
|
218
|
+
```typescript
|
|
219
|
+
design_deck({ slides: "~/.pi/deck-snapshots/api-design-myapp-main-2026-02-22-143000/deck.json" })
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
The deck opens with selections pre-populated and image paths resolved relative to the snapshot directory.
|
|
223
|
+
|
|
224
|
+
**Snapshot structure:**
|
|
225
|
+
```
|
|
226
|
+
~/.pi/deck-snapshots/
|
|
227
|
+
{title}-{project}-{branch}-{date}-{time}[-submitted|-cancelled]/
|
|
228
|
+
deck.json # Config + selections + metadata
|
|
229
|
+
images/ # Copied image assets (relative paths in JSON)
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
## Keyboard Shortcuts
|
|
233
|
+
|
|
234
|
+
| Key | Action |
|
|
235
|
+
|-----|--------|
|
|
236
|
+
| `Arrow keys` | Navigate slides (left/right) or options within a slide (up/down) |
|
|
237
|
+
| `Space` / `Enter` | Select focused option |
|
|
238
|
+
| `1`-`9` | Quick-select option by number |
|
|
239
|
+
| `Enter` (on last slide) | Submit |
|
|
240
|
+
| `Cmd+S` | Save deck snapshot |
|
|
241
|
+
| `Cmd+Shift+L` | Toggle theme (configurable) |
|
|
242
|
+
| `Escape` | Cancel (confirmation bar if selections exist) |
|
|
243
|
+
|
|
244
|
+
## Configuration
|
|
245
|
+
|
|
246
|
+
Settings in `~/.pi/agent/settings.json` under the `designDeck` key:
|
|
247
|
+
|
|
248
|
+
```json
|
|
249
|
+
{
|
|
250
|
+
"designDeck": {
|
|
251
|
+
"port": 0,
|
|
252
|
+
"browser": "chrome",
|
|
253
|
+
"snapshotDir": "~/.pi/deck-snapshots",
|
|
254
|
+
"autoSaveOnSubmit": true,
|
|
255
|
+
"generateModel": "google/gemini-3.1-pro",
|
|
256
|
+
"theme": {
|
|
257
|
+
"mode": "dark",
|
|
258
|
+
"toggleHotkey": "mod+shift+l"
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
```
|
|
263
|
+
|
|
264
|
+
| Setting | Default | Description |
|
|
265
|
+
|---------|---------|-------------|
|
|
266
|
+
| `port` | `0` (random) | Fixed port for the deck server |
|
|
267
|
+
| `browser` | System default | Browser app to open (e.g., `"chrome"`, `"firefox"`) |
|
|
268
|
+
| `snapshotDir` | `~/.pi/deck-snapshots` | Directory for saved deck snapshots |
|
|
269
|
+
| `autoSaveOnSubmit` | `true` | Auto-save snapshot on successful submit |
|
|
270
|
+
| `generateModel` | — | Default model for generate-more (e.g., `"google/gemini-3.1-pro"`) |
|
|
271
|
+
| `theme.mode` | `"dark"` | `"dark"`, `"light"`, or `"auto"` (follows OS) |
|
|
272
|
+
| `theme.toggleHotkey` | `"mod+shift+l"` | Hotkey string to toggle theme |
|
|
273
|
+
|
|
274
|
+
**Migration:** If you previously had `deckGenerateModel` under the `interview` key, it's automatically migrated to `designDeck.generateModel` on first load.
|
|
275
|
+
|
|
276
|
+
## Tool Parameters
|
|
277
|
+
|
|
278
|
+
The agent handles these when you use the slash commands or ask in natural language. This documents the underlying tool API.
|
|
279
|
+
|
|
280
|
+
| Parameter | Type | Description |
|
|
281
|
+
|-----------|------|-------------|
|
|
282
|
+
| `slides` | string | JSON string of deck config, or file path to a saved deck |
|
|
283
|
+
| `action` | `"add-option"` \| `"replace-options"` | Push or replace options in a running deck |
|
|
284
|
+
| `slideId` | string | Target slide ID (required with actions) |
|
|
285
|
+
| `option` | string | JSON string of one option (required with `add-option`) |
|
|
286
|
+
| `options` | string | JSON string of option array (required with `replace-options`) |
|
|
287
|
+
|
|
288
|
+
Three modes of invocation:
|
|
289
|
+
1. **Start a new deck:** `design_deck({ slides: "<JSON>" })`
|
|
290
|
+
2. **Add option to running deck:** `design_deck({ action: "add-option", slideId: "...", option: "<JSON>" })`
|
|
291
|
+
3. **Replace all options on a slide:** `design_deck({ action: "replace-options", slideId: "...", options: "<JSON array>" })`
|
|
292
|
+
|
|
293
|
+
## File Structure
|
|
294
|
+
|
|
295
|
+
```
|
|
296
|
+
pi-design-deck/
|
|
297
|
+
├── index.ts # Tool registration, module-level state, lifecycle
|
|
298
|
+
├── generate-prompts.ts # Prompt builders for generate-more / regenerate
|
|
299
|
+
├── deck-schema.ts # TypeScript types and validation (no dependencies)
|
|
300
|
+
├── deck-server.ts # HTTP server, SSE, asset serving, snapshots
|
|
301
|
+
├── server-utils.ts # Shared HTTP/session utilities
|
|
302
|
+
├── settings.ts # Settings with designDeck namespace + migration
|
|
303
|
+
├── schema.test.ts # 48 tests across 3 describe blocks
|
|
304
|
+
├── form/
|
|
305
|
+
│ ├── deck.html # HTML template (loads CSS/JS, Prism, Mermaid)
|
|
306
|
+
│ ├── css/ # Theme variables, layout, preview blocks, controls
|
|
307
|
+
│ └── js/ # Client: state, rendering, interaction, session
|
|
308
|
+
├── prompts/
|
|
309
|
+
│ ├── deck.md # /deck — general purpose
|
|
310
|
+
│ ├── deck-plan.md # /deck-plan — design from plan/PRD
|
|
311
|
+
│ └── deck-discover.md # /deck-discover — interview then design
|
|
312
|
+
└── skills/
|
|
313
|
+
└── design-deck/
|
|
314
|
+
└── SKILL.md # Agent skill for on-demand loading
|
|
315
|
+
```
|
|
316
|
+
|
|
317
|
+
## Bundled Skill
|
|
318
|
+
|
|
319
|
+
The extension includes a `design-deck` skill at `skills/design-deck/SKILL.md` that teaches the agent when and how to use the design deck effectively — discovery-first vs deck-direct, slide structure, previewBlocks vs previewHtml, the generate-more loop, and model override patterns.
|
|
320
|
+
|
|
321
|
+
The skill is declared in `package.json` under `pi.skills` and is automatically discovered when the extension is installed. No manual copying needed.
|
|
322
|
+
|
|
323
|
+
## Limitations
|
|
324
|
+
|
|
325
|
+
- Only one deck can be active at a time. Complete or cancel before starting another.
|
|
326
|
+
- Image blocks require absolute file paths on disk (no URLs).
|
|
327
|
+
- The `summary` slide ID is reserved and cannot be used for custom slides.
|
|
328
|
+
- Mermaid diagrams load from CDN — requires internet on first load.
|
|
329
|
+
- macOS tested primarily; Linux and Windows support is best-effort.
|
package/banner.png
ADDED
|
Binary file
|
package/deck-schema.ts
ADDED
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
export type PreviewBlock =
|
|
2
|
+
| { type: "html"; content: string }
|
|
3
|
+
| { type: "mermaid"; content: string; theme?: Record<string, string> }
|
|
4
|
+
| { type: "code"; code: string; lang: string }
|
|
5
|
+
| { type: "image"; src: string; alt: string; caption?: string };
|
|
6
|
+
|
|
7
|
+
export interface DeckOption {
|
|
8
|
+
label: string;
|
|
9
|
+
description?: string;
|
|
10
|
+
aside?: string;
|
|
11
|
+
previewHtml?: string;
|
|
12
|
+
previewBlocks?: PreviewBlock[];
|
|
13
|
+
recommended?: boolean;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface DeckSlide {
|
|
17
|
+
id: string;
|
|
18
|
+
title: string;
|
|
19
|
+
context?: string;
|
|
20
|
+
columns?: 1 | 2 | 3;
|
|
21
|
+
options: DeckOption[];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface DeckConfig {
|
|
25
|
+
title?: string;
|
|
26
|
+
slides: DeckSlide[];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function validatePreviewBlock(block: unknown, slideId: string, label: string, index: number): PreviewBlock {
|
|
30
|
+
if (!block || typeof block !== "object") {
|
|
31
|
+
throw new Error(`Slide "${slideId}": option "${label}" previewBlocks[${index}] must be an object`);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const b = block as Record<string, unknown>;
|
|
35
|
+
const validTypes = ["html", "mermaid", "code", "image"];
|
|
36
|
+
|
|
37
|
+
if (typeof b.type !== "string" || !validTypes.includes(b.type)) {
|
|
38
|
+
throw new Error(`Slide "${slideId}": option "${label}" previewBlocks[${index}] type must be one of: ${validTypes.join(", ")}`);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (b.type === "html") {
|
|
42
|
+
if (typeof b.content !== "string" || b.content.trim() === "") {
|
|
43
|
+
throw new Error(`Slide "${slideId}": option "${label}" html block must have non-empty content`);
|
|
44
|
+
}
|
|
45
|
+
} else if (b.type === "mermaid") {
|
|
46
|
+
if (typeof b.content !== "string" || b.content.trim() === "") {
|
|
47
|
+
throw new Error(`Slide "${slideId}": option "${label}" mermaid block must have non-empty content`);
|
|
48
|
+
}
|
|
49
|
+
if (b.theme !== undefined) {
|
|
50
|
+
if (!b.theme || typeof b.theme !== "object" || Array.isArray(b.theme)) {
|
|
51
|
+
throw new Error(`Slide "${slideId}": option "${label}" mermaid block theme must be an object of string values`);
|
|
52
|
+
}
|
|
53
|
+
for (const [key, val] of Object.entries(b.theme as Record<string, unknown>)) {
|
|
54
|
+
if (typeof val !== "string") {
|
|
55
|
+
throw new Error(`Slide "${slideId}": option "${label}" mermaid block theme.${key} must be a string`);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
} else if (b.type === "code") {
|
|
60
|
+
if (typeof b.code !== "string" || b.code.trim() === "") {
|
|
61
|
+
throw new Error(`Slide "${slideId}": option "${label}" code block must have non-empty code`);
|
|
62
|
+
}
|
|
63
|
+
if (typeof b.lang !== "string" || b.lang.trim() === "") {
|
|
64
|
+
throw new Error(`Slide "${slideId}": option "${label}" code block must have non-empty lang`);
|
|
65
|
+
}
|
|
66
|
+
} else if (b.type === "image") {
|
|
67
|
+
if (typeof b.src !== "string" || b.src.trim() === "") {
|
|
68
|
+
throw new Error(`Slide "${slideId}": option "${label}" image block must have non-empty src`);
|
|
69
|
+
}
|
|
70
|
+
if (typeof b.alt !== "string" || b.alt.trim() === "") {
|
|
71
|
+
throw new Error(`Slide "${slideId}": option "${label}" image block must have non-empty alt`);
|
|
72
|
+
}
|
|
73
|
+
if (b.caption !== undefined && typeof b.caption !== "string") {
|
|
74
|
+
throw new Error(`Slide "${slideId}": option "${label}" image block caption must be a string`);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return b as unknown as PreviewBlock;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function validateDeckOption(option: unknown, slideId: string, index: number): DeckOption {
|
|
82
|
+
if (!option || typeof option !== "object") {
|
|
83
|
+
throw new Error(`Slide "${slideId}": option at index ${index} must be an object`);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const obj = option as Record<string, unknown>;
|
|
87
|
+
|
|
88
|
+
if (typeof obj.label !== "string" || obj.label.trim() === "") {
|
|
89
|
+
throw new Error(`Slide "${slideId}": option at index ${index} must have a non-empty label`);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (obj.previewHtml !== undefined && typeof obj.previewHtml !== "string") {
|
|
93
|
+
throw new Error(
|
|
94
|
+
`Slide "${slideId}": option "${obj.label}" previewHtml must be a string`
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const hasHtml = typeof obj.previewHtml === "string" && obj.previewHtml.trim() !== "";
|
|
99
|
+
const hasBlocks = Array.isArray(obj.previewBlocks) && obj.previewBlocks.length > 0;
|
|
100
|
+
|
|
101
|
+
if (hasHtml && hasBlocks) {
|
|
102
|
+
throw new Error(
|
|
103
|
+
`Slide "${slideId}": option "${obj.label}" must have either previewHtml or previewBlocks, not both`
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (!hasHtml && !hasBlocks) {
|
|
108
|
+
throw new Error(
|
|
109
|
+
`Slide "${slideId}": option "${obj.label}" must have non-empty previewHtml or previewBlocks`
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (hasBlocks) {
|
|
114
|
+
for (let i = 0; i < (obj.previewBlocks as unknown[]).length; i++) {
|
|
115
|
+
validatePreviewBlock((obj.previewBlocks as unknown[])[i], slideId, obj.label as string, i);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (obj.description !== undefined && typeof obj.description !== "string") {
|
|
120
|
+
throw new Error(`Slide "${slideId}": option "${obj.label}" description must be a string`);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (obj.aside !== undefined && typeof obj.aside !== "string") {
|
|
124
|
+
throw new Error(`Slide "${slideId}": option "${obj.label}" aside must be a string`);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (obj.recommended !== undefined && typeof obj.recommended !== "boolean") {
|
|
128
|
+
throw new Error(`Slide "${slideId}": option "${obj.label}" recommended must be boolean`);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return obj as unknown as DeckOption;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function validateDeckSlide(slide: unknown, index: number): DeckSlide {
|
|
135
|
+
if (!slide || typeof slide !== "object") {
|
|
136
|
+
throw new Error(`Slide at index ${index} must be an object`);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const obj = slide as Record<string, unknown>;
|
|
140
|
+
|
|
141
|
+
if (typeof obj.id !== "string" || obj.id.trim() === "") {
|
|
142
|
+
throw new Error(`Slide at index ${index} must have a non-empty id`);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (obj.id === "summary") {
|
|
146
|
+
throw new Error(`Slide at index ${index}: id "summary" is reserved`);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (typeof obj.title !== "string" || obj.title.trim() === "") {
|
|
150
|
+
throw new Error(`Slide "${obj.id}": title must be a non-empty string`);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
if (obj.context !== undefined && typeof obj.context !== "string") {
|
|
154
|
+
throw new Error(`Slide "${obj.id}": context must be a string`);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
if (obj.columns !== undefined) {
|
|
158
|
+
if (obj.columns !== 1 && obj.columns !== 2 && obj.columns !== 3) {
|
|
159
|
+
throw new Error(`Slide "${obj.id}": columns must be 1, 2, or 3`);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
if (!Array.isArray(obj.options) || obj.options.length === 0) {
|
|
164
|
+
throw new Error(`Slide "${obj.id}": options must be a non-empty array`);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
obj.options.forEach((option, optionIndex) => {
|
|
168
|
+
validateDeckOption(option, obj.id as string, optionIndex);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
return obj as unknown as DeckSlide;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export function isDeckOption(value: unknown): value is DeckOption {
|
|
175
|
+
if (!value || typeof value !== "object") return false;
|
|
176
|
+
const obj = value as Record<string, unknown>;
|
|
177
|
+
if (typeof obj.label !== "string" || obj.label.trim() === "") return false;
|
|
178
|
+
if (obj.previewHtml !== undefined && typeof obj.previewHtml !== "string") return false;
|
|
179
|
+
|
|
180
|
+
const hasHtml = typeof obj.previewHtml === "string" && obj.previewHtml.trim() !== "";
|
|
181
|
+
const hasBlocks = Array.isArray(obj.previewBlocks) && obj.previewBlocks.length > 0;
|
|
182
|
+
if (!hasHtml && !hasBlocks) return false;
|
|
183
|
+
if (hasHtml && hasBlocks) return false;
|
|
184
|
+
|
|
185
|
+
if (hasBlocks) {
|
|
186
|
+
try {
|
|
187
|
+
for (let i = 0; i < (obj.previewBlocks as unknown[]).length; i++) {
|
|
188
|
+
validatePreviewBlock((obj.previewBlocks as unknown[])[i], "check", obj.label as string, i);
|
|
189
|
+
}
|
|
190
|
+
} catch {
|
|
191
|
+
return false;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
if (obj.description !== undefined && typeof obj.description !== "string") return false;
|
|
196
|
+
if (obj.aside !== undefined && typeof obj.aside !== "string") return false;
|
|
197
|
+
if (obj.recommended !== undefined && typeof obj.recommended !== "boolean") return false;
|
|
198
|
+
return true;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
export function validateDeckConfig(data: unknown): DeckConfig {
|
|
202
|
+
if (!data || typeof data !== "object" || Array.isArray(data)) {
|
|
203
|
+
throw new Error("Deck config must be an object");
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const obj = data as Record<string, unknown>;
|
|
207
|
+
|
|
208
|
+
if (obj.title !== undefined && typeof obj.title !== "string") {
|
|
209
|
+
throw new Error("Deck config title must be a string");
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
if (!Array.isArray(obj.slides) || obj.slides.length === 0) {
|
|
213
|
+
throw new Error("Deck config slides must be a non-empty array");
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const ids = new Set<string>();
|
|
217
|
+
obj.slides.forEach((slide, index) => {
|
|
218
|
+
const validated = validateDeckSlide(slide, index);
|
|
219
|
+
if (ids.has(validated.id)) {
|
|
220
|
+
throw new Error(`Duplicate slide id: "${validated.id}"`);
|
|
221
|
+
}
|
|
222
|
+
ids.add(validated.id);
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
return obj as unknown as DeckConfig;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
export interface SavedDeckData {
|
|
229
|
+
config: DeckConfig;
|
|
230
|
+
selections: Record<string, string>;
|
|
231
|
+
savedAt: string;
|
|
232
|
+
savedFrom?: {
|
|
233
|
+
cwd: string;
|
|
234
|
+
branch: string | null;
|
|
235
|
+
sessionId: string;
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
export function validateSavedDeck(data: unknown): SavedDeckData {
|
|
240
|
+
if (!data || typeof data !== "object" || Array.isArray(data)) {
|
|
241
|
+
throw new Error("Invalid saved deck: must be an object");
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const obj = data as Record<string, unknown>;
|
|
245
|
+
const config = validateDeckConfig(obj.config);
|
|
246
|
+
|
|
247
|
+
const selections: Record<string, string> = {};
|
|
248
|
+
if (obj.selections && typeof obj.selections === "object" && !Array.isArray(obj.selections)) {
|
|
249
|
+
for (const [key, val] of Object.entries(obj.selections as Record<string, unknown>)) {
|
|
250
|
+
if (typeof val === "string") selections[key] = val;
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
return {
|
|
255
|
+
config,
|
|
256
|
+
selections,
|
|
257
|
+
savedAt: typeof obj.savedAt === "string" ? obj.savedAt : new Date().toISOString(),
|
|
258
|
+
savedFrom: obj.savedFrom && typeof obj.savedFrom === "object"
|
|
259
|
+
? obj.savedFrom as SavedDeckData["savedFrom"]
|
|
260
|
+
: undefined,
|
|
261
|
+
};
|
|
262
|
+
}
|