@dfosco/storyboard 0.6.3 → 0.6.5

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.
@@ -133,6 +133,8 @@ export function buildPrototypeIndex(knownRoutes = []) {
133
133
  const raw = getPrototypeMetadata(name)
134
134
  const meta = raw?.meta || raw || {}
135
135
  const isExternal = Boolean(raw?.url)
136
+ const folder = raw?.folder || null
137
+ const isPrivate = raw?._isPrivate || false
136
138
  protoMap[name] = {
137
139
  name: meta.title || name,
138
140
  dirName: name,
@@ -144,9 +146,10 @@ export function buildPrototypeIndex(knownRoutes = []) {
144
146
  team: meta.team || null,
145
147
  tags: meta.tags || null,
146
148
  hideFlows: meta.hideFlows ?? raw?.hideFlows ?? true,
147
- folder: raw?.folder || null,
149
+ folder,
148
150
  isExternal,
149
151
  externalUrl: isExternal ? raw.url : null,
152
+ isPrivate,
150
153
  flows: [],
151
154
  }
152
155
  }
@@ -202,6 +205,7 @@ export function buildPrototypeIndex(knownRoutes = []) {
202
205
  dirName: folderName,
203
206
  description: meta.description || null,
204
207
  icon: meta.icon || null,
208
+ isPrivate: !!raw?._isPrivate,
205
209
  prototypes: [],
206
210
  }
207
211
  }
@@ -218,6 +222,7 @@ export function buildPrototypeIndex(knownRoutes = []) {
218
222
  dirName: proto.folder,
219
223
  description: null,
220
224
  icon: null,
225
+ isPrivate: false,
221
226
  prototypes: [proto],
222
227
  }
223
228
  } else {
@@ -243,25 +248,56 @@ export function buildPrototypeIndex(knownRoutes = []) {
243
248
  const pageName = data.title || toTitleCase(fileSegment)
244
249
 
245
250
  const pageEntry = {
251
+ id: canvasId,
246
252
  name: pageName,
247
253
  route: data._route || `/canvas/${canvasId}`,
254
+ isPrivate: !!data._isPrivate,
248
255
  }
249
256
 
250
257
  // If this canvas belongs to a group we've already seen, add as a page
251
258
  if (group && seenGroups.has(group)) {
252
259
  const idx = seenGroups.get(group)
253
- if (!canvasEntries[idx].pages) {
260
+ const existing = canvasEntries[idx]
261
+ if (!existing.pages) {
254
262
  // Retroactively add the first page
255
- const firstId = canvasEntries[idx].dirName
263
+ const firstId = existing.dirName
256
264
  const firstData = getCanvasData(firstId)
257
265
  const firstSegment = firstId.split('/').pop()
258
266
  const firstName = firstData?.title || toTitleCase(firstSegment)
259
- canvasEntries[idx].pages = [{ name: firstName, route: canvasEntries[idx].route }]
267
+ existing.pages = [{
268
+ id: firstId,
269
+ name: firstName,
270
+ route: existing.route,
271
+ isPrivate: !!firstData?._isPrivate,
272
+ }]
260
273
  }
261
- canvasEntries[idx].pages.push(pageEntry)
274
+ // If the existing card is private but this incoming page is public,
275
+ // promote the public page to be the card's public face. The private
276
+ // sibling stays as one of the group's pages (with its eye-off icon
277
+ // in the dropdown). Without this swap, the workspace card would
278
+ // inherit the title + privacy of whichever page happened to come
279
+ // first in iteration order.
280
+ const incomingIsPrivate = !!data._isPrivate || canvasId.split('/').includes('drafts')
281
+ if (existing.isPrivate && !incomingIsPrivate) {
282
+ existing.name = meta?.title || data.title || canvasId
283
+ existing.dirName = canvasId
284
+ existing.description = meta?.description || data.description || null
285
+ existing.route = data._route || `/canvas/${canvasId}`
286
+ existing.folder = data._folder || existing.folder
287
+ existing.isPrivate = false
288
+ existing.author = meta?.author || data.author || existing.author
289
+ existing.gitAuthor = data.gitAuthor || existing.gitAuthor
290
+ existing._canvasMeta = meta || existing._canvasMeta
291
+ }
292
+ existing.pages.push(pageEntry)
262
293
  continue
263
294
  }
264
295
 
296
+ // Canvas is "private" when its parsed data carries _isPrivate (from the
297
+ // data plugin) or — fallback — when its canonical ID contains a `drafts`
298
+ // path segment.
299
+ const isPrivate = data._isPrivate
300
+ || canvasId.split('/').includes('drafts')
265
301
  const entry = {
266
302
  name: meta?.title || data.title || canvasId,
267
303
  dirName: canvasId,
@@ -269,6 +305,7 @@ export function buildPrototypeIndex(knownRoutes = []) {
269
305
  route: data._route || `/canvas/${canvasId}`,
270
306
  folder: data._folder || null,
271
307
  isCanvas: true,
308
+ isPrivate,
272
309
  author: meta?.author || data.author || null,
273
310
  gitAuthor: data.gitAuthor || null,
274
311
  _canvasMeta: meta || null,
@@ -287,8 +324,10 @@ export function buildPrototypeIndex(knownRoutes = []) {
287
324
  if (key) orderMap.set(key, idx)
288
325
  })
289
326
  entry.pages.sort((a, b) => {
290
- const aIdx = orderMap.has(a.name) ? orderMap.get(a.name) : Infinity
291
- const bIdx = orderMap.has(b.name) ? orderMap.get(b.name) : Infinity
327
+ const aKey = a.id ?? a.name
328
+ const bKey = b.id ?? b.name
329
+ const aIdx = orderMap.has(aKey) ? orderMap.get(aKey) : Infinity
330
+ const bIdx = orderMap.has(bKey) ? orderMap.get(bKey) : Infinity
292
331
  return aIdx - bIdx
293
332
  })
294
333
  }
@@ -305,6 +344,7 @@ export function buildPrototypeIndex(knownRoutes = []) {
305
344
  dirName: canvas.folder,
306
345
  description: null,
307
346
  icon: null,
347
+ isPrivate: false,
308
348
  prototypes: [],
309
349
  canvases: [canvas],
310
350
  }
@@ -78,11 +78,16 @@ function scanDirectory(root, watchEntry, config) {
78
78
 
79
79
  /**
80
80
  * Compute the route path for a prototype file (relative to src/prototypes/).
81
- * Mirrors the route regex in src/routes.jsx.
81
+ * Mirrors the route regex in src/routes.jsx — strips `.folder/` segments,
82
+ * `drafts/` segments (scratch dirs share the public URL of their non-draft
83
+ * sibling), and trailing file extension.
82
84
  */
83
85
  function prototypeRoute(relPath) {
84
86
  let route = relPath
85
87
  .replace(/[^/]*\.folder\//g, '')
88
+ .split('/')
89
+ .filter((seg) => seg !== 'drafts')
90
+ .join('/')
86
91
  .replace(/\.(jsx|tsx|mdx)$/, '')
87
92
  .replace(/\/index$/, '')
88
93
 
@@ -92,12 +97,14 @@ function prototypeRoute(relPath) {
92
97
 
93
98
  /**
94
99
  * Compute the route path for a canvas file (relative to src/canvas/).
95
- * Uses toCanvasId() for proper folder handling.
100
+ * Uses toCanvasId() for proper folder handling, then strips `drafts/`
101
+ * segments so moves in/out of `drafts/` are no-ops.
96
102
  */
97
103
  function canvasRoute(relPath) {
98
104
  // relPath is relative to src/canvas/, prepend prefix for toCanvasId()
99
105
  const canvasId = toCanvasId('src/canvas/' + relPath)
100
- return '/canvas/' + canvasId
106
+ const stripped = canvasId.split('/').filter((seg) => seg !== 'drafts').join('/')
107
+ return '/canvas/' + stripped
101
108
  }
102
109
 
103
110
  function computeRoute(relPath, watchType) {
@@ -6,9 +6,10 @@
6
6
  */
7
7
  import { useState, useEffect, useRef, useMemo, useCallback, useSyncExternalStore } from 'react'
8
8
  import { buildPrototypeIndex, listStories, getStoryData, BranchSelect, getCustomerModeConfig } from '../core/index.js'
9
- import { MarkGithubIcon, GitBranchIcon, ChevronDownIcon, ChevronRightIcon, PlusIcon, StarIcon, StarFillIcon, ThreeBarsIcon, XIcon, StackIcon, TrashIcon, ShieldLockIcon, KebabHorizontalIcon, PencilIcon } from '@primer/octicons-react'
9
+ import { MarkGithubIcon, GitBranchIcon, ChevronDownIcon, ChevronRightIcon, PlusIcon, StarIcon, StarFillIcon, ThreeBarsIcon, XIcon, StackIcon, TrashIcon, ShieldLockIcon, KebabHorizontalIcon, PencilIcon, EyeClosedIcon } from '@primer/octicons-react'
10
10
  import { Menu } from '@base-ui/react/menu'
11
11
  import { Dialog } from '@base-ui/react/dialog'
12
+ import { Tooltip } from '@primer/react'
12
13
  import Icon from './Icon.jsx'
13
14
  import { useBranches } from './BranchBar/useBranches.js'
14
15
  import css from './Viewfinder.module.css'
@@ -467,9 +468,23 @@ function ArtifactCard({ item, basePath, starred, onToggleStar, onItemDeleted })
467
468
 
468
469
  return (
469
470
  <>
470
- <Tag className={css.card} {...linkProps} onClick={handleClick}>
471
+ <Tag className={`${css.card}${item.isPrivate ? ' ' + css.cardPrivate : ''}`} {...linkProps} onClick={handleClick}>
471
472
  <div className={css.cardHeader}>
472
- <span className={css.cardBadge}>{getTypeLabel(item.type)}</span>
473
+ <div className={css.cardBadgeGroup}>
474
+ <span className={css.cardBadge}>{getTypeLabel(item.type)}</span>
475
+ {item.isPrivate && (
476
+ <Tooltip text={`${typeLabel} not published, only visible to you`} direction="n">
477
+ <button
478
+ type="button"
479
+ className={css.cardPrivateBadge}
480
+ aria-label={`Private ${typeLabel.toLowerCase()}`}
481
+ onClick={(e) => { e.preventDefault(); e.stopPropagation() }}
482
+ >
483
+ <EyeClosedIcon size={12} />
484
+ </button>
485
+ </Tooltip>
486
+ )}
487
+ </div>
473
488
  <div className={css.cardActions}>
474
489
  <StarBtn active={starred} onClick={() => onToggleStar(item.id)} inline />
475
490
  {item.flows?.length > 1 && <FlowsDropdown flows={item.flows} basePath={basePath} />}
@@ -609,8 +624,12 @@ function PagesDropdown({ pages, basePath }) {
609
624
  <a
610
625
  href={withBase(basePath, page.route)}
611
626
  className={css.flowsItemLink}
627
+ title={page.isPrivate ? 'Local-only page — gitignored, dev-only' : undefined}
612
628
  >
613
629
  {page.name}
630
+ {page.isPrivate && (
631
+ <EyeClosedIcon size={12} className={css.flowsItemPrivateIcon} />
632
+ )}
614
633
  </a>
615
634
  </Menu.Item>
616
635
  ))}
@@ -624,17 +643,36 @@ function PagesDropdown({ pages, basePath }) {
624
643
  /* ─── Folder Section ─── */
625
644
 
626
645
  function FolderSection({ folder, collapsed, onToggle, basePath, starred, onToggleStar, onItemDeleted }) {
646
+ const sectionClass = collapsed ? css.folderSectionCollapsed : css.folderSection
627
647
  return (
628
- <section className={collapsed ? css.folderSectionCollapsed : css.folderSection}>
629
- <button className={css.folderHeader} onClick={onToggle}>
648
+ <section className={`${sectionClass}${folder.isPrivate ? ' ' + css.folderSectionPrivate : ''}`}>
649
+ <div
650
+ className={css.folderHeader}
651
+ role="button"
652
+ tabIndex={0}
653
+ onClick={onToggle}
654
+ onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onToggle() } }}
655
+ >
630
656
  <Icon name={collapsed ? 'folder' : 'folder-open'} size={16} className={css.folderIcon} />
631
657
  <span className={css.folderName}>{folder.name}</span>
658
+ {folder.isPrivate && (
659
+ <Tooltip text="Folder not published, only visible to you" direction="n">
660
+ <button
661
+ type="button"
662
+ className={css.folderPrivateBadge}
663
+ aria-label="Private folder"
664
+ onClick={(e) => { e.preventDefault(); e.stopPropagation() }}
665
+ >
666
+ <EyeClosedIcon size={12} />
667
+ </button>
668
+ </Tooltip>
669
+ )}
632
670
  <span className={css.folderCount}>{folder.items.length}</span>
633
671
  <ChevronRightIcon
634
672
  size={14}
635
673
  className={collapsed ? css.folderChevron : css.folderChevronExpanded}
636
674
  />
637
- </button>
675
+ </div>
638
676
  {!collapsed && (
639
677
  <div className={css.grid}>
640
678
  {folder.items.map(item => (
@@ -1164,6 +1202,7 @@ function WorkspaceImpl({
1164
1202
  folder: proto.folder,
1165
1203
  description: proto.description,
1166
1204
  flows: proto.flows || [],
1205
+ isPrivate: !!proto.isPrivate,
1167
1206
  })
1168
1207
  }
1169
1208
 
@@ -1187,6 +1226,7 @@ function WorkspaceImpl({
1187
1226
  folder: canvas.folder,
1188
1227
  description: canvas.description,
1189
1228
  pages: canvas.pages || null,
1229
+ isPrivate: !!canvas.isPrivate,
1190
1230
  })
1191
1231
  }
1192
1232
 
@@ -1200,9 +1240,10 @@ function WorkspaceImpl({
1200
1240
  for (const name of storyNames) {
1201
1241
  const data = getStoryData(name)
1202
1242
  if (!data) continue
1243
+ const displayName = (name.split('/').pop() || name).replace(/^~/, '')
1203
1244
  items.push({
1204
1245
  id: `component:${name}`,
1205
- name: name.split('-').map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(' '),
1246
+ name: displayName.split('-').map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(' '),
1206
1247
  type: 'component',
1207
1248
  author: null,
1208
1249
  gitAuthor: null,
@@ -1212,6 +1253,7 @@ function WorkspaceImpl({
1212
1253
  externalUrl: null,
1213
1254
  folder: null,
1214
1255
  description: null,
1256
+ isPrivate: !!data._isPrivate || name.split('/').includes('drafts'),
1215
1257
  })
1216
1258
  }
1217
1259
 
@@ -1298,6 +1340,7 @@ function WorkspaceImpl({
1298
1340
  const folders = Object.entries(folderItems).map(([dirName, fItems]) => ({
1299
1341
  dirName,
1300
1342
  name: folderMeta[dirName]?.name || dirName,
1343
+ isPrivate: !!folderMeta[dirName]?.isPrivate || dirName === 'drafts',
1301
1344
  items: fItems,
1302
1345
  }))
1303
1346
  folders.sort((a, b) => {
@@ -1357,7 +1400,13 @@ function WorkspaceImpl({
1357
1400
  >
1358
1401
  {sidebarOpen ? <XIcon size={18} /> : <ThreeBarsIcon size={18} />}
1359
1402
  </button>
1360
- <div className={`${css.logo} smooth-corners`}><Icon name="iconoir/key-command" size={22} color="#fff" /></div>
1403
+ <img
1404
+ src={`${basePath || '/'}favicon.svg`}
1405
+ alt=""
1406
+ width={48}
1407
+ height={48}
1408
+ className={css.logo}
1409
+ />
1361
1410
  <div>
1362
1411
  <div className={css.appName}>{title}</div>
1363
1412
  {subtitle && <div className={css.appSubtitle}>{subtitle}</div>}
@@ -30,13 +30,8 @@
30
30
  .logo {
31
31
  width: 48px;
32
32
  height: 48px;
33
- background: var(--bgColor-emphasis, #313131);
34
- border-radius: 8px;
35
33
  transform: rotate(-1deg);
36
- display: flex;
37
- align-items: center;
38
- justify-content: center;
39
- color: var(--fgColor-onEmphasis, #fff);
34
+ display: block;
40
35
  flex-shrink: 0;
41
36
  }
42
37
 
@@ -642,6 +637,14 @@
642
637
  color: inherit;
643
638
  }
644
639
 
640
+ .cardPrivate {
641
+ opacity: 0.8;
642
+ }
643
+
644
+ .cardPrivate:hover {
645
+ opacity: 1;
646
+ }
647
+
645
648
  .cardThumb {
646
649
  height: 140px;
647
650
  background: var(--bgColor-muted, #f5f5f5);
@@ -663,6 +666,22 @@
663
666
  color: var(--fgColor-muted, #555);
664
667
  }
665
668
 
669
+ .cardPrivateBadge {
670
+ display: inline-flex;
671
+ align-items: center;
672
+ justify-content: center;
673
+ color: var(--fgColor-muted, #656d76);
674
+ background: none;
675
+ border: none;
676
+ padding: 2px;
677
+ }
678
+
679
+ .cardBadgeGroup {
680
+ display: flex;
681
+ align-items: center;
682
+ gap: 6px;
683
+ }
684
+
666
685
  /* Icon buttons (star, flows, etc.) */
667
686
 
668
687
  .iconBtn {
@@ -1036,6 +1055,25 @@
1036
1055
  flex: 1;
1037
1056
  }
1038
1057
 
1058
+ .folderSectionPrivate {
1059
+ opacity: 0.8;
1060
+ }
1061
+
1062
+ .folderSectionPrivate:hover {
1063
+ opacity: 1;
1064
+ }
1065
+
1066
+ .folderPrivateBadge {
1067
+ display: inline-flex;
1068
+ align-items: center;
1069
+ justify-content: center;
1070
+ color: var(--fgColor-muted, #656d76);
1071
+ background: none;
1072
+ border: none;
1073
+ padding: 2px;
1074
+ margin-right: 6px;
1075
+ }
1076
+
1039
1077
  .folderCount {
1040
1078
  font-size: 14px;
1041
1079
  font-weight: 400;
@@ -1119,11 +1157,19 @@
1119
1157
  }
1120
1158
 
1121
1159
  .flowsItemLink {
1122
- display: block;
1160
+ display: flex;
1161
+ align-items: center;
1162
+ gap: 6px;
1123
1163
  color: inherit;
1124
1164
  text-decoration: none;
1125
1165
  }
1126
1166
 
1167
+ .flowsItemPrivateIcon {
1168
+ color: var(--fgColor-muted, #656d76);
1169
+ flex-shrink: 0;
1170
+ margin-left: auto;
1171
+ }
1172
+
1127
1173
  /* Avatar stack */
1128
1174
 
1129
1175
  .avatarStack {
@@ -1636,6 +1636,15 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
1636
1636
  : [],
1637
1637
  createdAt: snapshot.createdAt ?? null,
1638
1638
  updatedAt: snapshot.updatedAt ?? null,
1639
+ // PR-specific metadata (state, branches, diff stats)
1640
+ state: snapshot.state ?? null,
1641
+ merged: typeof snapshot.merged === 'boolean' ? snapshot.merged : null,
1642
+ draft: typeof snapshot.draft === 'boolean' ? snapshot.draft : null,
1643
+ baseRef: typeof snapshot.baseRef === 'string' ? snapshot.baseRef : null,
1644
+ headRef: typeof snapshot.headRef === 'string' ? snapshot.headRef : null,
1645
+ additions: typeof snapshot.additions === 'number' ? snapshot.additions : null,
1646
+ deletions: typeof snapshot.deletions === 'number' ? snapshot.deletions : null,
1647
+ changedFiles: typeof snapshot.changedFiles === 'number' ? snapshot.changedFiles : null,
1639
1648
  fetchedAt: new Date().toISOString(),
1640
1649
  },
1641
1650
  }
@@ -497,11 +497,25 @@ export default function PageSelector({ currentName, pages: initialPages, isLocal
497
497
  ) : (
498
498
  <a
499
499
  href={getPageHref(page)}
500
- className={styles.itemLink}
500
+ className={`${styles.itemLink} ${page.isPrivate ? styles.itemPrivate : ''}`}
501
501
  onClick={(e) => handleItemClick(page, e)}
502
502
  onDoubleClick={(e) => e.stopPropagation()}
503
+ title={page.isPrivate ? 'Local-only page — gitignored, dev-only' : undefined}
503
504
  >
504
505
  <span className={styles.itemContent}>{page.title}</span>
506
+ {page.isPrivate && (
507
+ <svg
508
+ className={styles.privateIcon}
509
+ width="14"
510
+ height="14"
511
+ viewBox="0 0 16 16"
512
+ fill="currentColor"
513
+ aria-hidden="true"
514
+ >
515
+ <path d="M.143 2.31a.75.75 0 0 1 1.047-.167l14.5 10.5a.75.75 0 1 1-.88 1.214l-2.248-1.628C11.346 13.19 9.792 14 8 14c-1.981 0-3.67-.992-4.933-2.078C1.797 10.832.88 9.577.43 8.9a1.62 1.62 0 0 1 0-1.797c.353-.533 1.068-1.495 2.062-2.42L.31 3.357A.75.75 0 0 1 .143 2.31Zm1.536 5.622A.12.12 0 0 0 1.659 8c0 .021.006.045.02.068.387.582 1.211 1.703 2.31 2.646C5.1 11.668 6.42 12.5 8 12.5c1.286 0 2.413-.55 3.359-1.252L9.81 10.13a2.5 2.5 0 0 1-3.498-3.498L4.388 5.226c-1.014.91-1.749 1.886-2.108 2.426Zm6.728 1.42-1.668-1.208a1 1 0 0 0 1.36 1.207Z" />
516
+ <path d="M8 3.5c-.387 0-.74.057-1.063.145L5.793 2.503A7.84 7.84 0 0 1 8 2.207c1.981 0 3.67.992 4.933 2.078 1.27 1.09 2.187 2.345 2.637 3.022a1.62 1.62 0 0 1 0 1.795c-.247.371-.622.86-1.118 1.384L13.39 9.4c.402-.43.706-.83.91-1.137a.121.121 0 0 0 .012-.022.122.122 0 0 0-.012-.023c-.387-.582-1.211-1.703-2.31-2.646C10.9 4.733 9.58 3.9 8 3.9Zm.482 2.067-.044-.011a.748.748 0 0 0 .044.011Zm-.482 0c-.272 0-.5.228-.5.5 0 .272.228.5.5.5h.022l1.024.741A2.5 2.5 0 0 0 8 5.5Z" />
517
+ </svg>
518
+ )}
505
519
  </a>
506
520
  )}
507
521
  {!isEditing && isLocalDev && (
@@ -166,6 +166,20 @@
166
166
  background: var(--bgColor-accent-muted, #ddf4ff);
167
167
  }
168
168
 
169
+ .itemPrivate {
170
+ opacity: 0.55;
171
+ }
172
+
173
+ .itemPrivate:hover {
174
+ opacity: 1;
175
+ }
176
+
177
+ .privateIcon {
178
+ color: var(--fgColor-muted, #656d76);
179
+ flex-shrink: 0;
180
+ margin-left: 4px;
181
+ }
182
+
169
183
  .separator {
170
184
  height: 1px;
171
185
  background: var(--borderColor-default, rgba(0, 0, 0, 0.15));
@@ -48,32 +48,41 @@ export default function FrozenTerminalOverlay({ widgetId, onActivate }) {
48
48
 
49
49
  async function fetchSnapshot() {
50
50
  const baseUrl = getBaseUrl()
51
- const urls = [
52
- `${baseUrl}_storyboard/canvas/terminal-snapshot/${widgetId}`,
53
- `${baseUrl}_storyboard/terminal-snapshots/${widgetId}.snapshot.json`,
54
- ]
55
-
56
- for (const url of urls) {
57
- try {
58
- const res = await fetch(url)
59
- if (!res.ok) continue
51
+ // Try the dev-server REST endpoint first, then fall back to the static
52
+ // consolidated index (production builds / no server).
53
+ const restUrl = `${baseUrl}_storyboard/canvas/terminal-snapshot/${widgetId}`
54
+ const indexUrl = `${baseUrl}_storyboard/terminal-snapshots/agents.snapshot.json`
55
+
56
+ try {
57
+ const res = await fetch(restUrl)
58
+ if (res.ok) {
60
59
  const data = await res.json()
61
60
  if (cancelled) return
62
61
  const text = data.paneContent || data.content || data.output || ''
63
- if (!text) continue
64
-
65
- const converter = await getConverter()
66
- if (cancelled) return
67
- if (converter) {
68
- setHtml(converter.toHtml(text))
69
- } else {
70
- setPlainText(stripAnsi(text))
62
+ if (text) {
63
+ const converter = await getConverter()
64
+ if (cancelled) return
65
+ if (converter) setHtml(converter.toHtml(text))
66
+ else setPlainText(stripAnsi(text))
67
+ return
71
68
  }
72
- return
73
- } catch {
74
- continue
75
69
  }
76
- }
70
+ } catch { /* fall through to static index */ }
71
+
72
+ try {
73
+ const res = await fetch(indexUrl)
74
+ if (!res.ok) return
75
+ const data = await res.json()
76
+ if (cancelled) return
77
+ const entry = data?.agents?.[widgetId]
78
+ if (!entry) return
79
+ const text = entry.paneContent || entry.content || entry.output || ''
80
+ if (!text) return
81
+ const converter = await getConverter()
82
+ if (cancelled) return
83
+ if (converter) setHtml(converter.toHtml(text))
84
+ else setPlainText(stripAnsi(text))
85
+ } catch { /* empty */ }
77
86
  }
78
87
 
79
88
  fetchSnapshot()
@@ -599,8 +599,8 @@
599
599
  .prStateBadge {
600
600
  display: inline-flex;
601
601
  align-items: center;
602
- gap: 4px;
603
- padding: 3px 8px;
602
+ gap: 6px;
603
+ padding: 5px 10px 5px 9px;
604
604
  border-radius: 2em;
605
605
  font-size: 12px;
606
606
  font-weight: 500;
@@ -54,13 +54,41 @@ export default forwardRef(function PrototypeEmbed({ id: widgetId, props, onUpdat
54
54
 
55
55
  const basePath = (import.meta.env.BASE_URL || '/').replace(/\/$/, '')
56
56
  const baseSegment = basePath.replace(/^\//, '')
57
+ // Internal prototype iframes load the isolated prototypes.html entry
58
+ // (see .agents/plans/vite-isolation.md). prototypes.html uses
59
+ // createHashRouter so the prototype route lives in the URL hash —
60
+ // any path /MyProto/SignupForm becomes prototypes.html#/MyProto/SignupForm.
61
+ // External http(s) URLs are left alone.
57
62
  const rawSrc = useMemo(() => {
58
63
  if (!src) return ''
59
64
  if (/^https?:\/\//.test(src)) return src
60
65
  const cleaned = src.replace(/^\/branch--[^/]+/, '')
61
- if (baseSegment && cleaned.startsWith(basePath)) return cleaned
62
- if (baseSegment && cleaned.startsWith(baseSegment)) return `/${cleaned}`
63
- return `${basePath}${cleaned}`
66
+ let normalized
67
+ if (baseSegment && cleaned.startsWith(basePath)) normalized = cleaned
68
+ else if (baseSegment && cleaned.startsWith(baseSegment)) normalized = `/${cleaned}`
69
+ else normalized = `${basePath}${cleaned}`
70
+ // Strip basePath so we can split path/query/hash and re-anchor on
71
+ // prototypes.html. Any pre-existing #hash on the prototype URL is
72
+ // preserved by appending it after the route hash with an extra `#`,
73
+ // so the inner router still parses /MyProto correctly.
74
+ const withoutBase = baseSegment && normalized.startsWith(basePath)
75
+ ? normalized.slice(basePath.length) || '/'
76
+ : normalized
77
+ const hashIdx = withoutBase.indexOf('#')
78
+ const innerHash = hashIdx >= 0 ? withoutBase.slice(hashIdx + 1) : ''
79
+ const pathAndQuery = hashIdx >= 0 ? withoutBase.slice(0, hashIdx) : withoutBase
80
+ const routeHash = pathAndQuery.startsWith('/') ? pathAndQuery : `/${pathAndQuery}`
81
+ const suffix = innerHash ? `#${innerHash}` : ''
82
+ // Extract the prototype name (first path segment) so the dev iframe can
83
+ // narrow its router to just that prototype's subtree — broken siblings
84
+ // never appear in the matched lazy() chain. The consumer's
85
+ // prototypes-entry.jsx reads ?proto= and calls getRoutesForProto(); if
86
+ // it doesn't (older scaffold), the param is harmlessly ignored and the
87
+ // full route tree loads. Prod builds skip the param entirely.
88
+ const pathOnly = pathAndQuery.split('?')[0]
89
+ const protoName = pathOnly.split('/').filter(Boolean)[0] || ''
90
+ const queryStr = (import.meta.env.DEV && protoName) ? `?proto=${encodeURIComponent(protoName)}` : ''
91
+ return `${basePath}/prototypes.html${queryStr}#${routeHash}${suffix}`
64
92
  }, [src, basePath, baseSegment])
65
93
 
66
94
  const scale = zoom / 100
@@ -31,11 +31,23 @@ function resolveStorySetUrl(storyId, layout, selected, density, theme) {
31
31
  const story = getStoryData(storyId)
32
32
  if (!story?._storyModule) return ''
33
33
  const base = (import.meta.env.BASE_URL || '/').replace(/\/$/, '')
34
+
35
+ // In dev, prefer the dedicated isolate-set middleware: it serves a
36
+ // minimal HTML shell + dynamic-imports just this one story module, so a
37
+ // broken story cannot poison the canvas SPA's module graph. The
38
+ // middleware doesn't exist in deployed builds, so production falls
39
+ // through to the real story page with `_sb_component_set`.
40
+ if (import.meta.env.DEV) {
41
+ const params = new URLSearchParams()
42
+ params.set('module', story._storyModule)
43
+ if (layout) params.set('layout', layout)
44
+ if (selected) params.set('selected', selected)
45
+ if (density) params.set('density', density)
46
+ if (theme) params.set('theme', theme)
47
+ return `${base}/_storyboard/canvas/isolate-set?${params}`
48
+ }
49
+
34
50
  const params = new URLSearchParams()
35
- // Route via the real story page (works in dev AND prod). The dev-only
36
- // `_storyboard/canvas/isolate-set` middleware doesn't exist in deployed
37
- // builds, so we mount ComponentSetPage at the story's route with
38
- // `_sb_component_set` instead. `_sb_embed` keeps the canvas chrome off.
39
51
  params.set('_sb_embed', '')
40
52
  params.set('_sb_component_set', '')
41
53
  if (layout) params.set('layout', layout)
@@ -55,26 +55,24 @@ export default function TerminalReadWidget({ id, props }) {
55
55
  const canvasId = getCanvasId()
56
56
  if (!canvasId) { setFailed(true); return }
57
57
 
58
- const urls = isProduction()
59
- ? [
60
- // New flat format: <widgetId>.snapshot.json
61
- `${baseUrl}_storyboard/terminal-snapshots/${id}.snapshot.json`,
62
- // Legacy nested format: <canvasDir>/<widgetId>.json
63
- `${baseUrl}_storyboard/terminal-snapshots/${canvasId.replace(/\//g, '--')}/${id}.json`,
64
- ]
65
- : [
66
- `${baseUrl}_storyboard/canvas/terminal-snapshot/${id}`,
67
- `${baseUrl}_storyboard/terminal-snapshots/${id}.snapshot.json`,
68
- `${baseUrl}_storyboard/terminal-snapshots/${canvasId.replace(/\//g, '--')}/${id}.json`,
69
- ]
70
-
71
- for (const url of urls) {
58
+ const indexUrl = `${baseUrl}_storyboard/terminal-snapshots/agents.snapshot.json`
59
+ const legacyUrl = `${baseUrl}_storyboard/terminal-snapshots/${canvasId.replace(/\//g, '--')}/${id}.json`
60
+ const restUrl = `${baseUrl}_storyboard/canvas/terminal-snapshot/${id}`
61
+
62
+ const tryUrls = isProduction()
63
+ ? [{ url: indexUrl, fromIndex: true }, { url: legacyUrl, fromIndex: false }]
64
+ : [{ url: restUrl, fromIndex: false }, { url: indexUrl, fromIndex: true }, { url: legacyUrl, fromIndex: false }]
65
+
66
+ for (const { url, fromIndex } of tryUrls) {
72
67
  try {
73
68
  const res = await fetch(url)
74
69
  if (!res.ok) continue
75
70
  const data = await res.json()
76
71
  if (cancelled) return
77
- const text = data.paneContent || data.content || data.output || ''
72
+ const entry = fromIndex ? data?.agents?.[id] : data
73
+ if (!entry) continue
74
+ const text = entry.paneContent || entry.content || entry.output || ''
75
+ if (!text) continue
78
76
  setContent(text)
79
77
 
80
78
  const converter = await getConverter()