@dfosco/storyboard 0.12.0 → 0.12.2

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.
Files changed (32) hide show
  1. package/dist/storyboard-ui.js.map +1 -1
  2. package/package.json +1 -1
  3. package/scaffold/eslint.config.js +45 -0
  4. package/scaffold/scaffold.config.json +25 -0
  5. package/scaffold/scripts/aggregate-artifacts.mjs +23 -1
  6. package/scaffold/src/library/README.md +48 -3
  7. package/scaffold/src/library/home.jsx +24 -0
  8. package/scaffold/src/library/index.jsx +37 -10
  9. package/scaffold/src/library/routes.jsx +22 -7
  10. package/scaffold/src/library/workspace.jsx +12 -2
  11. package/scaffold/tsconfig.json +25 -0
  12. package/scaffold/vite.config.js +153 -0
  13. package/scaffold/vitest.config.js +17 -0
  14. package/scripts/aggregate-artifacts.mjs +23 -1
  15. package/src/core/cli/updateVersion.js +18 -1
  16. package/src/core/data/artifactIndex.js +54 -8
  17. package/src/core/data/artifactIndex.test.js +52 -0
  18. package/src/core/data/viewfinder.js +3 -2
  19. package/src/core/data/viewfinder.test.js +39 -0
  20. package/src/core/scaffold/scaffold-integration.test.js +24 -0
  21. package/src/core/stores/configSchema.js +98 -0
  22. package/src/core/stores/configSchema.test.js +75 -1
  23. package/src/internals/SimpleWorkspace/SimpleWorkspace.jsx +116 -20
  24. package/src/internals/SimpleWorkspace/SimpleWorkspace.module.css +54 -0
  25. package/src/internals/Viewfinder.jsx +92 -22
  26. package/src/internals/Viewfinder.module.css +14 -1
  27. package/src/internals/canvas/isolation/prototypeRoutes.filter.test.js +47 -0
  28. package/src/internals/canvas/isolation/prototypeRoutes.jsx +16 -18
  29. package/src/internals/canvas/isolation/routeFilter.js +31 -0
  30. package/src/internals/canvas/widgets/PrototypeEmbed.jsx +1 -1
  31. package/src/internals/vite/data-plugin.js +4 -0
  32. package/src/internals/vite/data-plugin.test.js +31 -1
@@ -1,5 +1,50 @@
1
+ import { parseCanvasId } from '../canvas/identity.js'
2
+
1
3
  const ARTIFACT_INDEX_SCHEMA_VERSION = 1
2
4
 
5
+ /**
6
+ * Folder-independent identity for a canvas, mirroring how prototypes are
7
+ * matched across branches by their (folder-independent) `dirName`.
8
+ *
9
+ * The full canvas id is path-based (e.g. `widget/design-system`), so the same
10
+ * canvas living at the root on one branch and inside a folder on another would
11
+ * otherwise key differently and render as duplicate cross-branch cards. We key
12
+ * by the canvas name instead — proto-scoped canvases keep their prototype
13
+ * segment so two prototypes with same-named canvases never collide.
14
+ */
15
+ export function canvasMatchKey(id) {
16
+ const { namespace, segments, name } = parseCanvasId(String(id || ''))
17
+ if (namespace === 'prototype') {
18
+ const scope = segments.length >= 2 ? segments[segments.length - 2] : ''
19
+ return scope ? `proto:${scope}/${name}` : `proto:${name}`
20
+ }
21
+ return name
22
+ }
23
+
24
+ /**
25
+ * Collapse the remote canvas map (keyed by full path id) into a map keyed by
26
+ * folder-independent match key, merging the branch lists of any entries that
27
+ * resolve to the same canvas. This makes matching resilient to canvases that
28
+ * moved between folders (or root ↔ folder) across branches, and keeps the
29
+ * behavior correct for both freshly-aggregated and legacy indexes.
30
+ */
31
+ function collapseRemoteCanvases(remoteCanvases) {
32
+ const byKey = new Map()
33
+ for (const [id, entry] of Object.entries(remoteCanvases || {})) {
34
+ const key = canvasMatchKey(id)
35
+ const existing = byKey.get(key)
36
+ if (existing) {
37
+ existing.entry = {
38
+ ...existing.entry,
39
+ branches: [...(existing.entry.branches || []), ...(entry?.branches || [])],
40
+ }
41
+ } else {
42
+ byKey.set(key, { id, entry: { ...entry, branches: [...(entry?.branches || [])] } })
43
+ }
44
+ }
45
+ return byKey
46
+ }
47
+
3
48
  function stripBranchSegments(basePath) {
4
49
  let path = typeof basePath === 'string' && basePath.trim() ? basePath : '/'
5
50
  path = path.replace(/[?#].*$/, '')
@@ -188,9 +233,9 @@ export function mergeArtifactIndexIntoPrototypeIndex(localIndex, remoteIndex, cu
188
233
 
189
234
  const baseIndex = localIndex || {}
190
235
  const remotePrototypes = remoteIndex.prototypes || {}
191
- const remoteCanvases = remoteIndex.canvases || {}
236
+ const remoteCanvasesByKey = collapseRemoteCanvases(remoteIndex.canvases)
192
237
  const localPrototypeIds = new Set()
193
- const localCanvasIds = new Set()
238
+ const localCanvasKeys = new Set()
194
239
 
195
240
  const enrichPrototype = (proto) => {
196
241
  const id = proto?.dirName
@@ -202,10 +247,11 @@ export function mergeArtifactIndexIntoPrototypeIndex(localIndex, remoteIndex, cu
202
247
 
203
248
  const enrichCanvas = (canvas) => {
204
249
  const id = canvas?.dirName
205
- if (id) localCanvasIds.add(id)
206
- const remoteEntry = id ? remoteCanvases[id] : null
207
- if (!remoteEntry) return { ...canvas }
208
- return { ...canvas, branches: branchesForLocalCard(remoteEntry, currentBranch) }
250
+ const key = id ? canvasMatchKey(id) : null
251
+ if (key) localCanvasKeys.add(key)
252
+ const remote = key ? remoteCanvasesByKey.get(key) : null
253
+ if (!remote) return { ...canvas }
254
+ return { ...canvas, branches: branchesForLocalCard(remote.entry, currentBranch) }
209
255
  }
210
256
 
211
257
  const folders = (baseIndex.folders || []).map(folder => ({
@@ -220,8 +266,8 @@ export function mergeArtifactIndexIntoPrototypeIndex(localIndex, remoteIndex, cu
220
266
  if (!localPrototypeIds.has(id)) prototypes.push(createBranchOnlyPrototype(id, remoteEntry))
221
267
  }
222
268
 
223
- for (const [id, remoteEntry] of Object.entries(remoteCanvases)) {
224
- if (!localCanvasIds.has(id)) canvases.push(createBranchOnlyCanvas(id, remoteEntry))
269
+ for (const [key, { id, entry }] of remoteCanvasesByKey) {
270
+ if (!localCanvasKeys.has(key)) canvases.push(createBranchOnlyCanvas(id, entry))
225
271
  }
226
272
 
227
273
  return {
@@ -134,6 +134,58 @@ describe('mergeArtifactIndexIntoPrototypeIndex', () => {
134
134
  expect(merged.folders[0].canvases[0].branches.map(branch => branch.branch)).toEqual(['topic'])
135
135
  })
136
136
 
137
+ it('matches canvases across branches by folder-independent name (no duplicate when a canvas moved folders)', () => {
138
+ // Local canvas lives at the root ("design-system"); the remote index has the
139
+ // same canvas keyed under a folder path ("widget/design-system") because it
140
+ // sits inside a folder on other branches. They are the same canvas and must
141
+ // collapse into a single card — never a duplicate branch-only entry.
142
+ const localIndex = makeLocalIndex()
143
+ localIndex.canvases = [
144
+ { name: 'Design System', dirName: 'design-system', folder: null, route: '/canvas/design-system', isCanvas: true },
145
+ ]
146
+ const remoteIndex = makeRemoteIndex()
147
+ remoteIndex.canvases['widget/design-system'] = {
148
+ id: 'widget/design-system',
149
+ branches: [
150
+ { branch: 'main', folder: '', name: 'Design System', route: '/canvas/design-system', folderName: null },
151
+ { branch: 'topic', folder: 'branch--topic/', name: 'Design System', route: '/canvas/widget/design-system', folderName: 'widget' },
152
+ ],
153
+ }
154
+
155
+ const merged = mergeArtifactIndexIntoPrototypeIndex(localIndex, remoteIndex, 'main')
156
+
157
+ const matches = merged.canvases.filter(canvas => canvas.name === 'Design System')
158
+ expect(matches).toHaveLength(1)
159
+ expect(matches[0].dirName).toBe('design-system')
160
+ expect(matches[0].isBranchOnly).toBeFalsy()
161
+ expect(matches[0].branches.map(branch => branch.branch)).toEqual(['topic'])
162
+ })
163
+
164
+ it('merges branches for the same canvas split across folder-path keys in a legacy index', () => {
165
+ // A legacy (pre-collapse) index can hold the same canvas under two keys —
166
+ // one at the root, one under a folder. The merge must fold both branch
167
+ // lists into a single local card.
168
+ const localIndex = makeLocalIndex()
169
+ localIndex.canvases = [
170
+ { name: 'Design System', dirName: 'design-system', folder: null, route: '/canvas/design-system', isCanvas: true },
171
+ ]
172
+ const remoteIndex = makeRemoteIndex()
173
+ remoteIndex.canvases['design-system'] = {
174
+ id: 'design-system',
175
+ branches: [{ branch: 'main', folder: '', name: 'Design System', route: '/canvas/design-system', folderName: null }],
176
+ }
177
+ remoteIndex.canvases['widget/design-system'] = {
178
+ id: 'widget/design-system',
179
+ branches: [{ branch: 'topic', folder: 'branch--topic/', name: 'Design System', route: '/canvas/widget/design-system', folderName: 'widget' }],
180
+ }
181
+
182
+ const merged = mergeArtifactIndexIntoPrototypeIndex(localIndex, remoteIndex, 'main')
183
+
184
+ const matches = merged.canvases.filter(canvas => canvas.name === 'Design System')
185
+ expect(matches).toHaveLength(1)
186
+ expect(matches[0].branches.map(branch => branch.branch)).toEqual(['topic'])
187
+ })
188
+
137
189
  it('synthesizes remote-only prototype and canvas cards with prefixed IDs', () => {
138
190
  const localIndex = makeLocalIndex()
139
191
  const remoteIndex = makeRemoteIndex()
@@ -323,9 +323,10 @@ export function buildPrototypeIndex(knownRoutes = []) {
323
323
  const key = typeof p === 'string' ? p : p?.name
324
324
  if (key) orderMap.set(key, idx)
325
325
  })
326
+ const stripDraft = (k) => (typeof k === 'string' ? k.split('/').filter(seg => seg !== 'drafts').join('/') : k)
326
327
  entry.pages.sort((a, b) => {
327
- const aKey = a.id ?? a.name
328
- const bKey = b.id ?? b.name
328
+ const aKey = stripDraft(a.id ?? a.name)
329
+ const bKey = stripDraft(b.id ?? b.name)
329
330
  const aIdx = orderMap.has(aKey) ? orderMap.get(aKey) : Infinity
330
331
  const bIdx = orderMap.has(bKey) ? orderMap.get(bKey) : Infinity
331
332
  return aIdx - bIdx
@@ -342,6 +342,45 @@ describe('buildPrototypeIndex', () => {
342
342
  const result = buildPrototypeIndex([])
343
343
  expect(result.prototypes[0].lastModified).toBeNull()
344
344
  })
345
+
346
+ it('sorts drafts canvas-group pages by pageOrder despite the drafts/ id prefix', () => {
347
+ const meta = {
348
+ title: 'Security Vision',
349
+ pageOrder: [
350
+ 'security-vision/1-security-vision',
351
+ 'security-vision/2-confidence-scores',
352
+ 'security-vision/3-final-updates',
353
+ ],
354
+ }
355
+ // Pages are registered in reverse discovery order; their ids keep `drafts/`
356
+ // but pageOrder is authored without it (the correct convention).
357
+ init({
358
+ flows: {},
359
+ objects: {},
360
+ records: {},
361
+ canvases: {
362
+ 'drafts/security-vision/3-final-updates': {
363
+ title: '3 Final Updates', _group: 'security-vision', _isPrivate: true, _canvasMeta: meta,
364
+ },
365
+ 'drafts/security-vision/2-confidence-scores': {
366
+ title: '2 Confidence Scores', _group: 'security-vision', _isPrivate: true, _canvasMeta: meta,
367
+ },
368
+ 'drafts/security-vision/1-security-vision': {
369
+ title: '1 Security Vision', _group: 'security-vision', _isPrivate: true, _canvasMeta: meta,
370
+ },
371
+ },
372
+ })
373
+ const result = buildPrototypeIndex([])
374
+ const card = result.canvases.find(c => c._group === 'security-vision')
375
+ expect(card).toBeTruthy()
376
+ // Title comes from .meta.json, not a page title.
377
+ expect(card.name).toBe('Security Vision')
378
+ expect(card.pages.map(p => p.id)).toEqual([
379
+ 'drafts/security-vision/1-security-vision',
380
+ 'drafts/security-vision/2-confidence-scores',
381
+ 'drafts/security-vision/3-final-updates',
382
+ ])
383
+ })
345
384
  })
346
385
 
347
386
  // ── appendTokens ──
@@ -284,6 +284,30 @@ describe('storyboard-scaffold integration', () => {
284
284
  const after = fs.readFileSync(path.join(tmp, '.gitignore'), 'utf-8')
285
285
  expect(after).toContain('STALE')
286
286
  })
287
+
288
+ it('force-syncs the package-owned base set onto a fresh client', () => {
289
+ runScaffolder(tmp)
290
+
291
+ // Tier-1 infra configs land (iron-clad base — customers never edit these).
292
+ for (const f of ['vite.config.js', 'vitest.config.js', 'eslint.config.js', 'tsconfig.json']) {
293
+ expect(fs.existsSync(path.join(tmp, f)), `${f} should be scaffolded`).toBe(true)
294
+ }
295
+ // The new `home` library surface lands so /-resolution works everywhere.
296
+ expect(fs.existsSync(path.join(tmp, 'src/library/home.jsx'))).toBe(true)
297
+ // No svelte stub is ever scaffolded.
298
+ expect(fs.existsSync(path.join(tmp, 'svelte.config.js'))).toBe(false)
299
+ // The scaffolded eslint config carries no svelte reference.
300
+ expect(fs.readFileSync(path.join(tmp, 'eslint.config.js'), 'utf-8')).not.toContain('svelte')
301
+ }, 20000)
302
+
303
+ it('overwrites a customer-modified vite.config.js (updateable = force-sync)', () => {
304
+ runScaffolder(tmp)
305
+ const vitePath = path.join(tmp, 'vite.config.js')
306
+ const pristine = fs.readFileSync(vitePath, 'utf-8')
307
+ fs.writeFileSync(vitePath, '// customer tampering\n', 'utf-8')
308
+ runScaffolder(tmp)
309
+ expect(fs.readFileSync(vitePath, 'utf-8')).toBe(pristine)
310
+ }, 20000)
287
311
  })
288
312
 
289
313
  // ---------------------------------------------------------------------------
@@ -207,6 +207,56 @@
207
207
  * @property {string} [default] — default global theme ID. Use `"system"` to follow OS preference.
208
208
  */
209
209
 
210
+ /**
211
+ * @typedef {(string|{dev?: string, prod?: string, default?: string})} RouteTarget
212
+ * Target page for a configured route path. Either:
213
+ * - a string — the page name (no leading slash) used in every environment,
214
+ * e.g. `"home"`, `"workspace"`, or a flat component like `"Dashboard"`.
215
+ * - an object — a per-environment target. `dev` is used when
216
+ * `import.meta.env.DEV` is true (running `storyboard dev`); `prod` is used
217
+ * in production builds; `default` is the fallback when the active env key
218
+ * is absent. See {@link resolveRouteTarget} for resolution order.
219
+ */
220
+
221
+ /**
222
+ * @typedef {Record<string, RouteTarget>} RoutesConfig
223
+ * Map of route path → {@link RouteTarget}. The index path `"/"` controls
224
+ * which page renders at the site root. Default: `{ "/": "home" }`
225
+ * (behavior-preserving — today's SimpleWorkspace landing).
226
+ */
227
+
228
+ /**
229
+ * @typedef {object} HomePageConfig
230
+ * Presentation props for the `home` surface (SimpleWorkspace landing). The
231
+ * library `home.jsx`/`index.jsx` files spread these — customers configure the
232
+ * surface here and never edit the JSX.
233
+ * @property {string} [title] — app name shown in the header (default "Storyboard")
234
+ * @property {string} [subtitle] — sub-line under the title
235
+ */
236
+
237
+ /**
238
+ * @typedef {object} WorkspacePageConfig
239
+ * Presentation props for the `workspace` surface (Viewfinder). Spread by the
240
+ * library `workspace.jsx`; customers configure here, never edit the JSX.
241
+ * @property {string} [title] — app name (default "Storyboard")
242
+ * @property {string} [subtitle] — sub-line under the title
243
+ * @property {string|null} [logo] — logo node/text; falsy → render `logoIcon`
244
+ * @property {string} [logoIcon] — Octicon/Iconoir name (default "iconoir/key-command")
245
+ * @property {boolean} [showAllArtifacts] — show the combined "All" tab (default false)
246
+ * @property {boolean} [showPrototypes] — show the Prototypes tab (default true)
247
+ * @property {boolean} [showCanvases] — show the Canvases tab (default true)
248
+ * @property {boolean} [showComponents] — show the Components tab (default true)
249
+ */
250
+
251
+ /**
252
+ * @typedef {object} PagesConfig
253
+ * Per-surface presentation props, keyed by surface/target name. Holds every
254
+ * prop the library workspace pages used to hardcode, so customers configure
255
+ * surfaces purely via config. Behavior-preserving defaults match today's UX.
256
+ * @property {HomePageConfig} [home]
257
+ * @property {WorkspacePageConfig} [workspace]
258
+ */
259
+
210
260
  /**
211
261
  * @typedef {object} StoryboardConfig
212
262
  * @property {string} [customDomain]
@@ -225,6 +275,8 @@
225
275
  * @property {HotPoolConfig} [hotPool]
226
276
  * @property {CommandPaletteConfig} [commandPalette]
227
277
  * @property {CustomerModeConfig} [customerMode]
278
+ * @property {RoutesConfig} [routes] — map of route path → target page. Controls which page renders at each path; `"/"` sets the index. Default `{ "/": "home" }`.
279
+ * @property {PagesConfig} [pages] — per-surface presentation props (title, subtitle, logo, tab toggles) for the `home` and `workspace` surfaces. Customers configure surfaces here instead of editing library JSX.
228
280
  * @property {ThemesConfig} [theming]
229
281
  * @property {Record<string, object>} [widgets] — Custom widget metadata (server-side). Maps widget type string → definition (label, icon, chrome, interaction, connectors, features, props). Consumer entries override built-in widget metadata for collision detection, default sizes, prompt-exec config, etc. Browser-side widget components are registered separately via `mountStoryboardCore({ widgets })`. See `DOCS/custom-widgets.md`.
230
282
  */
@@ -348,6 +400,33 @@ export const configDefaults = {
348
400
  protoHomepage: '',
349
401
  canvasHomepage: '',
350
402
  },
403
+ // Route table: path → target page. `"/"` selects the index surface.
404
+ // Behavior-preserving default — today `/` renders the `home` surface
405
+ // (SimpleWorkspace). A target is either a string (all envs) or a
406
+ // per-env object `{ dev, prod, default }` resolved by resolveRouteTarget.
407
+ // Example consumer override: { "/": { "dev": "workspace", "prod": "home" } }.
408
+ routes: {
409
+ '/': 'home',
410
+ },
411
+ // Per-surface presentation props, keyed by surface name. The library
412
+ // workspace pages spread these so customers never edit the JSX. Defaults
413
+ // reproduce today's exact titles/subtitles and tab visibility.
414
+ pages: {
415
+ home: {
416
+ title: 'Storyboard',
417
+ subtitle: 'Collaborative workspace for design & code',
418
+ },
419
+ workspace: {
420
+ title: 'Storyboard',
421
+ subtitle: 'Where design work goes',
422
+ logo: null,
423
+ logoIcon: 'iconoir/key-command',
424
+ showAllArtifacts: false,
425
+ showPrototypes: true,
426
+ showCanvases: true,
427
+ showComponents: true,
428
+ },
429
+ },
351
430
  theming: {
352
431
  // Minimal default theme registry. Consumers add more in their own
353
432
  // storyboard.config.json — see README#theming. IDs starting with "dark"
@@ -413,3 +492,22 @@ export function getConfig(raw = {}) {
413
492
  export function getConfigDefaults() {
414
493
  return JSON.parse(JSON.stringify(configDefaults))
415
494
  }
495
+
496
+ /**
497
+ * Resolve a configured route path to a target page name for the active env.
498
+ *
499
+ * Resolution order for an object target: exact env key (`dev`/`prod`) →
500
+ * `default` → the other env key → `null`. A string target is returned as-is.
501
+ *
502
+ * @param {RoutesConfig|undefined} routes — the `routes` config map
503
+ * @param {string} path — route path to resolve (e.g. `"/"`)
504
+ * @param {{ dev?: boolean }} [env] — environment flags (default reads `import.meta.env.DEV` at call sites)
505
+ * @returns {string|null} the target page name, or `null` if unconfigured
506
+ */
507
+ export function resolveRouteTarget(routes, path, { dev } = {}) {
508
+ const entry = routes?.[path]
509
+ if (!entry) return null
510
+ if (typeof entry === 'string') return entry || null
511
+ if (typeof entry !== 'object') return null
512
+ return (dev ? entry.dev : entry.prod) ?? entry.default ?? entry.prod ?? entry.dev ?? null
513
+ }
@@ -1,5 +1,5 @@
1
1
  import { describe, it, expect } from 'vitest'
2
- import { getConfig, getConfigDefaults, configDefaults, builtinPasteRules } from './configSchema.js'
2
+ import { getConfig, getConfigDefaults, configDefaults, builtinPasteRules, resolveRouteTarget } from './configSchema.js'
3
3
 
4
4
  describe('configSchema', () => {
5
5
  describe('getConfigDefaults', () => {
@@ -85,4 +85,78 @@ describe('configSchema', () => {
85
85
  expect(JSON.stringify(configDefaults)).toBe(before)
86
86
  })
87
87
  })
88
+
89
+ describe('routes + pages defaults', () => {
90
+ it('defaults routes["/"] to "home" (behavior-preserving)', () => {
91
+ const c = getConfig({})
92
+ expect(c.routes).toEqual({ '/': 'home' })
93
+ })
94
+
95
+ it('defaults the home surface props to today’s values', () => {
96
+ const c = getConfig({})
97
+ expect(c.pages.home).toEqual({
98
+ title: 'Storyboard',
99
+ subtitle: 'Collaborative workspace for design & code',
100
+ })
101
+ })
102
+
103
+ it('defaults the workspace surface props to today’s values', () => {
104
+ const c = getConfig({})
105
+ expect(c.pages.workspace).toEqual({
106
+ title: 'Storyboard',
107
+ subtitle: 'Where design work goes',
108
+ logo: null,
109
+ logoIcon: 'iconoir/key-command',
110
+ showAllArtifacts: false,
111
+ showPrototypes: true,
112
+ showCanvases: true,
113
+ showComponents: true,
114
+ })
115
+ })
116
+
117
+ it('merges a partial pages override without dropping sibling defaults', () => {
118
+ const c = getConfig({ pages: { home: { title: 'My App' } } })
119
+ expect(c.pages.home.title).toBe('My App')
120
+ expect(c.pages.home.subtitle).toBe('Collaborative workspace for design & code')
121
+ expect(c.pages.workspace.showPrototypes).toBe(true)
122
+ })
123
+
124
+ it('replaces the routes map when a user provides one', () => {
125
+ const c = getConfig({ routes: { '/': { dev: 'workspace', prod: 'home' } } })
126
+ expect(c.routes['/']).toEqual({ dev: 'workspace', prod: 'home' })
127
+ })
128
+ })
129
+
130
+ describe('resolveRouteTarget', () => {
131
+ it('returns null for an unconfigured path', () => {
132
+ expect(resolveRouteTarget({ '/': 'home' }, '/missing')).toBeNull()
133
+ expect(resolveRouteTarget(undefined, '/')).toBeNull()
134
+ })
135
+
136
+ it('returns a string target in every environment', () => {
137
+ expect(resolveRouteTarget({ '/': 'home' }, '/', { dev: true })).toBe('home')
138
+ expect(resolveRouteTarget({ '/': 'home' }, '/', { dev: false })).toBe('home')
139
+ })
140
+
141
+ it('picks the dev target in dev and prod target in prod', () => {
142
+ const routes = { '/': { dev: 'workspace', prod: 'home' } }
143
+ expect(resolveRouteTarget(routes, '/', { dev: true })).toBe('workspace')
144
+ expect(resolveRouteTarget(routes, '/', { dev: false })).toBe('home')
145
+ })
146
+
147
+ it('falls back to default when the active env key is absent', () => {
148
+ const routes = { '/': { dev: 'workspace', default: 'home' } }
149
+ expect(resolveRouteTarget(routes, '/', { dev: false })).toBe('home')
150
+ expect(resolveRouteTarget(routes, '/', { dev: true })).toBe('workspace')
151
+ })
152
+
153
+ it('falls back to the other env key when nothing else matches', () => {
154
+ expect(resolveRouteTarget({ '/': { prod: 'home' } }, '/', { dev: true })).toBe('home')
155
+ expect(resolveRouteTarget({ '/': { dev: 'workspace' } }, '/', { dev: false })).toBe('workspace')
156
+ })
157
+
158
+ it('treats an empty-string target as unconfigured', () => {
159
+ expect(resolveRouteTarget({ '/': '' }, '/')).toBeNull()
160
+ })
161
+ })
88
162
  })
@@ -39,6 +39,12 @@ import Workspace, { CreateMenu } from '../Viewfinder.jsx'
39
39
  import Icon from '../Icon.jsx'
40
40
  import css from './SimpleWorkspace.module.css'
41
41
 
42
+ /* ─── localStorage keys ─── */
43
+
44
+ const SHOW_DRAFTS_KEY = 'sb-home-show-drafts'
45
+ const GROUP_BY_FOLDERS_KEY = 'sb-home-group-folders'
46
+ const COLLAPSED_FOLDERS_KEY = 'sb-home-collapsed-folders'
47
+
42
48
  /* ─── Helpers ─── */
43
49
 
44
50
  function withBase(basePath, route) {
@@ -154,12 +160,23 @@ function AvatarStack({ authors }) {
154
160
 
155
161
  /* ─── Cards ─── */
156
162
 
157
- function FolderCard({ folder }) {
163
+ function FolderCard({ folder, collapsed, onToggle }) {
158
164
  const isPrivate = !!folder.isPrivate
159
165
  return (
160
- <div className={`${css.folderCard}${isPrivate ? ' ' + css.folderCardPrivate : ''}`}>
166
+ <button
167
+ type="button"
168
+ className={`${css.folderCard} ${css.folderCardButton}${isPrivate ? ' ' + css.folderCardPrivate : ''}`}
169
+ onClick={onToggle}
170
+ aria-expanded={!collapsed}
171
+ >
172
+ <span
173
+ className={collapsed ? css.folderChevron : css.folderChevronExpanded}
174
+ aria-hidden="true"
175
+ >
176
+ <ChevronRightIcon size={14} />
177
+ </span>
161
178
  <span className={css.folderIcon} aria-hidden="true">
162
- <Icon name="folder" size={18} />
179
+ <Icon name={collapsed ? 'folder' : 'folder-open'} size={18} />
163
180
  </span>
164
181
  <span className={css.folderName}>{folder.dirName.replace(/\.folder$/, '').toUpperCase()}</span>
165
182
  {isPrivate && (
@@ -172,7 +189,7 @@ function FolderCard({ folder }) {
172
189
  </span>
173
190
  )}
174
191
  {folder.description && <span className={css.folderDesc}>{folder.description}</span>}
175
- </div>
192
+ </button>
176
193
  )
177
194
  }
178
195
 
@@ -341,19 +358,61 @@ function SimpleWorkspaceImpl({
341
358
  const [showAll, setShowAll] = useState(false)
342
359
  const [showCreate, setShowCreate] = useState(false)
343
360
 
361
+ // List-control state (persisted to localStorage)
362
+ const [showDrafts, setShowDrafts] = useState(() => {
363
+ try { return localStorage.getItem(SHOW_DRAFTS_KEY) !== 'false' } catch { return true }
364
+ })
365
+ const [groupByFolders, setGroupByFolders] = useState(() => {
366
+ try { return localStorage.getItem(GROUP_BY_FOLDERS_KEY) !== 'false' } catch { return true }
367
+ })
368
+ const [collapsedFolders, setCollapsedFolders] = useState(() => {
369
+ try {
370
+ const raw = localStorage.getItem(COLLAPSED_FOLDERS_KEY)
371
+ const parsed = raw ? JSON.parse(raw) : []
372
+ return new Set(Array.isArray(parsed) ? parsed : [])
373
+ } catch { return new Set() }
374
+ })
375
+
376
+ const toggleShowDrafts = useCallback(() => {
377
+ setShowDrafts(prev => {
378
+ const next = !prev
379
+ try { localStorage.setItem(SHOW_DRAFTS_KEY, String(next)) } catch { /* empty */ }
380
+ return next
381
+ })
382
+ }, [])
383
+ const toggleGroupByFolders = useCallback(() => {
384
+ setGroupByFolders(prev => {
385
+ const next = !prev
386
+ try { localStorage.setItem(GROUP_BY_FOLDERS_KEY, String(next)) } catch { /* empty */ }
387
+ return next
388
+ })
389
+ }, [])
390
+ const toggleFolder = useCallback((dirName) => {
391
+ setCollapsedFolders(prev => {
392
+ const next = new Set(prev)
393
+ if (next.has(dirName)) next.delete(dirName)
394
+ else next.add(dirName)
395
+ try { localStorage.setItem(COLLAPSED_FOLDERS_KEY, JSON.stringify([...next])) } catch { /* empty */ }
396
+ return next
397
+ })
398
+ }, [])
399
+
344
400
  const canFilter = !!(ghUser.name || ghUser.login)
345
401
 
346
- // Filter by tab + (optionally) "mine".
347
- // Draft / private items (e.g. canvases under `drafts/`) are kept — they render
348
- // with an EyeClosedIcon badge so the user can still see and reach them.
402
+ // Filter by tab + (optionally) "mine" + (optionally) drafts.
403
+ // Draft / private items (e.g. canvases under `drafts/`) render with an
404
+ // EyeClosedIcon badge; the "Show drafts" toggle can hide them entirely.
349
405
  const visibleItems = useMemo(() => {
350
406
  const tabType = activeTab === 'prototypes' ? 'prototype' : 'canvas'
351
407
  let pool = allItems.filter(i => i.type === tabType)
352
408
  if (!showAll && canFilter) {
353
409
  pool = pool.filter(i => isMine(i, ghUser.name, ghUser.login))
354
410
  }
411
+ if (!showDrafts) {
412
+ pool = pool.filter(i => !i.isPrivate)
413
+ }
355
414
  return pool
356
- }, [allItems, activeTab, showAll, canFilter, ghUser.name, ghUser.login])
415
+ }, [allItems, activeTab, showAll, canFilter, ghUser.name, ghUser.login, showDrafts])
357
416
 
358
417
  // Folders visible only if they contain at least one visible item of the active tab type.
359
418
  // Private folders (e.g. `drafts/`) are kept and rendered with a draft badge.
@@ -455,6 +514,16 @@ function SimpleWorkspaceImpl({
455
514
  ? `No ${activeTab === 'prototypes' ? 'prototypes' : 'canvases'} created by you yet.`
456
515
  : `No ${activeTab === 'prototypes' ? 'prototypes' : 'canvases'} found.`}
457
516
  </div>
517
+ ) : !groupByFolders ? (
518
+ /* Flat list — folders ignored, all items inline */
519
+ sortedItems.map(item => (
520
+ <ArtifactRow
521
+ key={item.id}
522
+ item={item}
523
+ basePath={basePath}
524
+ branchBasePath={branchBasePath}
525
+ />
526
+ ))
458
527
  ) : (
459
528
  <>
460
529
  {/* Ungrouped items first (no folder) */}
@@ -467,22 +536,29 @@ function SimpleWorkspaceImpl({
467
536
  />
468
537
  ))}
469
538
 
470
- {/* Then each folder as a section: header + its items */}
539
+ {/* Then each folder as a collapsible section: header + its items */}
471
540
  {visibleFolders.map(folder => {
472
541
  const folderItems = grouped.byFolder.get(folder.dirName) || []
542
+ const collapsed = collapsedFolders.has(folder.dirName)
473
543
  return (
474
544
  <section key={folder.dirName} className={css.folderSection}>
475
- <FolderCard folder={folder} />
476
- <div className={css.folderItems}>
477
- {folderItems.map(item => (
478
- <ArtifactRow
479
- key={item.id}
480
- item={item}
481
- basePath={basePath}
482
- branchBasePath={branchBasePath}
483
- />
484
- ))}
485
- </div>
545
+ <FolderCard
546
+ folder={folder}
547
+ collapsed={collapsed}
548
+ onToggle={() => toggleFolder(folder.dirName)}
549
+ />
550
+ {!collapsed && (
551
+ <div className={css.folderItems}>
552
+ {folderItems.map(item => (
553
+ <ArtifactRow
554
+ key={item.id}
555
+ item={item}
556
+ basePath={basePath}
557
+ branchBasePath={branchBasePath}
558
+ />
559
+ ))}
560
+ </div>
561
+ )}
486
562
  </section>
487
563
  )
488
564
  })}
@@ -492,6 +568,26 @@ function SimpleWorkspaceImpl({
492
568
 
493
569
  {/* Footer actions */}
494
570
  <div className={css.footer}>
571
+ <div className={css.controls}>
572
+ <label className={css.controlLabel}>
573
+ <input
574
+ type="checkbox"
575
+ className={css.controlCheckbox}
576
+ checked={showDrafts}
577
+ onChange={toggleShowDrafts}
578
+ />
579
+ Show drafts
580
+ </label>
581
+ <label className={css.controlLabel}>
582
+ <input
583
+ type="checkbox"
584
+ className={css.controlCheckbox}
585
+ checked={groupByFolders}
586
+ onChange={toggleGroupByFolders}
587
+ />
588
+ Group by folders
589
+ </label>
590
+ </div>
495
591
  {ghUser.loaded && canFilter && hiddenCount > 0 && (
496
592
  <button
497
593
  type="button"