@dfosco/storyboard-core 2.2.0 → 2.4.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dfosco/storyboard-core",
3
- "version": "2.2.0",
3
+ "version": "2.4.0",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -28,6 +28,7 @@
28
28
  "./svelte-plugin-ui/styles/base.css": "./src/svelte-plugin-ui/styles/base.css"
29
29
  },
30
30
  "dependencies": {
31
+ "@primer/octicons": "^19.22.0",
31
32
  "alpinejs": "^3.15.8",
32
33
  "jsonc-parser": "^3.3.1",
33
34
  "tachyons": "^4.12.0"
package/src/index.js CHANGED
@@ -14,6 +14,8 @@ export { loadFlow, listFlows, flowExists, loadRecord, findRecord, loadObject, de
14
14
  export { resolveFlowName, resolveRecordName } from './loader.js'
15
15
  // Prototype metadata
16
16
  export { listPrototypes, getPrototypeMetadata } from './loader.js'
17
+ // Folder metadata
18
+ export { listFolders, getFolderMetadata } from './loader.js'
17
19
  // Deprecated scene aliases
18
20
  export { loadScene, listScenes, sceneExists } from './loader.js'
19
21
 
package/src/loader.js CHANGED
@@ -30,13 +30,13 @@ function deepMerge(target, source) {
30
30
  * Module-level data index, seeded by init().
31
31
  * Shape: { flows: {}, objects: {}, records: {} }
32
32
  */
33
- let dataIndex = { flows: {}, objects: {}, records: {}, prototypes: {} }
33
+ let dataIndex = { flows: {}, objects: {}, records: {}, prototypes: {}, folders: {} }
34
34
 
35
35
  /**
36
36
  * Seed the data index. Call once at app startup before any load functions.
37
37
  * The Vite data plugin calls this automatically via the generated virtual module.
38
38
  *
39
- * @param {{ flows?: object, scenes?: object, objects: object, records: object, prototypes?: object }} index
39
+ * @param {{ flows?: object, scenes?: object, objects: object, records: object, prototypes?: object, folders?: object }} index
40
40
  */
41
41
  export function init(index) {
42
42
  if (!index || typeof index !== 'object') {
@@ -47,6 +47,7 @@ export function init(index) {
47
47
  objects: index.objects || {},
48
48
  records: index.records || {},
49
49
  prototypes: index.prototypes || {},
50
+ folders: index.folders || {},
50
51
  }
51
52
  }
52
53
 
@@ -303,4 +304,21 @@ export function getPrototypeMetadata(name) {
303
304
  return dataIndex.prototypes[name] ?? null
304
305
  }
305
306
 
307
+ /**
308
+ * Returns the names of all registered folders.
309
+ * @returns {string[]}
310
+ */
311
+ export function listFolders() {
312
+ return Object.keys(dataIndex.folders)
313
+ }
314
+
315
+ /**
316
+ * Returns folder metadata by name.
317
+ * @param {string} name - Folder name (e.g. "Getting Started")
318
+ * @returns {object|null} Metadata from the .folder.json file, or null
319
+ */
320
+ export function getFolderMetadata(name) {
321
+ return dataIndex.folders[name] ?? null
322
+ }
323
+
306
324
  export { deepMerge }
@@ -1,4 +1,4 @@
1
- import { init, loadFlow, listFlows, flowExists, loadScene, listScenes, sceneExists, loadRecord, findRecord, loadObject, deepMerge, resolveFlowName, resolveRecordName } from './loader.js'
1
+ import { init, loadFlow, listFlows, flowExists, loadScene, listScenes, sceneExists, loadRecord, findRecord, loadObject, deepMerge, resolveFlowName, resolveRecordName, listFolders, getFolderMetadata } from './loader.js'
2
2
 
3
3
  const makeIndex = () => ({
4
4
  flows: {
@@ -416,3 +416,41 @@ describe('error hints for scoped data', () => {
416
416
  expect(() => loadRecord('xyz')).not.toThrow(/Did you mean/)
417
417
  })
418
418
  })
419
+
420
+ // ── Folder functions ──
421
+
422
+ describe('listFolders', () => {
423
+ it('returns empty array when no folders registered', () => {
424
+ init(makeIndex())
425
+ expect(listFolders()).toEqual([])
426
+ })
427
+
428
+ it('returns folder names when folders are registered', () => {
429
+ init({
430
+ ...makeIndex(),
431
+ folders: {
432
+ 'Getting Started': { meta: { title: 'Getting Started' } },
433
+ Advanced: { meta: { title: 'Advanced' } },
434
+ },
435
+ })
436
+ expect(listFolders()).toEqual(['Getting Started', 'Advanced'])
437
+ })
438
+ })
439
+
440
+ describe('getFolderMetadata', () => {
441
+ it('returns null when folder does not exist', () => {
442
+ init(makeIndex())
443
+ expect(getFolderMetadata('nonexistent')).toBeNull()
444
+ })
445
+
446
+ it('returns folder metadata when folder exists', () => {
447
+ init({
448
+ ...makeIndex(),
449
+ folders: {
450
+ 'My Folder': { meta: { title: 'My Folder', description: 'A folder' } },
451
+ },
452
+ })
453
+ const meta = getFolderMetadata('My Folder')
454
+ expect(meta).toEqual({ meta: { title: 'My Folder', description: 'A folder' } })
455
+ })
456
+ })
@@ -0,0 +1,75 @@
1
+ <!--
2
+ Octicon — renders a Primer Octicon by name.
3
+
4
+ Includes custom icon overrides (e.g. folder, folder-open) that replace
5
+ or extend the Primer set.
6
+
7
+ Usage:
8
+ <Octicon name="repo" />
9
+ <Octicon name="folder" color="#54aeff" />
10
+ <Octicon name="folder-open" size={24} />
11
+ <Octicon name="gear" size={16} label="Settings" />
12
+ <Octicon name="lock" offsetX={1} offsetY={-1} />
13
+ -->
14
+
15
+ <script lang="ts">
16
+ import octicons from '@primer/octicons'
17
+
18
+ // Custom SVG paths that override or extend the Primer icon set.
19
+ // Each entry: viewBox string + path d attribute.
20
+ const customIcons: Record<string, { viewBox: string; path: string }> = {
21
+ 'folder': {
22
+ viewBox: '0 0 24 24',
23
+ path: 'M4 20q-.825 0-1.412-.587T2 18V6q0-.825.588-1.412T4 4h5.175q.4 0 .763.15t.637.425L12 6h8q.825 0 1.413.588T22 8v10q0 .825-.587 1.413T20 20z',
24
+ },
25
+ 'folder-open': {
26
+ viewBox: '0 0 24 24',
27
+ path: 'M4 20q-.825 0-1.412-.587T2 18V6q0-.825.588-1.412T4 4h5.175q.4 0 .763.15t.637.425L12 6h9q.425 0 .713.288T22 7t-.288.713T21 8H7.85q-1.55 0-2.7.975T4 11.45V18l1.975-6.575q.2-.65.738-1.037T7.9 10h12.9q1.025 0 1.613.813t.312 1.762l-1.8 6q-.2.65-.737 1.038T19 20z',
28
+ },
29
+ }
30
+
31
+ interface Props {
32
+ name: string
33
+ size?: number
34
+ label?: string
35
+ color?: string
36
+ offsetX?: number
37
+ offsetY?: number
38
+ }
39
+
40
+ let { name, size = 16, label, color, offsetX = 0, offsetY = 0 }: Props = $props()
41
+
42
+ const ariaAttrs = $derived(
43
+ label ? `aria-label="${label}"` : 'aria-hidden="true"'
44
+ )
45
+
46
+ const custom = $derived(customIcons[name])
47
+
48
+ const svg = $derived(
49
+ custom
50
+ ? `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="${custom.viewBox}" fill="currentColor" ${ariaAttrs}><path d="${custom.path}"/></svg>`
51
+ : octicons[name]?.toSVG({
52
+ width: size,
53
+ height: size,
54
+ ...(label ? { 'aria-label': label } : { 'aria-hidden': 'true' }),
55
+ }) ?? ''
56
+ )
57
+
58
+ const style = $derived(
59
+ [
60
+ color ? `color: ${color}` : '',
61
+ color ? `fill: ${color}` : '',
62
+ (offsetX || offsetY) ? `translate: ${offsetX}px ${offsetY}px` : '',
63
+ ].filter(Boolean).join('; ') || undefined
64
+ )
65
+ </script>
66
+
67
+ <span class="octicon" {style}>{@html svg}</span>
68
+
69
+ <style>
70
+ .octicon {
71
+ display: inline-flex;
72
+ align-items: center;
73
+ vertical-align: middle;
74
+ }
75
+ </style>
@@ -10,6 +10,7 @@
10
10
 
11
11
  <script lang="ts">
12
12
  import { buildPrototypeIndex } from '../../viewfinder.js'
13
+ import Octicon from './Octicon.svelte'
13
14
 
14
15
  interface Props {
15
16
  title?: string
@@ -37,39 +38,54 @@
37
38
  : prototypeIndex.globalFlows
38
39
  )
39
40
 
40
- // Merge global flows into the prototype list as "Other flows"
41
- const allGroups = $derived(
42
- globalFlows.length > 0
43
- ? [
44
- ...prototypeIndex.prototypes,
45
- {
46
- name: 'Other flows',
47
- dirName: '__global__',
48
- description: null,
49
- author: null,
50
- gitAuthor: null,
51
- icon: null,
52
- team: null,
53
- tags: null,
54
- flows: globalFlows,
55
- },
56
- ]
57
- : prototypeIndex.prototypes
41
+ // Build a flat display list: folders (with nested prototypes), ungrouped prototypes, global flows
42
+ const ungroupedProtos = $derived(prototypeIndex.prototypes)
43
+
44
+ const folders = $derived(prototypeIndex.folders || [])
45
+
46
+ const otherFlows = $derived.by(() => {
47
+ if (globalFlows.length === 0) return null
48
+ return {
49
+ name: 'Other flows',
50
+ dirName: '__global__',
51
+ description: null,
52
+ author: null,
53
+ gitAuthor: null,
54
+ lastModified: null,
55
+ icon: null,
56
+ team: null,
57
+ tags: null,
58
+ flows: globalFlows,
59
+ }
60
+ })
61
+
62
+ const totalProtos = $derived(
63
+ ungroupedProtos.length + folders.reduce((sum: number, f: any) => sum + f.prototypes.length, 0)
58
64
  )
59
65
 
60
66
  const totalFlows = $derived(
61
- allGroups.reduce((sum: number, p: any) => sum + p.flows.length, 0)
67
+ ungroupedProtos.reduce((sum: number, p: any) => sum + p.flows.length, 0) +
68
+ globalFlows.length +
69
+ folders.reduce((sum: number, f: any) =>
70
+ sum + f.prototypes.reduce((s: number, p: any) => s + p.flows.length, 0), 0)
62
71
  )
63
72
 
64
- // Expanded state all prototypes start expanded
73
+ // Sortinguse pre-sorted arrays from buildPrototypeIndex
74
+ type SortMode = 'updated' | 'title'
75
+ let sortBy: SortMode = $state('updated')
76
+
77
+ const sortedProtos = $derived(prototypeIndex.sorted?.[sortBy]?.prototypes ?? ungroupedProtos)
78
+ const sortedFolders = $derived(prototypeIndex.sorted?.[sortBy]?.folders ?? folders)
79
+
80
+ // Expanded state — all prototypes and folders start expanded
65
81
  let expanded: Record<string, boolean> = $state({})
66
82
 
67
83
  function isExpanded(dirName: string): boolean {
68
84
  return expanded[dirName] ?? true
69
85
  }
70
86
 
71
- function togglePrototype(dirName: string) {
72
- expanded[dirName] = !expanded[dirName]
87
+ function toggle(dirName: string) {
88
+ expanded[dirName] = !isExpanded(dirName)
73
89
  }
74
90
 
75
91
  function protoRoute(dirName: string): string {
@@ -165,11 +181,32 @@
165
181
  <p class="subtitle">{subtitle}</p>
166
182
  {/if}
167
183
  </div>
184
+ </div>
185
+ <div class="controlsRow">
186
+ <!-- <span class="sceneCount">
187
+ {(folders.length > 0 ? `${folders.length} folder${folders.length !== 1 ? 's' : ''} · ` : '') + `${totalProtos} prototype${totalProtos !== 1 ? 's' : ''} · ${totalFlows} flow${totalFlows !== 1 ? 's' : ''}`}
188
+ </span> -->
189
+ <div class="sortToggle">
190
+ <button
191
+ class="sortButton"
192
+ class:sortButtonActive={sortBy === 'updated'}
193
+ onclick={() => sortBy = 'updated'}
194
+ >
195
+ <Octicon name="clock" size={14} offsetY={-1} />
196
+ Last updated
197
+ </button>
198
+ <button
199
+ class="sortButton"
200
+ class:sortButtonActive={sortBy === 'title'}
201
+ onclick={() => sortBy = 'title'}
202
+ >
203
+ <Octicon name="sort-asc" size={14} offsetY={-1} />
204
+ Title A–Z
205
+ </button>
206
+ </div>
168
207
  {#if branches && branches.length > 0}
169
208
  <div class="branchDropdown">
170
- <svg class="branchIcon" width="16" height="16" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
171
- <path d="M9.5 3.25a2.25 2.25 0 1 1 3 2.122V6A2.5 2.5 0 0 1 10 8.5H6a1 1 0 0 0-1 1v1.128a2.251 2.251 0 1 1-1.5 0V5.372a2.25 2.25 0 1 1 1.5 0v1.836A2.492 2.492 0 0 1 6 7h4a1 1 0 0 0 1-1v-.628A2.25 2.25 0 0 1 9.5 3.25Zm-6 0a.75.75 0 1 0 1.5 0 .75.75 0 0 0-1.5 0Zm8.25-.75a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5ZM4.25 12a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5Z" />
172
- </svg>
209
+ <span class="branchIcon"><Octicon size={16} color="var(--fgColor-muted)" offsetY={-1} offsetX={2} name="git-branch" /></span>
173
210
  <select
174
211
  class="branchSelect"
175
212
  onchange={handleBranchChange}
@@ -183,32 +220,61 @@
183
220
  </div>
184
221
  {/if}
185
222
  </div>
186
- <p class="sceneCount">
187
- {allGroups.length} prototype{allGroups.length !== 1 ? 's' : ''} · {totalFlows} flow{totalFlows !== 1 ? 's' : ''}
188
- </p>
189
223
  </header>
190
224
 
191
- {#if allGroups.length === 0}
225
+ {#if totalProtos === 0 && folders.length === 0}
192
226
  <p class="empty">No flows found. Add a <code>*.flow.json</code> file to get started.</p>
193
227
  {:else}
194
228
  <div class="list">
195
- {#each allGroups as proto (proto.dirName)}
229
+ {#snippet protoEntry(proto)}
196
230
  <section class="protoGroup">
197
- {#if proto.flows.length > 0}
231
+ {#if proto.hideFlows && proto.flows.length === 1}
232
+ <!-- Single flow, hidden — navigates directly to the flow -->
233
+ <a class="listItem" href={proto.flows[0].route}>
234
+ <div class="cardBody">
235
+ <p class="protoName" class:otherflows={proto.dirName === '__global__'}>
236
+ {#if proto.icon}<span class="protoIcon">{proto.icon}</span>{/if}
237
+ {proto.name}
238
+ </p>
239
+ {#if proto.description}
240
+ <p class="protoDesc">{proto.description}</p>
241
+ {/if}
242
+ {#if proto.author}
243
+ {@const authors = Array.isArray(proto.author) ? proto.author : [proto.author]}
244
+ <div class="author">
245
+ <span class="authorAvatars">
246
+ {#each authors as a (a)}
247
+ <img
248
+ src="https://github.com/{a}.png?size=48"
249
+ alt={a}
250
+ class="authorAvatar"
251
+ />
252
+ {/each}
253
+ </span>
254
+ <span class="authorName">{authors.join(', ')}</span>
255
+ </div>
256
+ {:else if proto.gitAuthor}
257
+ <p class="authorPlain">{proto.gitAuthor}</p>
258
+ {/if}
259
+ </div>
260
+ </a>
261
+ {:else if proto.flows.length > 0}
198
262
  <!-- Expandable prototype with flows -->
199
263
  <button
200
264
  class="listItem protoHeader"
201
- onclick={() => togglePrototype(proto.dirName)}
265
+ onclick={() => toggle(proto.dirName)}
202
266
  aria-expanded={isExpanded(proto.dirName)}
203
267
  >
204
268
  <div class="cardBody">
205
- <p class="sceneName">
269
+ <p class="protoName" class:otherflows={proto.dirName === '__global__'}>
206
270
  {#if proto.icon}<span class="protoIcon">{proto.icon}</span>{/if}
207
271
  {proto.name}
208
- <span class="protoChevron" class:protoChevronOpen={isExpanded(proto.dirName)}>
209
- <svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
210
- <path d="M6.22 3.22a.75.75 0 0 1 1.06 0l4.25 4.25a.75.75 0 0 1 0 1.06l-4.25 4.25a.751.751 0 0 1-1.042-.018.751.751 0 0 1-.018-1.042L9.94 8 6.22 4.28a.75.75 0 0 1 0-1.06Z" />
211
- </svg>
272
+ <span class="protoChevron">
273
+ {#if isExpanded(proto.dirName)}
274
+ <Octicon size={12} color="var(--fgColor-disabled)" name="chevron-down" offsetY={-3} offsetX={2} />
275
+ {:else}
276
+ <Octicon size={12} color="var(--fgColor-disabled)" name="chevron-right" offsetY={-3} offsetX={2} />
277
+ {/if}
212
278
  </span>
213
279
  </p>
214
280
  {#if proto.description}
@@ -237,7 +303,7 @@
237
303
  <!-- Prototype with no flows — navigates directly -->
238
304
  <a class="listItem" href={protoRoute(proto.dirName)}>
239
305
  <div class="cardBody">
240
- <p class="sceneName">
306
+ <p class="protoName" class:otherflows={proto.dirName === '__global__'}>
241
307
  {#if proto.icon}<span class="protoIcon">{proto.icon}</span>{/if}
242
308
  {proto.name}
243
309
  </p>
@@ -265,7 +331,7 @@
265
331
  </a>
266
332
  {/if}
267
333
 
268
- {#if isExpanded(proto.dirName) && proto.flows.length > 0}
334
+ {#if !(proto.hideFlows && proto.flows.length === 1) && isExpanded(proto.dirName) && proto.flows.length > 0}
269
335
  <div class="flowList">
270
336
  {#each proto.flows as flow (flow.key)}
271
337
  <a href={flow.route} class="listItem flowItem">
@@ -275,7 +341,7 @@
275
341
  </div>
276
342
  {/if}
277
343
  <div class="cardBody">
278
- <p class="sceneName">{flow.meta?.title || formatName(flow.name)}</p>
344
+ <p class="protoName">{flow.meta?.title || formatName(flow.name)}</p>
279
345
  {#if flow.meta?.description}
280
346
  <p class="flowDesc">{flow.meta.description}</p>
281
347
  {/if}
@@ -285,7 +351,49 @@
285
351
  </div>
286
352
  {/if}
287
353
  </section>
354
+ {/snippet}
355
+
356
+ <!-- Folders with their prototypes -->
357
+ {#each sortedFolders as folder (folder.dirName)}
358
+ <section class="folderGroup" class:folderGroupOpen={isExpanded(`folder:${folder.dirName}`)}>
359
+ <button
360
+ class="folderHeader"
361
+ onclick={() => toggle(`folder:${folder.dirName}`)}
362
+ aria-expanded={isExpanded(`folder:${folder.dirName}`)}
363
+ >
364
+ <p class="folderName">
365
+ <span>
366
+ {#if isExpanded(`folder:${folder.dirName}`)}
367
+ <Octicon size={20} offsetY={-1.5} name="folder-open" color="#54aeff" />
368
+ {:else}
369
+ <Octicon size={20} offsetY={-1.5} name="folder" color="#54aeff" />
370
+ {/if}
371
+ </span>
372
+ {folder.name}
373
+ </p>
374
+ {#if folder.description}
375
+ <p class="folderDesc">{folder.description}</p>
376
+ {/if}
377
+ </button>
378
+ {#if isExpanded(`folder:${folder.dirName}`) && folder.prototypes.length > 0}
379
+ <div class="folderContent">
380
+ {#each folder.prototypes as proto (proto.dirName)}
381
+ {@render protoEntry(proto)}
382
+ {/each}
383
+ </div>
384
+ {/if}
385
+ </section>
386
+ {/each}
387
+
388
+ <!-- Ungrouped prototypes (not in any folder) -->
389
+ {#each sortedProtos as proto (proto.dirName)}
390
+ {@render protoEntry(proto)}
288
391
  {/each}
392
+
393
+ <!-- Other flows (always at the bottom) -->
394
+ {#if otherFlows}
395
+ {@render protoEntry(otherFlows)}
396
+ {/if}
289
397
  </div>
290
398
  {/if}
291
399
  </div>
@@ -300,19 +408,17 @@
300
408
 
301
409
  .header {
302
410
  max-width: 720px;
303
- margin: 0 auto 64px;
411
+ margin: 0 auto 40px;
304
412
  }
305
413
 
306
414
  .headerTop {
307
415
  display: flex;
308
416
  align-items: baseline;
309
- justify-content: space-between;
310
417
  gap: 16px;
311
418
  }
312
419
 
313
420
  .title {
314
- font-size: 72px;
315
- font-weight: 400;
421
+ font: var(--text-display-shorthand);
316
422
  margin: 0 0 12px;
317
423
  color: var(--fgColor-default, #e6edf3);
318
424
  letter-spacing: -0.03em;
@@ -326,11 +432,64 @@
326
432
  letter-spacing: 0.01em;
327
433
  }
328
434
 
329
- .sceneCount {
435
+ .controlsRow {
436
+ display: flex;
437
+ align-items: center;
438
+ gap: 8px;
439
+ margin: 16px 0 0;
440
+ }
441
+
442
+ /* .sceneCount {
330
443
  font-size: 13px;
331
444
  color: var(--fgColor-muted, #848d97);
332
- margin: 16px 0 0;
333
445
  letter-spacing: 0.01em;
446
+ white-space: nowrap;
447
+ } */
448
+
449
+ .sortToggle {
450
+ display: flex;
451
+ gap: 2px;
452
+ background: var(--bgColor-inset);
453
+ padding: var(--base-size-4) var(--base-size-6);
454
+ border-radius: 9999px;
455
+ }
456
+
457
+ .sortButton {
458
+ display: inline-flex;
459
+ align-items: center;
460
+ border-radius: 9999px;
461
+ gap: 4px;
462
+ padding: 6px 10px;
463
+ font-size: 12px;
464
+ font-family: inherit;
465
+ color: var(--fgColor-muted, #848d97);
466
+ background: transparent;
467
+ border: none;
468
+ cursor: pointer;
469
+ transition: color 0.15s, background 0.15s, border-color 0.15s;
470
+
471
+ &:first-child {
472
+ transform: translateX(-1px);
473
+ }
474
+
475
+ &:last-child {
476
+ transform: translateX(1px);
477
+ }
478
+ }
479
+
480
+ .sortButton:hover {
481
+ color: var(--fgColor-default, #e6edf3);
482
+ background: var(--bgColor-neutral-muted, rgba(110, 118, 129, 0.1));
483
+ }
484
+
485
+ .sortButtonActive {
486
+ color: var(--fgColor-default, #e6edf3);
487
+ background: var(--bgColor-neutral-muted, rgba(110, 118, 129, 0.15));
488
+ border: none;
489
+ }
490
+
491
+ .sortButton:first-child {
492
+ transform: translateX(-1px);
334
493
  }
335
494
 
336
495
  .branchDropdown {
@@ -339,6 +498,7 @@
339
498
  gap: 0;
340
499
  flex-shrink: 0;
341
500
  position: relative;
501
+ margin-left: auto;
342
502
  }
343
503
 
344
504
  .branchIcon {
@@ -370,7 +530,7 @@
370
530
  }
371
531
 
372
532
  .branchSelect:hover {
373
- border-color: var(--fgColor-muted, #848d97);
533
+ border-color: #bbbbbb;
374
534
  }
375
535
 
376
536
  .branchSelect:focus-visible {
@@ -381,6 +541,7 @@
381
541
  .list {
382
542
  display: flex;
383
543
  flex-direction: column;
544
+ gap: var(--base-size-8);
384
545
  max-width: 720px;
385
546
  margin: 0 auto;
386
547
  }
@@ -388,11 +549,79 @@
388
549
  .protoGroup {
389
550
  display: flex;
390
551
  flex-direction: column;
552
+ gap: var(--base-size-8);
553
+ }
554
+
555
+ .protoGroup > .listItem {
556
+ border: 1px solid var(--borderColor-muted, #30363d);
557
+ border-radius: var(--base-size-6);
558
+ }
559
+
560
+ .folderGroup {
561
+ display: flex;
562
+ flex-direction: column;
563
+ gap: var(--base-size-8);
564
+ }
565
+
566
+ .folderHeader {
567
+ display: flex;
568
+ flex-direction: row;
569
+ align-items: baseline;
570
+ justify-content: flex-start;
571
+ gap: var(--base-size-8);
572
+ appearance: none;
573
+ border: none;
574
+ border-radius: var(--base-size-6);
575
+ border: 1px solid var(--borderColor-muted, #30363d);
576
+ background: none;
577
+ width: 100%;
578
+ text-align: left;
579
+ cursor: pointer;
580
+ color: inherit;
581
+ padding: var(--base-size-16);
582
+
583
+ &:hover,
584
+ .folderGroupOpen & {
585
+ background-color: var(--bgColor-muted, #161b22);
586
+ }
587
+ }
588
+
589
+
590
+ .folderGroupOpen .folderHeader {
591
+ background-color: var(--bgColor-muted, #161b22);
592
+ }
593
+
594
+
595
+ .folderName {
596
+ display: inline-flex;
597
+ align-items: center;
598
+ gap: var(--base-size-8);
599
+ font-size: var(--text-body-size-small);
600
+ font-weight: 600;
601
+ color: var(--fgColor-default);
602
+ margin: 0;
603
+ letter-spacing: 0.04em;
604
+ text-transform: uppercase;
605
+ line-height: 1.6;
606
+ }
607
+
608
+ .folderDesc {
609
+ font-size: var(--text-body-size-small);
610
+ color: var(--fgColor-muted, #848d97);
611
+ margin: 0;
612
+ letter-spacing: 0.01em;
613
+ text-transform: none;
614
+ font-weight: 400;
615
+ }
616
+
617
+ .folderContent {
618
+ display: flex;
619
+ flex-direction: column;
620
+ gap: var(--base-size-8);
391
621
  }
392
622
 
393
623
  .listItem {
394
624
  display: block;
395
- padding: 8px 0;
396
625
  text-decoration: none;
397
626
  color: inherit;
398
627
  }
@@ -405,11 +634,17 @@
405
634
  appearance: none;
406
635
  border: none;
407
636
  background: none;
637
+ border-radius: var(--base-size-6);
408
638
  width: 100%;
409
639
  text-align: left;
410
640
  cursor: pointer;
411
641
  color: inherit;
412
- padding: 8px 0;
642
+ padding: 0;
643
+ }
644
+
645
+ .protoHeader[aria-expanded="true"] .cardBody {
646
+ background-color: var(--bgColor-muted);
647
+ border-radius: var(--base-size-6);
413
648
  }
414
649
 
415
650
  .cardBody {
@@ -417,11 +652,11 @@
417
652
  }
418
653
 
419
654
  .cardBody:hover {
420
- background-color: var(--bgColor-muted, #161b22);
421
- border-radius: 8px;
655
+ background-color: var(--bgColor-muted);
656
+ border-radius: var(--base-size-6);
422
657
  }
423
658
 
424
- .sceneName {
659
+ .protoName {
425
660
  font-size: var(--text-title-size-medium);
426
661
  font-weight: 400;
427
662
  color: var(--fgColor-default, #e6edf3);
@@ -430,23 +665,17 @@
430
665
  line-height: 1.6;
431
666
  transition: font-style 0.15s ease;
432
667
  }
668
+
669
+ .protoName.otherflows {
670
+ font-size: var(--text-body-size-small);
671
+ font-weight: 600;
672
+ text-transform: uppercase;
673
+ direction: rtl;
433
674
 
434
- .protoChevron {
435
- display: inline-flex;
436
- align-items: center;
437
- color: var(--fgColor-muted, #848d97);
438
- transition: transform 0.15s ease;
439
- transform: rotate(0deg);
440
- margin-right: 4px;
441
- vertical-align: middle;
442
- }
443
-
444
- .protoChevronOpen {
445
- transform: rotate(90deg);
446
- }
675
+ & .protoChevron {
676
+ margin-right: var(--base-size-8);
677
+ }
447
678
 
448
- .protoIcon {
449
- margin-right: 4px;
450
679
  }
451
680
 
452
681
  .protoDesc {
@@ -501,14 +730,14 @@
501
730
  }
502
731
 
503
732
  .flowList {
504
- margin: 0 var(--base-size-12);
733
+ margin: 0;
505
734
  padding: 0;
506
735
  display: flex;
507
736
  flex-direction: column;
508
737
  }
509
738
 
510
739
  .flowItem {
511
- border: 1px solid var(--borderColor-muted, #30363d);
740
+ border: 1px solid var(--borderColor-muted);
512
741
  padding: 0;
513
742
  }
514
743
 
@@ -530,7 +759,7 @@
530
759
  border-radius: var(--base-size-6);
531
760
  }
532
761
 
533
- .flowItem .sceneName {
762
+ .flowItem .protoName {
534
763
  font-size: var(--text-title-size-small);
535
764
  color: var(--fgColor-muted);
536
765
  }
package/src/viewfinder.js CHANGED
@@ -1,4 +1,4 @@
1
- import { loadFlow, listFlows, listPrototypes, getPrototypeMetadata } from './loader.js'
1
+ import { loadFlow, listFlows, listPrototypes, getPrototypeMetadata, listFolders, getFolderMetadata } from './loader.js'
2
2
 
3
3
  /**
4
4
  * Deterministic hash from a string — used for seeding generative placeholders.
@@ -71,14 +71,16 @@ export function getFlowMeta(flowName) {
71
71
  export const getSceneMeta = getFlowMeta
72
72
 
73
73
  /**
74
- * Build a structured prototype index grouping flows by prototype.
74
+ * Build a structured prototype index grouping flows by prototype,
75
+ * and prototypes by folder.
75
76
  *
76
77
  * Returns an object with:
77
- * - prototypes: array of prototype entries with metadata and their flows
78
+ * - folders: array of folder entries containing their prototypes
79
+ * - prototypes: array of ungrouped prototype entries (not in any folder)
78
80
  * - globalFlows: flows not belonging to any prototype
79
81
  *
80
82
  * @param {string[]} [knownRoutes] - Array of known route names
81
- * @returns {{ prototypes: Array, globalFlows: Array }}
83
+ * @returns {{ folders: Array, prototypes: Array, globalFlows: Array }}
82
84
  */
83
85
  export function buildPrototypeIndex(knownRoutes = []) {
84
86
  const flows = listFlows()
@@ -95,9 +97,12 @@ export function buildPrototypeIndex(knownRoutes = []) {
95
97
  description: meta.description || null,
96
98
  author: meta.author || null,
97
99
  gitAuthor: raw?.gitAuthor || null,
100
+ lastModified: raw?.lastModified || null,
98
101
  icon: meta.icon || null,
99
102
  team: meta.team || null,
100
103
  tags: meta.tags || null,
104
+ hideFlows: meta.hideFlows ?? raw?.hideFlows ?? false,
105
+ folder: raw?.folder || null,
101
106
  flows: [],
102
107
  }
103
108
  }
@@ -115,9 +120,12 @@ export function buildPrototypeIndex(knownRoutes = []) {
115
120
  description: null,
116
121
  author: null,
117
122
  gitAuthor: null,
123
+ lastModified: null,
118
124
  icon: null,
119
125
  team: null,
120
126
  tags: null,
127
+ hideFlows: false,
128
+ folder: null,
121
129
  flows: [],
122
130
  }
123
131
  }
@@ -138,8 +146,72 @@ export function buildPrototypeIndex(knownRoutes = []) {
138
146
  }
139
147
  }
140
148
 
149
+ // Build folder entries from .folder.json metadata
150
+ const folderMap = {}
151
+ for (const folderName of listFolders()) {
152
+ const raw = getFolderMetadata(folderName)
153
+ const meta = raw?.meta || raw || {}
154
+ folderMap[folderName] = {
155
+ name: meta.title || folderName,
156
+ dirName: folderName,
157
+ description: meta.description || null,
158
+ icon: meta.icon || null,
159
+ prototypes: [],
160
+ }
161
+ }
162
+
163
+ // Partition prototypes into folders vs ungrouped
164
+ const ungrouped = []
165
+ for (const proto of Object.values(protoMap)) {
166
+ if (proto.folder && folderMap[proto.folder]) {
167
+ folderMap[proto.folder].prototypes.push(proto)
168
+ } else if (proto.folder) {
169
+ // Folder referenced but no .folder.json — create an implicit folder
170
+ folderMap[proto.folder] = {
171
+ name: proto.folder,
172
+ dirName: proto.folder,
173
+ description: null,
174
+ icon: null,
175
+ prototypes: [proto],
176
+ }
177
+ } else {
178
+ ungrouped.push(proto)
179
+ }
180
+ }
181
+
182
+ const folders = Object.values(folderMap)
183
+ const prototypes = ungrouped
184
+
185
+ // Pre-sort by title (A-Z)
186
+ const sortByTitle = (a, b) => (a.name || '').localeCompare(b.name || '')
187
+
188
+ // Pre-sort by last updated (newest first, nulls last)
189
+ const sortByUpdated = (a, b) => {
190
+ const aTime = a.lastModified ? new Date(a.lastModified).getTime() : 0
191
+ const bTime = b.lastModified ? new Date(b.lastModified).getTime() : 0
192
+ return bTime - aTime
193
+ }
194
+
195
+ // Sort folder contents by their most recently updated prototype
196
+ const folderByUpdated = (a, b) => {
197
+ const aMax = Math.max(0, ...a.prototypes.map(p => p.lastModified ? new Date(p.lastModified).getTime() : 0))
198
+ const bMax = Math.max(0, ...b.prototypes.map(p => p.lastModified ? new Date(p.lastModified).getTime() : 0))
199
+ return bMax - aMax
200
+ }
201
+
141
202
  return {
142
- prototypes: Object.values(protoMap),
203
+ folders,
204
+ prototypes,
143
205
  globalFlows,
206
+ sorted: {
207
+ title: {
208
+ prototypes: [...prototypes].sort(sortByTitle),
209
+ folders: [...folders].map(f => ({ ...f, prototypes: [...f.prototypes].sort(sortByTitle) })).sort(sortByTitle),
210
+ },
211
+ updated: {
212
+ prototypes: [...prototypes].sort(sortByUpdated),
213
+ folders: [...folders].map(f => ({ ...f, prototypes: [...f.prototypes].sort(sortByUpdated) })).sort(folderByUpdated),
214
+ },
215
+ },
144
216
  }
145
217
  }
@@ -1,5 +1,5 @@
1
1
  import { init } from './loader.js'
2
- import { hash, resolveFlowRoute, getFlowMeta, resolveSceneRoute, getSceneMeta } from './viewfinder.js'
2
+ import { hash, resolveFlowRoute, getFlowMeta, resolveSceneRoute, getSceneMeta, buildPrototypeIndex } from './viewfinder.js'
3
3
 
4
4
  const makeIndex = () => ({
5
5
  flows: {
@@ -150,3 +150,168 @@ describe('getSceneMeta (deprecated alias)', () => {
150
150
  expect(getSceneMeta('meta-author')).toEqual({ author: 'dfosco' })
151
151
  })
152
152
  })
153
+
154
+ // ── buildPrototypeIndex ──
155
+
156
+ describe('buildPrototypeIndex', () => {
157
+ it('passes hideFlows from prototype metadata', () => {
158
+ init({
159
+ flows: { 'MyProto/only-flow': { meta: { title: 'Only Flow' } } },
160
+ objects: {},
161
+ records: {},
162
+ prototypes: {
163
+ MyProto: { meta: { title: 'My Proto', hideFlows: true } },
164
+ },
165
+ })
166
+ const { prototypes } = buildPrototypeIndex([])
167
+ const proto = prototypes.find(p => p.dirName === 'MyProto')
168
+ expect(proto.hideFlows).toBe(true)
169
+ expect(proto.flows).toHaveLength(1)
170
+ })
171
+
172
+ it('defaults hideFlows to false when not set', () => {
173
+ init({
174
+ flows: { 'Other/flow-a': { meta: { title: 'A' } } },
175
+ objects: {},
176
+ records: {},
177
+ prototypes: {
178
+ Other: { meta: { title: 'Other Proto' } },
179
+ },
180
+ })
181
+ const { prototypes } = buildPrototypeIndex([])
182
+ const proto = prototypes.find(p => p.dirName === 'Other')
183
+ expect(proto.hideFlows).toBe(false)
184
+ })
185
+
186
+ it('reads hideFlows from top-level prototype metadata (outside meta key)', () => {
187
+ init({
188
+ flows: { 'TopLevel/only-flow': { meta: { title: 'Only Flow' } } },
189
+ objects: {},
190
+ records: {},
191
+ prototypes: {
192
+ TopLevel: { meta: { title: 'Top Level' }, hideFlows: true },
193
+ },
194
+ })
195
+ const { prototypes } = buildPrototypeIndex([])
196
+ const proto = prototypes.find(p => p.dirName === 'TopLevel')
197
+ expect(proto.hideFlows).toBe(true)
198
+ })
199
+
200
+ it('groups prototypes into folders when folder field is set', () => {
201
+ init({
202
+ flows: {
203
+ 'Example/basic': { meta: { title: 'Basic' } },
204
+ 'Signup/default': { meta: { title: 'Default' } },
205
+ },
206
+ objects: {},
207
+ records: {},
208
+ prototypes: {
209
+ Example: { meta: { title: 'Examples' }, folder: 'Getting Started' },
210
+ Signup: { meta: { title: 'Sign Up' }, folder: 'Getting Started' },
211
+ },
212
+ folders: {
213
+ 'Getting Started': { meta: { title: 'Getting Started', description: 'Intro prototypes', icon: '📚' } },
214
+ },
215
+ })
216
+ const result = buildPrototypeIndex([])
217
+ expect(result.folders).toHaveLength(1)
218
+ expect(result.prototypes).toHaveLength(0)
219
+
220
+ const folder = result.folders[0]
221
+ expect(folder.name).toBe('Getting Started')
222
+ expect(folder.description).toBe('Intro prototypes')
223
+ expect(folder.icon).toBe('📚')
224
+ expect(folder.prototypes).toHaveLength(2)
225
+ expect(folder.prototypes.map(p => p.dirName)).toContain('Example')
226
+ expect(folder.prototypes.map(p => p.dirName)).toContain('Signup')
227
+ })
228
+
229
+ it('keeps prototypes without a folder as ungrouped', () => {
230
+ init({
231
+ flows: {
232
+ 'Grouped/flow-a': {},
233
+ 'Standalone/flow-b': {},
234
+ },
235
+ objects: {},
236
+ records: {},
237
+ prototypes: {
238
+ Grouped: { meta: { title: 'Grouped' }, folder: 'MyFolder' },
239
+ Standalone: { meta: { title: 'Standalone' } },
240
+ },
241
+ folders: {
242
+ MyFolder: { meta: { title: 'My Folder' } },
243
+ },
244
+ })
245
+ const result = buildPrototypeIndex([])
246
+ expect(result.folders).toHaveLength(1)
247
+ expect(result.prototypes).toHaveLength(1)
248
+ expect(result.prototypes[0].dirName).toBe('Standalone')
249
+ expect(result.folders[0].prototypes).toHaveLength(1)
250
+ expect(result.folders[0].prototypes[0].dirName).toBe('Grouped')
251
+ })
252
+
253
+ it('creates implicit folder when prototype references a folder with no metadata', () => {
254
+ init({
255
+ flows: { 'Proto/flow': {} },
256
+ objects: {},
257
+ records: {},
258
+ prototypes: {
259
+ Proto: { meta: { title: 'Proto' }, folder: 'Implicit' },
260
+ },
261
+ })
262
+ const result = buildPrototypeIndex([])
263
+ expect(result.folders).toHaveLength(1)
264
+ expect(result.folders[0].name).toBe('Implicit')
265
+ expect(result.folders[0].prototypes).toHaveLength(1)
266
+ })
267
+
268
+ it('uses folder directory name as display name when no title in metadata', () => {
269
+ init({
270
+ flows: {},
271
+ objects: {},
272
+ records: {},
273
+ prototypes: {},
274
+ folders: {
275
+ 'My Folder': {},
276
+ },
277
+ })
278
+ const result = buildPrototypeIndex([])
279
+ expect(result.folders).toHaveLength(1)
280
+ expect(result.folders[0].name).toBe('My Folder')
281
+ })
282
+
283
+ it('returns empty folders array when no folders exist', () => {
284
+ init({
285
+ flows: { 'A/flow': {} },
286
+ objects: {},
287
+ records: {},
288
+ prototypes: { A: { meta: { title: 'A' } } },
289
+ })
290
+ const result = buildPrototypeIndex([])
291
+ expect(result.folders).toHaveLength(0)
292
+ expect(result.prototypes).toHaveLength(1)
293
+ })
294
+
295
+ it('passes through lastModified from prototype metadata', () => {
296
+ const ts = '2025-01-15T10:30:00-05:00'
297
+ init({
298
+ flows: { 'App/home': {} },
299
+ objects: {},
300
+ records: {},
301
+ prototypes: { App: { meta: { title: 'My App' }, lastModified: ts } },
302
+ })
303
+ const result = buildPrototypeIndex([])
304
+ expect(result.prototypes[0].lastModified).toBe(ts)
305
+ })
306
+
307
+ it('defaults lastModified to null when not provided', () => {
308
+ init({
309
+ flows: { 'App/home': {} },
310
+ objects: {},
311
+ records: {},
312
+ prototypes: { App: { meta: { title: 'My App' } } },
313
+ })
314
+ const result = buildPrototypeIndex([])
315
+ expect(result.prototypes[0].lastModified).toBeNull()
316
+ })
317
+ })