@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.
- package/dist/storyboard-ui.js.map +1 -1
- package/package.json +1 -1
- package/scaffold/eslint.config.js +45 -0
- package/scaffold/scaffold.config.json +25 -0
- package/scaffold/scripts/aggregate-artifacts.mjs +23 -1
- package/scaffold/src/library/README.md +48 -3
- package/scaffold/src/library/home.jsx +24 -0
- package/scaffold/src/library/index.jsx +37 -10
- package/scaffold/src/library/routes.jsx +22 -7
- package/scaffold/src/library/workspace.jsx +12 -2
- package/scaffold/tsconfig.json +25 -0
- package/scaffold/vite.config.js +153 -0
- package/scaffold/vitest.config.js +17 -0
- package/scripts/aggregate-artifacts.mjs +23 -1
- package/src/core/cli/updateVersion.js +18 -1
- package/src/core/data/artifactIndex.js +54 -8
- package/src/core/data/artifactIndex.test.js +52 -0
- package/src/core/data/viewfinder.js +3 -2
- package/src/core/data/viewfinder.test.js +39 -0
- package/src/core/scaffold/scaffold-integration.test.js +24 -0
- package/src/core/stores/configSchema.js +98 -0
- package/src/core/stores/configSchema.test.js +75 -1
- package/src/internals/SimpleWorkspace/SimpleWorkspace.jsx +116 -20
- package/src/internals/SimpleWorkspace/SimpleWorkspace.module.css +54 -0
- package/src/internals/Viewfinder.jsx +92 -22
- package/src/internals/Viewfinder.module.css +14 -1
- package/src/internals/canvas/isolation/prototypeRoutes.filter.test.js +47 -0
- package/src/internals/canvas/isolation/prototypeRoutes.jsx +16 -18
- package/src/internals/canvas/isolation/routeFilter.js +31 -0
- package/src/internals/canvas/widgets/PrototypeEmbed.jsx +1 -1
- package/src/internals/vite/data-plugin.js +4 -0
- 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
|
|
236
|
+
const remoteCanvasesByKey = collapseRemoteCanvases(remoteIndex.canvases)
|
|
192
237
|
const localPrototypeIds = new Set()
|
|
193
|
-
const
|
|
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
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
return { ...canvas
|
|
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,
|
|
224
|
-
if (!
|
|
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
|
-
<
|
|
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=
|
|
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
|
-
</
|
|
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/`)
|
|
348
|
-
//
|
|
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
|
|
476
|
-
|
|
477
|
-
{
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
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"
|