@dfosco/storyboard 0.6.10 → 0.6.12

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dfosco/storyboard",
3
- "version": "0.6.10",
3
+ "version": "0.6.12",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "description": "Storyboard prototyping framework — core engine, React integration, and canvas",
@@ -110,6 +110,7 @@ npm run lint # Run ESLint
110
110
 
111
111
  The `storyboard` CLI (`sb` alias) wraps dev tooling. The standard `npm run dev` script in this repo just calls `storyboard dev` — use `npm run dev` unless you need a CLI-only feature (e.g. starting a dev server for a different worktree).
112
112
 
113
+ <!-- cli-table --start-->
113
114
  | Command | Description |
114
115
  |---------|-------------|
115
116
  | `npm run dev` _(preferred)_ | Start the Vite dev server for the current worktree |
@@ -152,6 +153,7 @@ The `storyboard` CLI (`sb` alias) wraps dev tooling. The standard `npm run dev`
152
153
  | `storyboard messages send` | Publish and wait for correlated response |
153
154
  | `storyboard messages read` | Read events from a channel |
154
155
  | `storyboard messages batch` | Batch publish + read operations |
156
+ <!-- cli-table --end-->
155
157
 
156
158
  > **Convention: Every server API endpoint must have a corresponding CLI command with 1:1 flag-to-field mapping.** Agents use CLI commands, not curl. When adding a new API endpoint, always create a matching CLI command.
157
159
 
@@ -0,0 +1,18 @@
1
+ # Storyboard
2
+
3
+ > Note: this is a scaffold-source README. The file copied to client repos
4
+ > only contains the fragment bodies — the bare `<!-- … --start-->` markers
5
+ > below are stripped on whole-file copy. Clients can also adopt individual
6
+ > fragments by adding `<!-- storyboard:scaffold/README.md:<id> --start-->`
7
+ > markers around their own content.
8
+
9
+ ## Storyboard Launcher (Desktop App)
10
+
11
+ <!-- launcher-download --start-->
12
+ Download the macOS Launcher from the [latest launcher release](https://github.com/dfosco/storyboard/releases?q=storyboard-launcher&expanded=true).
13
+
14
+ After installing, run:
15
+ ```bash
16
+ sudo xattr -rd com.apple.quarantine '/Applications/Storyboard Launcher.app'
17
+ ```
18
+ <!-- launcher-download --end-->
@@ -52,25 +52,18 @@ packages/core/dist/storyboard-ui.*
52
52
  # Agent Browser
53
53
  agent-browser.json
54
54
 
55
- # Auto-generated scaffold dir (copies of library config defaults — overwritten on every dev-server boot)
56
- .storyboard/scaffold/
57
-
58
- # Real-time canvas selection bridge for Copilot
59
- .storyboard/.selectedwidgets.json
60
-
61
- # Runtime/transient state (per-machine, per-session)
62
- .storyboard/agent-sessions/
63
- .storyboard/hot-pool/
64
- .storyboard/logs/
65
- .storyboard/terminal-buffers/
66
- .storyboard/messages/
67
-
68
- # Private canvas images (tilde prefix = not committed)
55
+ # <!-- runtime-state --start-->
56
+ # Storyboard runtime state + private tilde-prefixed files.
57
+ # Kept in sync by storyboard-scaffold — edit `packages/storyboard/scaffold/gitignore` in storyboard-core.
58
+ .storyboard/
69
59
  src/canvas/images/~*
70
60
  assets/canvas/images/~*
71
61
  assets/canvas/snapshots/~*
72
62
  assets/.storyboard-public/terminal-snapshots/~*
63
+ src/canvas/**/drafts/
64
+ src/prototypes/**/drafts/
73
65
  .sync-target
66
+ # <!-- runtime-state --end-->
74
67
 
75
68
  # Integration test results (ephemeral local artifacts)
76
69
  test-results/
@@ -3,12 +3,12 @@
3
3
  {
4
4
  "source": "scaffold/AGENTS.md",
5
5
  "target": "AGENTS.md",
6
- "mode": "updateable"
6
+ "mode": "scaffold"
7
7
  },
8
8
  {
9
9
  "source": "scaffold/gitignore",
10
10
  "target": ".gitignore",
11
- "mode": "updateable"
11
+ "mode": "scaffold"
12
12
  },
13
13
  {
14
14
  "source": "scaffold/storyboard.config.json",
@@ -89,5 +89,10 @@
89
89
  "target": ".codex/config.toml",
90
90
  "mode": "updateable"
91
91
  }
92
+ ],
93
+ "fragmentSources": [
94
+ "scaffold/gitignore",
95
+ "scaffold/AGENTS.md",
96
+ "scaffold/README.md"
92
97
  ]
93
98
  }
@@ -10,7 +10,7 @@
10
10
  */
11
11
 
12
12
  import * as p from '@clack/prompts'
13
- import { existsSync, writeFileSync, readFileSync, mkdirSync, readdirSync, symlinkSync } from 'fs'
13
+ import { existsSync, writeFileSync, mkdirSync, readdirSync, symlinkSync } from 'fs'
14
14
  import path from 'path'
15
15
  import { execSync, spawn } from 'child_process'
16
16
  import { gettingStartedLines, dim, magenta, bold, yellow, green } from './intro.js'
@@ -398,35 +398,23 @@ if (isInstalled('code')) {
398
398
  p.log.success('Canvas asset directories ready')
399
399
  }
400
400
 
401
- // 7a. Scaffold .gitignore entries for storyboard runtime + private files
401
+ // 7a. Scaffold .gitignore entries for storyboard runtime + private files.
402
+ // Source of truth is `scaffold/gitignore` (with a `runtime-state`
403
+ // fragment that the scaffolder keeps in sync). We invoke the scaffolder
404
+ // in fragments-only mode here so the patterns get refreshed on every
405
+ // setup, even when no whole-file copy is due.
402
406
  {
403
407
  const gitignorePath = '.gitignore'
404
- const privatePatterns = [
405
- // Storyboard runtime state — entirely per-machine, never committed.
406
- // Files that NEED to be public must be written to .storyboard-public/
407
- // (or assets/.storyboard-public/) instead.
408
- '.storyboard/',
409
- // Private canvas images / snapshots (tilde prefix = not committed)
410
- 'src/canvas/images/~*',
411
- 'assets/canvas/images/~*',
412
- 'assets/canvas/snapshots/~*',
413
- 'assets/.storyboard-public/terminal-snapshots/~*',
414
- // Drafts canvases & prototypes — anything inside a `drafts/` dir is
415
- // loaded by `npx storyboard dev` but excluded from `npm run build`.
416
- 'src/canvas/**/drafts/',
417
- 'src/prototypes/**/drafts/',
418
- ]
419
408
  if (existsSync(gitignorePath)) {
420
409
  try {
421
- let content = readFileSync(gitignorePath, 'utf-8')
422
- const missing = privatePatterns.filter(p => !content.includes(p))
423
- if (missing.length > 0) {
424
- const block = '\n# Storyboard: runtime state (gitignored) + private tilde-prefixed files\n' + missing.join('\n') + '\n'
425
- content = content.trimEnd() + '\n' + block
426
- writeFileSync(gitignorePath, content, 'utf-8')
427
- p.log.success('Added storyboard patterns to .gitignore')
428
- }
429
- } catch { /* ignore */ }
410
+ execSync('npx --no-install storyboard-scaffold --fragments-only', {
411
+ stdio: 'ignore',
412
+ cwd: process.cwd(),
413
+ })
414
+ p.log.success('Synced storyboard fragments in .gitignore')
415
+ } catch {
416
+ p.log.info(dim(' Could not run storyboard-scaffold run it manually to refresh .gitignore'))
417
+ }
430
418
  }
431
419
  }
432
420
 
@@ -0,0 +1,287 @@
1
+ /**
2
+ * Scaffold fragment templating — comment-style-agnostic block markers used by
3
+ * `storyboard-scaffold` to keep slices of client-owned files in sync with the
4
+ * library.
5
+ *
6
+ * Two marker schemes:
7
+ *
8
+ * Client-side ("namespaced"):
9
+ * # <!-- storyboard:<source-path>:<fragment-id> --start-->
10
+ * ...content the scaffolder rewrites...
11
+ * # <!-- storyboard:<source-path>:<fragment-id> --end-->
12
+ *
13
+ * The leading `#` (or `//`, or whatever comment prefix the host file uses)
14
+ * is ignored by the parser — we match on the `storyboard:` namespace
15
+ * substring only, so the same scheme works in .gitignore, .yml, .json,
16
+ * .js, .md, etc.
17
+ *
18
+ * Library-side ("bare"):
19
+ * # <!-- <fragment-id> --start-->
20
+ * ...fragment body...
21
+ * # <!-- <fragment-id> --end-->
22
+ *
23
+ * Bare markers live in `packages/storyboard/scaffold/<source-path>`. The
24
+ * scaffolder reads them via `extractFragment()`. When a library file is
25
+ * copied wholesale into a client (no client-side markers present), the
26
+ * scaffolder strips the bare markers first via `stripLibraryMarkers()`
27
+ * so the client gets clean content.
28
+ *
29
+ * Whitespace rules:
30
+ * - Fragment body = the bytes BETWEEN the start-marker line and the
31
+ * end-marker line, exclusive of both. Leading/trailing newlines on the
32
+ * body are preserved exactly so round-trips are stable.
33
+ * - The comment prefix on the start line is repeated on the end line by
34
+ * convention but not enforced — the parser only requires that both
35
+ * markers appear on their own line.
36
+ *
37
+ * Failure modes (all throw):
38
+ * - Nested markers with the same id.
39
+ * - Unbalanced markers (start without end, or vice versa).
40
+ * - Unknown source path or fragment id during apply.
41
+ */
42
+
43
+ const NAMESPACE = 'storyboard'
44
+
45
+ // Captures `storyboard:<src>:<id> --start` or `--end` inside an HTML comment.
46
+ // The wrapping comment chars (`#`, `//`, etc.) AROUND the `<!--` are matched
47
+ // loosely — we only require the `<!--` HTML-comment open/close brackets and
48
+ // the `storyboard:` namespace substring.
49
+ const NAMESPACED_MARKER_RE = new RegExp(
50
+ `<!--\\s*${NAMESPACE}:([^\\s:]+):([A-Za-z0-9][A-Za-z0-9-]*)\\s+--(start|end)\\s*-->`
51
+ )
52
+
53
+ // Library-side bare markers: `<!-- <id> --start-->` or `<!-- <id> --end-->`.
54
+ // `<!--` and `-->` are required so we don't accidentally match prose comments.
55
+ const BARE_MARKER_RE = /<!--\s*([A-Za-z0-9][A-Za-z0-9-]*)\s+--(start|end)\s*-->/
56
+
57
+ /**
58
+ * @typedef {object} NamespacedFragment
59
+ * @property {string} sourcePath - e.g. "scaffold/gitignore"
60
+ * @property {string} id - e.g. "runtime-state"
61
+ * @property {number} startLine - 0-indexed line number of the start marker
62
+ * @property {number} endLine - 0-indexed line number of the end marker
63
+ * @property {string} body - content between markers (exclusive)
64
+ */
65
+
66
+ /**
67
+ * Parse a client-side file for `storyboard:<src>:<id>` fragment markers.
68
+ *
69
+ * @param {string} text
70
+ * @returns {NamespacedFragment[]}
71
+ * @throws if markers are nested, unbalanced, or duplicated.
72
+ */
73
+ export function parseFragments(text) {
74
+ const lines = splitLinesKeepingEol(text)
75
+ const fragments = []
76
+ const open = new Map() // key=`${src}:${id}` → { sourcePath, id, startLine }
77
+
78
+ for (let i = 0; i < lines.length; i++) {
79
+ const m = lines[i].match(NAMESPACED_MARKER_RE)
80
+ if (!m) continue
81
+ const [, sourcePath, id, side] = m
82
+ const key = `${sourcePath}:${id}`
83
+
84
+ if (side === 'start') {
85
+ if (open.has(key)) {
86
+ throw new Error(
87
+ `Duplicate or nested storyboard:${key} --start-- marker at line ${i + 1}`
88
+ )
89
+ }
90
+ open.set(key, { sourcePath, id, startLine: i })
91
+ } else {
92
+ const opened = open.get(key)
93
+ if (!opened) {
94
+ throw new Error(
95
+ `Stray storyboard:${key} --end-- marker at line ${i + 1} with no matching --start--`
96
+ )
97
+ }
98
+ const bodyLines = lines.slice(opened.startLine + 1, i)
99
+ fragments.push({
100
+ sourcePath: opened.sourcePath,
101
+ id: opened.id,
102
+ startLine: opened.startLine,
103
+ endLine: i,
104
+ body: bodyLines.join(''),
105
+ })
106
+ open.delete(key)
107
+ }
108
+ }
109
+
110
+ if (open.size > 0) {
111
+ const first = [...open.values()][0]
112
+ throw new Error(
113
+ `Unclosed storyboard:${first.sourcePath}:${first.id} fragment (start at line ${first.startLine + 1})`
114
+ )
115
+ }
116
+
117
+ return fragments
118
+ }
119
+
120
+ /**
121
+ * Extract a bare-marker fragment body from a library source file.
122
+ *
123
+ * @param {string} srcText
124
+ * @param {string} id
125
+ * @returns {string|null} the fragment body (between markers, exclusive), or
126
+ * null if no matching fragment exists.
127
+ * @throws if markers are nested, unbalanced, or duplicated for the same id.
128
+ */
129
+ export function extractFragment(srcText, id) {
130
+ const lines = splitLinesKeepingEol(srcText)
131
+ let startLine = -1
132
+
133
+ for (let i = 0; i < lines.length; i++) {
134
+ const m = lines[i].match(BARE_MARKER_RE)
135
+ if (!m || m[1] !== id) continue
136
+ const side = m[2]
137
+ if (side === 'start') {
138
+ if (startLine !== -1) {
139
+ throw new Error(`Duplicate --start-- marker for fragment "${id}" at line ${i + 1}`)
140
+ }
141
+ startLine = i
142
+ } else {
143
+ if (startLine === -1) {
144
+ throw new Error(`Stray --end-- marker for fragment "${id}" at line ${i + 1}`)
145
+ }
146
+ const bodyLines = lines.slice(startLine + 1, i)
147
+ return bodyLines.join('')
148
+ }
149
+ }
150
+
151
+ if (startLine !== -1) {
152
+ throw new Error(`Unclosed --start-- marker for fragment "${id}" at line ${startLine + 1}`)
153
+ }
154
+ return null
155
+ }
156
+
157
+ /**
158
+ * Rewrite the bodies of every `storyboard:<src>:<id>` fragment in a client
159
+ * file by looking up the matching library fragment via `lookup(src, id)`.
160
+ *
161
+ * The lookup callback is responsible for resolving the source file and
162
+ * extracting the fragment body — typically:
163
+ *
164
+ * (src, id) => extractFragment(fs.readFileSync(src, 'utf-8'), id)
165
+ *
166
+ * Returns `{ text, replaced, unchanged }` so the caller can report counts and
167
+ * skip writes when nothing changed.
168
+ *
169
+ * @param {string} clientText
170
+ * @param {(sourcePath: string, fragmentId: string) => string|null} lookup
171
+ * @returns {{ text: string, replaced: number, unchanged: number }}
172
+ * @throws if a fragment marker references a source/id the lookup can't resolve.
173
+ */
174
+ export function applyFragments(clientText, lookup) {
175
+ const fragments = parseFragments(clientText)
176
+ if (fragments.length === 0) {
177
+ return { text: clientText, replaced: 0, unchanged: 0 }
178
+ }
179
+
180
+ const lines = splitLinesKeepingEol(clientText)
181
+ let replaced = 0
182
+ let unchanged = 0
183
+
184
+ // Walk fragments in reverse so earlier indices don't shift after splicing.
185
+ for (let i = fragments.length - 1; i >= 0; i--) {
186
+ const frag = fragments[i]
187
+ const newBody = lookup(frag.sourcePath, frag.id)
188
+ if (newBody == null) {
189
+ throw new Error(
190
+ `No fragment "${frag.id}" found in ${frag.sourcePath} (referenced from client line ${frag.startLine + 1})`
191
+ )
192
+ }
193
+
194
+ const newBodyLines = splitLinesKeepingEol(newBody)
195
+ const oldBody = lines.slice(frag.startLine + 1, frag.endLine).join('')
196
+ if (oldBody === newBody) {
197
+ unchanged++
198
+ continue
199
+ }
200
+
201
+ lines.splice(frag.startLine + 1, frag.endLine - frag.startLine - 1, ...newBodyLines)
202
+ replaced++
203
+ }
204
+
205
+ return { text: lines.join(''), replaced, unchanged }
206
+ }
207
+
208
+ /**
209
+ * Strip library-side bare markers from a text. Used by the whole-file copy
210
+ * pass so that when a library file is copied into a client, the markers
211
+ * don't leak through.
212
+ *
213
+ * Defensive behaviour:
214
+ * - Removes ONLY bare-marker lines (`<!-- <id> --start-->` / `--end-->`).
215
+ * - Leaves `storyboard:<src>:<id>` namespaced markers in place — those
216
+ * don't belong in library sources, but if a contributor adds one we
217
+ * shouldn't silently delete it.
218
+ * - Collapses any blank lines left behind so the output stays clean.
219
+ *
220
+ * @param {string} text
221
+ * @returns {{ text: string, stripped: number }} number of marker LINES removed.
222
+ */
223
+ export function stripLibraryMarkers(text) {
224
+ const lines = splitLinesKeepingEol(text)
225
+ let stripped = 0
226
+
227
+ const filtered = lines.filter((line) => {
228
+ if (NAMESPACED_MARKER_RE.test(line)) return true
229
+
230
+ const m = line.match(BARE_MARKER_RE)
231
+ if (!m) return true
232
+
233
+ stripped++
234
+ return false
235
+ })
236
+
237
+ // Collapse runs of >2 consecutive blank lines that may now exist where
238
+ // a marker used to live.
239
+ const collapsed = []
240
+ let blankRun = 0
241
+ for (const line of filtered) {
242
+ const isBlank = /^\s*$/.test(stripEol(line))
243
+ if (isBlank) {
244
+ blankRun++
245
+ if (blankRun > 1) continue
246
+ } else {
247
+ blankRun = 0
248
+ }
249
+ collapsed.push(line)
250
+ }
251
+
252
+ return { text: collapsed.join(''), stripped }
253
+ }
254
+
255
+ // ---------------------------------------------------------------------------
256
+ // Internals
257
+ // ---------------------------------------------------------------------------
258
+
259
+ /**
260
+ * Split text into an array of lines that, when joined, reproduce the input
261
+ * byte-for-byte. Each element retains its trailing `\n` / `\r\n` (or no EOL
262
+ * for a trailing non-terminated line).
263
+ *
264
+ * This is the only safe way to splice line ranges back together without
265
+ * mangling line endings — a naive `split('\n').join('\n')` corrupts CRLF
266
+ * files and drops the trailing newline.
267
+ */
268
+ function splitLinesKeepingEol(text) {
269
+ if (text === '') return []
270
+ const out = []
271
+ let start = 0
272
+ for (let i = 0; i < text.length; i++) {
273
+ const ch = text[i]
274
+ if (ch === '\n') {
275
+ out.push(text.slice(start, i + 1))
276
+ start = i + 1
277
+ }
278
+ }
279
+ if (start < text.length) {
280
+ out.push(text.slice(start))
281
+ }
282
+ return out
283
+ }
284
+
285
+ function stripEol(line) {
286
+ return line.replace(/\r?\n$/, '')
287
+ }