@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
@@ -286,6 +286,60 @@
286
286
  margin-top: 32px;
287
287
  }
288
288
 
289
+ .controls {
290
+ flex-basis: 100%;
291
+ display: flex;
292
+ flex-wrap: wrap;
293
+ justify-content: center;
294
+ gap: 16px;
295
+ margin-bottom: 4px;
296
+ }
297
+
298
+ .controlLabel {
299
+ display: inline-flex;
300
+ align-items: center;
301
+ gap: 6px;
302
+ font-size: 13px;
303
+ color: var(--color-foreground-muted, #555);
304
+ cursor: pointer;
305
+ user-select: none;
306
+ }
307
+
308
+ .controlCheckbox {
309
+ accent-color: var(--color-foreground, #1a1a1a);
310
+ cursor: pointer;
311
+ }
312
+
313
+ .folderCardButton {
314
+ appearance: none;
315
+ width: 100%;
316
+ border: none;
317
+ background: transparent;
318
+ font-family: inherit;
319
+ text-align: left;
320
+ cursor: pointer;
321
+ border-radius: 8px;
322
+ transition: background 0.12s;
323
+ }
324
+
325
+ .folderCardButton:hover {
326
+ background: var(--color-background, #fff);
327
+ }
328
+
329
+ .folderChevron {
330
+ display: inline-flex;
331
+ align-items: center;
332
+ justify-content: center;
333
+ color: var(--color-foreground-muted, #656d76);
334
+ flex-shrink: 0;
335
+ transition: transform 0.12s ease;
336
+ }
337
+
338
+ .folderChevronExpanded {
339
+ composes: folderChevron;
340
+ transform: rotate(90deg);
341
+ }
342
+
289
343
  .expandBtn {
290
344
  appearance: none;
291
345
  border: 1px solid var(--color-border, #d0d7de);
@@ -113,6 +113,7 @@ function useGitHubUser() {
113
113
  const STARRED_KEY = 'sb-workspace-starred'
114
114
  const GROUP_BY_FOLDERS_KEY = 'sb-workspace-group-folders'
115
115
  const COLLAPSED_FOLDERS_KEY = 'sb-workspace-collapsed-folders'
116
+ const SHOW_DRAFTS_KEY = 'sb-workspace-show-drafts'
116
117
 
117
118
  function readJSON(key, fallback) {
118
119
  try { return JSON.parse(localStorage.getItem(key)) || fallback }
@@ -795,10 +796,22 @@ function CreateFooter() {
795
796
 
796
797
  /* ─── Create Form ─── */
797
798
 
799
+ function slugify(value) {
800
+ return String(value || '')
801
+ .replace(/[^a-zA-Z0-9\s_-]/g, '')
802
+ .trim()
803
+ .replace(/[\s_]+/g, '-')
804
+ .toLowerCase()
805
+ .replace(/-+/g, '-')
806
+ .replace(/^-|-$/g, '')
807
+ }
808
+
798
809
  function CreateForm({ type, onClose, basePath }) {
799
810
  const [name, setName] = useState('')
811
+ const [nameTouched, setNameTouched] = useState(false)
800
812
  const [title, setTitle] = useState('')
801
813
  const [description, setDescription] = useState('')
814
+ const [author, setAuthor] = useState('')
802
815
  const [url, setUrl] = useState('')
803
816
  const [isExternal, setIsExternal] = useState(false)
804
817
  const [prototype, setPrototype] = useState('')
@@ -810,6 +823,22 @@ function CreateForm({ type, onClose, basePath }) {
810
823
 
811
824
  const needsPrototype = type === 'Flow' || type === 'Page'
812
825
  const showPartials = type === 'Prototype' && !isExternal
826
+ // Types that expose a Title field derive their Name from a slug of the
827
+ // Title; Component/Page have no Title so Name stays the primary field.
828
+ const hasTitle = type !== 'Component' && type !== 'Page'
829
+
830
+ // Prefill author from the currently authed gh user (login preferred, then
831
+ // git user.name), mirroring the CLI `create` behavior.
832
+ useEffect(() => {
833
+ const apiBase = (basePath || '/').replace(/\/+$/, '')
834
+ fetch(`${apiBase}/_storyboard/git-user`)
835
+ .then(r => r.ok ? r.json() : null)
836
+ .then(data => {
837
+ const resolved = data?.login || data?.name
838
+ if (resolved) setAuthor(resolved)
839
+ })
840
+ .catch(() => {})
841
+ }, [basePath])
813
842
 
814
843
  useEffect(() => {
815
844
  if (!needsPrototype) return
@@ -831,6 +860,14 @@ function CreateForm({ type, onClose, basePath }) {
831
860
  .catch(() => {})
832
861
  }, [type, basePath])
833
862
 
863
+ // When the user finishes editing Title, auto-fill Name with a slug of the
864
+ // Title — unless they've already typed their own Name.
865
+ const handleTitleBlur = () => {
866
+ if (nameTouched) return
867
+ const slug = slugify(title)
868
+ if (slug) setName(slug)
869
+ }
870
+
834
871
  const handleSubmit = async (e) => {
835
872
  e.preventDefault()
836
873
  if (!name.trim()) { setError('Name is required'); return }
@@ -849,6 +886,7 @@ function CreateForm({ type, onClose, basePath }) {
849
886
  const payload = { type: apiType, name: name.trim() }
850
887
  if (title.trim()) payload.title = title.trim()
851
888
  if (description.trim()) payload.description = description.trim()
889
+ if (hasTitle && author.trim()) payload.author = author.trim()
852
890
  if (needsPrototype && prototype) payload.prototype = prototype
853
891
  if (type === 'Prototype' && isExternal && url.trim()) payload.url = url.trim()
854
892
  if (type === 'Prototype' && !isExternal && partial) payload.partial = partial
@@ -875,6 +913,21 @@ function CreateForm({ type, onClose, basePath }) {
875
913
 
876
914
  const typeLabels = { Canvas: 'Canvas', Prototype: 'Prototype', Component: 'Component', Flow: 'Prototype Flow', Page: 'Prototype Page', Object: 'Object', Record: 'Record' }
877
915
 
916
+ const nameField = (
917
+ <div className={css.createFormField}>
918
+ <label className={css.createFormLabel}>Name *</label>
919
+ <input
920
+ className={css.createFormInput}
921
+ value={name}
922
+ onChange={e => { setName(e.target.value); setNameTouched(true) }}
923
+ placeholder={type === 'Page' ? 'my-page' : `my-${type.toLowerCase()}`}
924
+ autoFocus={!needsPrototype && !hasTitle}
925
+ autoComplete="off"
926
+ spellCheck={false}
927
+ />
928
+ </div>
929
+ )
930
+
878
931
  return (
879
932
  <form onSubmit={handleSubmit}>
880
933
  <div className={css.createFormHeader}>
@@ -900,18 +953,7 @@ function CreateForm({ type, onClose, basePath }) {
900
953
  </div>
901
954
  )}
902
955
 
903
- <div className={css.createFormField}>
904
- <label className={css.createFormLabel}>Name *</label>
905
- <input
906
- className={css.createFormInput}
907
- value={name}
908
- onChange={e => setName(e.target.value)}
909
- placeholder={type === 'Page' ? 'my-page' : `my-${type.toLowerCase()}`}
910
- autoFocus={!needsPrototype}
911
- />
912
- </div>
913
-
914
- {type !== 'Component' && type !== 'Page' && (
956
+ {hasTitle ? (
915
957
  <>
916
958
  <div className={css.createFormField}>
917
959
  <label className={css.createFormLabel}>Title</label>
@@ -919,9 +961,12 @@ function CreateForm({ type, onClose, basePath }) {
919
961
  className={css.createFormInput}
920
962
  value={title}
921
963
  onChange={e => setTitle(e.target.value)}
964
+ onBlur={handleTitleBlur}
922
965
  placeholder="Optional display title"
966
+ autoFocus={!needsPrototype}
923
967
  />
924
968
  </div>
969
+ {nameField}
925
970
  <div className={css.createFormField}>
926
971
  <label className={css.createFormLabel}>Description</label>
927
972
  <input
@@ -932,6 +977,8 @@ function CreateForm({ type, onClose, basePath }) {
932
977
  />
933
978
  </div>
934
979
  </>
980
+ ) : (
981
+ nameField
935
982
  )}
936
983
 
937
984
  {type === 'Prototype' && (
@@ -1407,6 +1454,9 @@ function WorkspaceImpl({
1407
1454
  return new Set(Array.isArray(parsed) ? parsed : [])
1408
1455
  } catch { return new Set() }
1409
1456
  })
1457
+ const [showDrafts, setShowDrafts] = useState(() => {
1458
+ try { return localStorage.getItem(SHOW_DRAFTS_KEY) !== 'false' } catch { return true }
1459
+ })
1410
1460
  const [hiddenItems, setHiddenItems] = useState(new Set())
1411
1461
  const { starred, toggle: toggleStar } = useStarred()
1412
1462
  const recentIds = useRecent()
@@ -1415,8 +1465,9 @@ function WorkspaceImpl({
1415
1465
  const navFiltered = useMemo(() => {
1416
1466
  let filtered = activeNav === 'all' ? allItems : allItems.filter(i => i.type === TYPE_MAP[activeNav])
1417
1467
  if (hiddenItems.size > 0) filtered = filtered.filter(i => !hiddenItems.has(i.id))
1468
+ if (!showDrafts) filtered = filtered.filter(i => !i.isPrivate)
1418
1469
  return filtered
1419
- }, [allItems, activeNav, hiddenItems])
1470
+ }, [allItems, activeNav, hiddenItems, showDrafts])
1420
1471
 
1421
1472
  // Filter by tab
1422
1473
  const items = useMemo(() => {
@@ -1484,6 +1535,14 @@ function WorkspaceImpl({
1484
1535
  })
1485
1536
  }, [])
1486
1537
 
1538
+ const toggleShowDrafts = useCallback(() => {
1539
+ setShowDrafts(prev => {
1540
+ const next = !prev
1541
+ try { localStorage.setItem(SHOW_DRAFTS_KEY, String(next)) } catch { /* empty */ }
1542
+ return next
1543
+ })
1544
+ }, [])
1545
+
1487
1546
  const handleItemDeleted = useCallback((itemId) => {
1488
1547
  setHiddenItems(prev => new Set(prev).add(itemId))
1489
1548
  }, [])
@@ -1631,15 +1690,26 @@ function WorkspaceImpl({
1631
1690
  {t}
1632
1691
  </button>
1633
1692
  ))}
1634
- <label className={css.groupByFolders}>
1635
- <input
1636
- type="checkbox"
1637
- className={css.groupByFoldersCheckbox}
1638
- checked={groupByFolders}
1639
- onChange={toggleGrouping}
1640
- />
1641
- Group by folders
1642
- </label>
1693
+ <div className={css.tabOptions}>
1694
+ <label className={css.groupByFolders}>
1695
+ <input
1696
+ type="checkbox"
1697
+ className={css.groupByFoldersCheckbox}
1698
+ checked={groupByFolders}
1699
+ onChange={toggleGrouping}
1700
+ />
1701
+ Group by folders
1702
+ </label>
1703
+ <label className={css.groupByFolders}>
1704
+ <input
1705
+ type="checkbox"
1706
+ className={css.groupByFoldersCheckbox}
1707
+ checked={showDrafts}
1708
+ onChange={toggleShowDrafts}
1709
+ />
1710
+ Show drafts
1711
+ </label>
1712
+ </div>
1643
1713
  </div>
1644
1714
 
1645
1715
  {/* Grid */}
@@ -910,11 +910,17 @@
910
910
  border-radius: 6px;
911
911
  font-size: 16px;
912
912
  box-sizing: border-box;
913
+ background: var(--color-background-inset, #fafafa);
914
+ color: var(--color-foreground, #1a1a1a);
915
+ }
916
+
917
+ .createFormInput::placeholder {
918
+ color: var(--color-foreground-muted, #999);
913
919
  }
914
920
 
915
921
  .createFormInput:focus {
916
922
  outline: none;
917
- border-color: var(--color-foreground-muted, #999);
923
+ border-color: var(--color-border-emphasis, #999);
918
924
  }
919
925
 
920
926
  .createFormCheckbox {
@@ -989,6 +995,13 @@
989
995
 
990
996
  /* Group by folders checkbox */
991
997
 
998
+ .tabOptions {
999
+ display: flex;
1000
+ align-items: center;
1001
+ gap: 12px;
1002
+ margin-left: auto;
1003
+ }
1004
+
992
1005
  .groupByFolders {
993
1006
  display: flex;
994
1007
  align-items: center;
@@ -0,0 +1,47 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { filterChildrenForProto } from './routeFilter.js'
3
+
4
+ // Shape mirrors what generouted's generateRegularRoutes produces: nested
5
+ // sub-routes live as children of the prototype's top-level path node.
6
+ // src/prototypes/canvas-threats/index.jsx -> { path: 'canvas-threats', children: [{ index: true }, ...] }
7
+ // src/prototypes/canvas-threats/threats.jsx -> a { path: 'threats' } child of 'canvas-threats'
8
+ const nestedTree = () => [
9
+ {
10
+ path: 'canvas-threats',
11
+ children: [
12
+ { index: true, id: 'index' },
13
+ { path: 'threats', id: 'threats' },
14
+ { path: 'detail/:id', id: 'detail' },
15
+ ],
16
+ },
17
+ { path: 'other-proto', children: [{ index: true, id: 'other' }] },
18
+ { path: '*', id: 'fallback' },
19
+ ]
20
+
21
+ describe('filterChildrenForProto', () => {
22
+ it('keeps nested sub-routes of the matched prototype (regression: nested scoped routes)', () => {
23
+ const out = filterChildrenForProto(nestedTree(), 'canvas-threats')
24
+ const proto = out.find((r) => r.path === 'canvas-threats')
25
+ expect(proto).toBeTruthy()
26
+ // The nested `threats` child must survive — this was the bug.
27
+ expect(proto.children.map((c) => c.id)).toEqual(['index', 'threats', 'detail'])
28
+ })
29
+
30
+ it('drops sibling prototypes but keeps the splat fallback', () => {
31
+ const out = filterChildrenForProto(nestedTree(), 'canvas-threats')
32
+ expect(out.find((r) => r.path === 'other-proto')).toBeUndefined()
33
+ expect(out.find((r) => r.path === '*')).toBeTruthy()
34
+ })
35
+
36
+ it('descends through pathless layout wrappers to reach prototype routes', () => {
37
+ const wrapped = [
38
+ { Component: () => null, children: nestedTree() },
39
+ { path: '*', id: 'fallback' },
40
+ ]
41
+ const out = filterChildrenForProto(wrapped, 'canvas-threats')
42
+ const layout = out.find((r) => !r.path && r.children)
43
+ const proto = layout.children.find((r) => r.path === 'canvas-threats')
44
+ expect(proto.children.map((c) => c.id)).toEqual(['index', 'threats', 'detail'])
45
+ expect(layout.children.find((r) => r.path === 'other-proto')).toBeUndefined()
46
+ })
47
+ })
@@ -19,6 +19,7 @@ import {
19
19
  patterns,
20
20
  } from '@generouted/react-router/core'
21
21
  import PrototypeErrorBoundary, { ImportErrorFallback } from '../../PrototypeErrorBoundary.jsx'
22
+ import { filterChildrenForProto } from './routeFilter.js'
22
23
 
23
24
  // Strip src/library/, src/prototypes/, .folder/, and `drafts/` segments
24
25
  // from route paths. `_app.jsx` lives in src/library/ (since
@@ -111,6 +112,20 @@ const fallback = { path: '*', Component: _404?.default || Fragment }
111
112
 
112
113
  export const routes = [{ ...app, children: [...regularRoutes, fallback] }]
113
114
 
115
+ /**
116
+ * Filter a route tree's children down to a single prototype subtree.
117
+ *
118
+ * Pure helper (no module-level globs) so it's unit-testable. Keeps `*` and
119
+ * pathless layout wrappers (descending into the latter to reach the prototype
120
+ * routes). Any route WITH a path is kept iff its first segment matches the
121
+ * requested proto — and when it matches we keep its ENTIRE subtree intact,
122
+ * since generouted nests sub-routes under the prototype (e.g.
123
+ * `canvas-threats/threats` → a `threats` child of `canvas-threats`).
124
+ * Re-filtering those children by first segment would drop them because their
125
+ * own path segment isn't the proto name — that was the nested-route bug.
126
+ */
127
+ export { filterChildrenForProto } from './routeFilter.js'
128
+
114
129
  /**
115
130
  * Return a routes tree filtered to a single prototype subtree. Used by the
116
131
  * per-prototype iframe entry — filtering at this layer keeps a broken
@@ -119,22 +134,5 @@ export const routes = [{ ...app, children: [...regularRoutes, fallback] }]
119
134
  */
120
135
  export function getRoutesForProto(proto) {
121
136
  if (!proto) return routes
122
- const matchesProto = (route) => {
123
- const seg = (route.path || '').split('/').filter(Boolean)[0]
124
- return seg === proto
125
- }
126
- const filter = (children) => children
127
- .map((r) => {
128
- if (r.path === '*') return r
129
- if (!r.path) {
130
- if (r.children) return { ...r, children: filter(r.children) }
131
- return r
132
- }
133
- if (matchesProto(r)) {
134
- return r.children ? { ...r, children: filter(r.children) } : r
135
- }
136
- return null
137
- })
138
- .filter(Boolean)
139
- return [{ ...routes[0], children: filter(routes[0].children || []) }]
137
+ return [{ ...routes[0], children: filterChildrenForProto(routes[0].children || [], proto) }]
140
138
  }
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Pure (dependency-free) route-tree filtering for the isolated prototypes
3
+ * iframe. Kept separate from `prototypeRoutes.jsx` so it can be unit-tested
4
+ * without pulling in that module's eager `import.meta.glob` chain.
5
+ *
6
+ * Filter a route tree's children down to a single prototype subtree:
7
+ * - `*` splat fallback is always kept.
8
+ * - Pathless layout wrappers are kept, descending into their children to
9
+ * reach the prototype routes.
10
+ * - Any route WITH a path is kept iff its first segment matches the
11
+ * requested proto. When it matches, its ENTIRE subtree is kept intact,
12
+ * because generouted nests sub-routes under the prototype's path node
13
+ * (e.g. `canvas-threats/threats` → a `threats` child of `canvas-threats`).
14
+ * Re-filtering those children by first segment would drop them since
15
+ * their own segment isn't the proto name — that was the nested-route bug.
16
+ */
17
+ export function filterChildrenForProto(children, proto) {
18
+ const matchesProto = (route) => {
19
+ const seg = (route.path || '').split('/').filter(Boolean)[0]
20
+ return seg === proto
21
+ }
22
+ return children
23
+ .map((r) => {
24
+ if (r.path === '*') return r
25
+ if (!r.path) {
26
+ return r.children ? { ...r, children: filterChildrenForProto(r.children, proto) } : r
27
+ }
28
+ return matchesProto(r) ? r : null
29
+ })
30
+ .filter(Boolean)
31
+ }
@@ -552,7 +552,7 @@ export default forwardRef(function PrototypeEmbed({ id: widgetId, props, onUpdat
552
552
  // Match the placeholder's themed background so any
553
553
  // brief gap during navigation (refresh or src change)
554
554
  // never flashes the browser's default white.
555
- background: 'var(--color-background-muted)',
555
+ background: 'var(--color-background)',
556
556
  }}
557
557
  title={`${prototypeTitle} prototype`}
558
558
  sandbox="allow-same-origin allow-scripts allow-forms allow-popups"
@@ -917,6 +917,10 @@ function buildDiscoveryResult({ index, protoFolders, flowRoutes, canvasRoutes, c
917
917
  const dirPath = path.dirname(absPath).replace(/\\/g, '/')
918
918
  const canvasRelDir = dirPath.replace(/^.*?src\/canvas\//, '')
919
919
  canvasMetaByDir[canvasRelDir] = parsed
920
+ // Drafts canvases derive their group with `drafts/` stripped, so also
921
+ // index the meta under the stripped dir so the group lookup matches.
922
+ const canvasRelDirPublic = canvasRelDir.split('/').filter(seg => seg !== 'drafts').join('/')
923
+ if (canvasRelDirPublic !== canvasRelDir) canvasMetaByDir[canvasRelDirPublic] = parsed
920
924
  }
921
925
  } catch { /* skip invalid meta files */ }
922
926
  }
@@ -1,7 +1,7 @@
1
1
  import { mkdtempSync, writeFileSync, mkdirSync, rmSync } from 'node:fs'
2
2
  import { tmpdir } from 'node:os'
3
3
  import path from 'node:path'
4
- import storyboardDataPlugin, { resolveTemplateVars, computeTemplateVars, parseDataFile } from './data-plugin.js'
4
+ import storyboardDataPlugin, { resolveTemplateVars, computeTemplateVars, parseDataFile, buildDataDiscovery } from './data-plugin.js'
5
5
 
6
6
  const RESOLVED_ID = '\0virtual:storyboard-data-index'
7
7
 
@@ -1243,3 +1243,33 @@ describe('parseDataFile — canvas path-based IDs', () => {
1243
1243
  expect(result.group).toBeNull()
1244
1244
  })
1245
1245
  })
1246
+
1247
+ describe('buildDataDiscovery — drafts canvas-group meta', () => {
1248
+ function writePage(dir, name, title) {
1249
+ const evt = { event: 'canvas_created', title, timestamp: Date.now() }
1250
+ writeFileSync(path.join(dir, `${name}.canvas.jsonl`), JSON.stringify(evt) + '\n')
1251
+ }
1252
+
1253
+ it('attaches .meta.json to a drafts/ canvas group (drafts-stripped key reconciliation)', () => {
1254
+ const groupDir = path.join(tmpDir, 'src', 'canvas', 'drafts', 'security-vision')
1255
+ mkdirSync(groupDir, { recursive: true })
1256
+ writePage(groupDir, '1-security-vision', 'Security Vision')
1257
+ writePage(groupDir, '2-confidence-scores', 'Confidence Scores')
1258
+ writeFileSync(
1259
+ path.join(groupDir, 'security-vision.meta.json'),
1260
+ JSON.stringify({
1261
+ title: 'Security Vision',
1262
+ pageOrder: ['security-vision/1-security-vision', 'security-vision/2-confidence-scores'],
1263
+ }),
1264
+ )
1265
+
1266
+ const discovery = buildDataDiscovery(tmpDir, { includeDraft: true })
1267
+ const page = discovery.canvases['drafts/security-vision/1-security-vision']
1268
+ expect(page).toBeTruthy()
1269
+ expect(page._group).toBe('security-vision')
1270
+ // Before the fix this was null because the meta was keyed under
1271
+ // "drafts/security-vision" while the group lookup used "security-vision".
1272
+ expect(page._canvasMeta).toBeTruthy()
1273
+ expect(page._canvasMeta.title).toBe('Security Vision')
1274
+ })
1275
+ })