@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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dfosco/storyboard",
3
- "version": "0.12.0",
3
+ "version": "0.12.2",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "description": "Storyboard prototyping framework — core engine, React integration, and canvas",
@@ -0,0 +1,45 @@
1
+ import js from '@eslint/js'
2
+ import globals from 'globals'
3
+ import react from 'eslint-plugin-react'
4
+ import reactHooks from 'eslint-plugin-react-hooks'
5
+ import reactRefresh from 'eslint-plugin-react-refresh'
6
+
7
+ export default [
8
+ { ignores: ['dist', '.worktrees'] },
9
+ {
10
+ files: ['vite.config.js', 'eslint.config.js'],
11
+ languageOptions: {
12
+ globals: globals.node,
13
+ },
14
+ },
15
+ {
16
+ files: ['**/*.{js,jsx}'],
17
+ languageOptions: {
18
+ ecmaVersion: 2020,
19
+ globals: globals.browser,
20
+ parserOptions: {
21
+ ecmaVersion: 'latest',
22
+ ecmaFeatures: { jsx: true },
23
+ sourceType: 'module',
24
+ },
25
+ },
26
+ settings: { react: { version: 'detect' } },
27
+ plugins: {
28
+ react,
29
+ 'react-hooks': reactHooks,
30
+ 'react-refresh': reactRefresh,
31
+ },
32
+ rules: {
33
+ ...js.configs.recommended.rules,
34
+ ...react.configs.recommended.rules,
35
+ ...react.configs['jsx-runtime'].rules,
36
+ ...reactHooks.configs.recommended.rules,
37
+ 'react/prop-types': 'off',
38
+ 'react/jsx-no-target-blank': 'off',
39
+ 'react-refresh/only-export-components': [
40
+ 'warn',
41
+ { allowConstantExport: true },
42
+ ],
43
+ },
44
+ },
45
+ ]
@@ -15,6 +15,26 @@
15
15
  "target": "storyboard.config.json",
16
16
  "mode": "scaffold"
17
17
  },
18
+ {
19
+ "source": "scaffold/vite.config.js",
20
+ "target": "vite.config.js",
21
+ "mode": "updateable"
22
+ },
23
+ {
24
+ "source": "scaffold/vitest.config.js",
25
+ "target": "vitest.config.js",
26
+ "mode": "updateable"
27
+ },
28
+ {
29
+ "source": "scaffold/eslint.config.js",
30
+ "target": "eslint.config.js",
31
+ "mode": "updateable"
32
+ },
33
+ {
34
+ "source": "scaffold/tsconfig.json",
35
+ "target": "tsconfig.json",
36
+ "mode": "updateable"
37
+ },
18
38
  {
19
39
  "source": "scaffold/scripts/link.sh",
20
40
  "target": "scripts/link.sh",
@@ -139,6 +159,11 @@
139
159
  "target": "src/library/index.jsx",
140
160
  "mode": "updateable"
141
161
  },
162
+ {
163
+ "source": "scaffold/src/library/home.jsx",
164
+ "target": "src/library/home.jsx",
165
+ "mode": "updateable"
166
+ },
142
167
  {
143
168
  "source": "scaffold/src/library/README.md",
144
169
  "target": "src/library/README.md",
@@ -19,6 +19,28 @@ function withTrailingSlash(folder) {
19
19
  return folder.endsWith('/') ? folder : `${folder}/`
20
20
  }
21
21
 
22
+ /**
23
+ * Folder-independent identity for a canvas, mirroring prototype `dirName`
24
+ * matching. Keeps cross-branch dedup stable when a canvas moves between
25
+ * folders (or root ↔ folder). Proto-scoped canvases retain their prototype
26
+ * segment so same-named canvases in different prototypes never collide.
27
+ */
28
+ function canvasMatchKey(id) {
29
+ let raw = String(id || '')
30
+ let isProto = false
31
+ if (raw.startsWith('proto:')) {
32
+ isProto = true
33
+ raw = raw.slice('proto:'.length)
34
+ }
35
+ const segments = raw.split('/').filter(Boolean)
36
+ const name = segments[segments.length - 1] || raw
37
+ if (isProto) {
38
+ const scope = segments.length >= 2 ? segments[segments.length - 2] : ''
39
+ return scope ? `proto:${scope}/${name}` : `proto:${name}`
40
+ }
41
+ return name
42
+ }
43
+
22
44
  function generatedAtMs(manifest) {
23
45
  const value = Date.parse(manifest?.generatedAt || '')
24
46
  return Number.isNaN(value) ? 0 : value
@@ -157,7 +179,7 @@ function addCanvas(index, manifestInfo, item) {
157
179
 
158
180
  const { manifest, folder } = manifestInfo
159
181
  const branch = deriveBranch(manifest, folder)
160
- const key = String(id)
182
+ const key = canvasMatchKey(id)
161
183
 
162
184
  if (!index.canvases[key]) {
163
185
  index.canvases[key] = { id: key, branches: [] }
@@ -13,10 +13,11 @@ The contents:
13
13
  | `prototypes-entry.jsx` | Vite iframe entry — `prototypes.html` loads this |
14
14
  | `routes.jsx` | Generouted glue: scans `src/library/*.jsx` (workspace pages) and `src/prototypes/**` (your prototypes) |
15
15
  | `_app.jsx`, `_app.module.css` | Workspace SPA shell — `StoryboardProvider`, suspense, error boundary, feature-flag banner |
16
- | `workspace.jsx` | `/workspace` route — calls `<Workspace>` |
16
+ | `workspace.jsx` | `/workspace` route — calls `<Workspace>`; props come from `pages.workspace` in `storyboard.config.json` |
17
17
  | `viewfinder.jsx` | `/viewfinder` redirect (kept for back-compat) |
18
18
  | `create.jsx` | `/create` standalone artifact creator |
19
- | `index.jsx` | `/` landing — `<SimpleWorkspace>` |
19
+ | `home.jsx` | `/home` routethe landing surface (`<SimpleWorkspace>`); props come from `pages.home` in `storyboard.config.json` |
20
+ | `index.jsx` | `/` index — renders whichever surface `routes["/"]` selects (env-aware) |
20
21
 
21
22
  ## How to customize storyboard
22
23
 
@@ -25,9 +26,53 @@ Almost every customization knob lives elsewhere:
25
26
  | What you want to change | Edit |
26
27
  |---|---|
27
28
  | App title, theming, feature flags, surfaces | `storyboard.config.json` |
29
+ | Which page renders at `/` (and per environment) | `storyboard.config.json` → `routes` |
30
+ | Landing / workspace titles, subtitles, logo, tabs | `storyboard.config.json` → `pages` |
28
31
  | Provider chain for every **prototype** (design system, fonts, Tailwind) | `src/_prototype.jsx` (optional — create it if you want it) |
29
32
  | Provider chain for every **story** | `src/_story.jsx` (optional) |
30
- | Add new routes the user can navigate to inside the SPA | Add files under `src/prototypes/` |
33
+ | Add new routes the user can navigate to inside the SPA | Add files under `src/prototypes/`, or a flat `src/<Name>.jsx` → `/<Name>` |
34
+
35
+ ### `routes` — what renders at `/`
36
+
37
+ `routes` maps a path to a target page name. The index `/` defaults to `home`:
38
+
39
+ ```jsonc
40
+ {
41
+ "routes": {
42
+ "/": "home" // same page in every env
43
+ // "/": { "dev": "workspace", "prod": "home" } // per-environment target
44
+ }
45
+ }
46
+ ```
47
+
48
+ A target is a page name with no leading slash — a library surface (`"home"`,
49
+ `"workspace"`) or a flat component (`"Dashboard"` for `src/Dashboard.jsx`). The
50
+ object form picks `dev` under `storyboard dev`, `prod` in production builds, and
51
+ `default` as a fallback. If `/` resolves to a flat component, the index
52
+ redirects there.
53
+
54
+ ### `pages` — surface presentation props
55
+
56
+ `pages` holds the title/subtitle/logo/tab props the library pages used to
57
+ hardcode, so you configure surfaces without touching the JSX:
58
+
59
+ ```jsonc
60
+ {
61
+ "pages": {
62
+ "home": { "title": "My App", "subtitle": "Welcome" },
63
+ "workspace": { "title": "My App", "showComponents": false }
64
+ }
65
+ }
66
+ ```
67
+
68
+ Both are leaf-merged over behavior-preserving defaults — set only the keys you
69
+ want to change.
70
+
71
+ ### Flat-file routes
72
+
73
+ Any top-level `src/<Name>.jsx` automatically becomes a `/<Name>` route, rendered
74
+ **bare** (only the `_app` SPA shell — no prototype wrapper). Files prefixed with
75
+ `_` (e.g. `src/_prototype.jsx`) are excluded.
31
76
 
32
77
  If you have a real reason to edit a file in this directory, just edit it —
33
78
  storyboard will warn (but not block) on the next sync. You opt out of sync
@@ -0,0 +1,24 @@
1
+ /**
2
+ * DO NOT EDIT — storyboard-owned library file.
3
+ *
4
+ * Synced from @dfosco/storyboard. The `home` surface (landing workspace).
5
+ * All presentation props (title, subtitle) come from `pages.home` in
6
+ * storyboard.config.json — configure there, never edit this file.
7
+ * See src/library/README.md for the full customization surface.
8
+ */
9
+ import { SimpleWorkspace } from '@dfosco/storyboard'
10
+ import { getConfig } from '@dfosco/storyboard/config'
11
+ import storyboardConfig from '../../storyboard.config.json'
12
+
13
+ const pageModules = import.meta.glob('/src/prototypes/*/*.jsx')
14
+ const { pages } = getConfig(storyboardConfig)
15
+
16
+ export default function HomePage() {
17
+ return (
18
+ <SimpleWorkspace
19
+ {...pages.home}
20
+ pageModules={pageModules}
21
+ basePath={import.meta.env.BASE_URL}
22
+ />
23
+ )
24
+ }
@@ -1,14 +1,41 @@
1
- import { SimpleWorkspace } from '@dfosco/storyboard'
1
+ /**
2
+ * DO NOT EDIT — storyboard-owned library file.
3
+ *
4
+ * Synced from @dfosco/storyboard. The site index (`/`).
5
+ *
6
+ * Which surface renders here is configured via `routes["/"]` in
7
+ * storyboard.config.json — a page name (e.g. "home", "workspace") or a
8
+ * per-environment object `{ dev, prod, default }`. Defaults to "home".
9
+ * Configure the route in storyboard.config.json, never edit this file.
10
+ * See src/library/README.md for the full customization surface.
11
+ */
12
+ import { useEffect } from 'react'
13
+ import { getConfig, resolveRouteTarget } from '@dfosco/storyboard/config'
14
+ import storyboardConfig from '../../storyboard.config.json'
15
+ import HomePage from './home'
16
+ import WorkspacePage from './workspace'
2
17
 
3
- const pageModules = import.meta.glob('/src/prototypes/*/*.jsx')
18
+ const { routes } = getConfig(storyboardConfig)
19
+ const target = resolveRouteTarget(routes, '/', { dev: import.meta.env.DEV }) || 'home'
20
+
21
+ // Built-in surfaces that render inline at the index without a redirect.
22
+ const SURFACES = {
23
+ home: HomePage,
24
+ workspace: WorkspacePage,
25
+ }
26
+
27
+ function RedirectTo({ to }) {
28
+ useEffect(() => {
29
+ const base = (import.meta.env.BASE_URL || '/').replace(/\/+$/, '')
30
+ window.location.replace(`${base}/${to}${window.location.hash}`)
31
+ }, [to])
32
+ return null
33
+ }
4
34
 
5
35
  export default function IndexPage() {
6
- return (
7
- <SimpleWorkspace
8
- title="Storyboard"
9
- subtitle="Collaborative workspace for design & code"
10
- pageModules={pageModules}
11
- basePath={import.meta.env.BASE_URL}
12
- />
13
- )
36
+ const Surface = SURFACES[target]
37
+ if (Surface) return <Surface />
38
+ // Target is a flat component (src/<Name>.jsx) or another library page —
39
+ // redirect `/` to that route so any configured target works.
40
+ return <RedirectTo to={target} />
14
41
  }
@@ -39,7 +39,12 @@ import {
39
39
  // and `drafts/` segments. A `drafts/` dir is a gitignored scratch root —
40
40
  // prototypes inside route at the same URL as a non-prefixed sibling (so
41
41
  // `prototypes/drafts/Foo/` and `prototypes/Foo/` both resolve at `/Foo`).
42
- patterns.route = [/^.*\/src\/(library|prototypes)\/|^\/(library|prototypes)\/|[^/]*\.folder\/|(?<=^|\/)drafts\/|\.(jsx|tsx|mdx)$/g, '']
42
+ //
43
+ // The second alternation strips a leading `/src/` for BARE top-level files
44
+ // (`/src/Dashboard.jsx` → `Dashboard`). The lookahead `(?=[^/]+\.(jsx|tsx|mdx)$)`
45
+ // ensures it only fires for files directly under /src (no further slash), so
46
+ // it never touches library/ or prototypes/ paths.
47
+ patterns.route = [/^.*\/src\/(library|prototypes)\/|^.*\/src\/(?=[^/]+\.(jsx|tsx|mdx)$)|^\/(library|prototypes)\/|[^/]*\.folder\/|(?<=^|\/)drafts\/|\.(jsx|tsx|mdx)$/g, '']
43
48
 
44
49
  // `_app.jsx` lives in src/library/ — it's storyboard's SPA shell
45
50
  // (StoryboardProvider + suspense + error boundary). Consumers can copy
@@ -63,6 +68,13 @@ const PROTOTYPE_ROUTES = import.meta.glob(
63
68
  ['/src/prototypes/**/[\\w[-]*.{jsx,tsx,mdx}', '!/src/prototypes/**/(_!(layout)*(/*)?|_app|404)*'],
64
69
  )
65
70
 
71
+ // Flat-file routes — any top-level `src/<Name>.jsx` becomes a `/<Name>` route,
72
+ // rendered BARE (only the _app SPA shell, NOT wrapped in _prototype). Files
73
+ // prefixed with `_` (e.g. _prototype.jsx, _story.jsx) are excluded.
74
+ const ROOT_ROUTES = import.meta.glob(
75
+ ['/src/*.{jsx,tsx}', '!/src/_*.{jsx,tsx}'],
76
+ )
77
+
66
78
  const preservedRoutes = generatePreservedRoutes(PRESERVED)
67
79
  const modalRoutes = generateModalRoutes(MODALS)
68
80
 
@@ -102,6 +114,7 @@ function makeLazyRoute(importFn, key) {
102
114
 
103
115
  const libraryRoutes = generateRegularRoutes(LIBRARY_ROUTES, makeLazyRoute)
104
116
  const prototypeRoutes = generateRegularRoutes(PROTOTYPE_ROUTES, makeLazyRoute)
117
+ const rootRoutes = generateRegularRoutes(ROOT_ROUTES, makeLazyRoute)
105
118
 
106
119
  const _app = preservedRoutes?.['_app']
107
120
  const _404 = preservedRoutes?.['404']
@@ -162,7 +175,7 @@ const prototypeLayoutRoute = {
162
175
 
163
176
  export const routes = [{
164
177
  ...app,
165
- children: [...libraryRoutes, prototypeLayoutRoute, fallback],
178
+ children: [...libraryRoutes, ...rootRoutes, prototypeLayoutRoute, fallback],
166
179
  }]
167
180
 
168
181
  // Return a routes tree filtered to a single prototype subtree. Used by the
@@ -185,13 +198,15 @@ export function getRoutesForProto(proto) {
185
198
  .map((r) => {
186
199
  if (r.path === '*') return r
187
200
  if (!r.path) {
188
- if (r.children) return { ...r, children: filter(r.children) }
189
- return r
190
- }
191
- if (matchesProto(r)) {
192
201
  return r.children ? { ...r, children: filter(r.children) } : r
193
202
  }
194
- return null
203
+ // A route WITH a path is kept iff its first segment matches the
204
+ // requested proto. When it matches, keep its ENTIRE subtree — do
205
+ // NOT re-filter children, since generouted nests sub-routes under
206
+ // the prototype (e.g. `canvas-threats/threats` becomes a `threats`
207
+ // child of `canvas-threats`); re-filtering would drop them because
208
+ // their own segment isn't the proto name.
209
+ return matchesProto(r) ? r : null
195
210
  })
196
211
  .filter(Boolean)
197
212
  return [{
@@ -1,13 +1,23 @@
1
+ /**
2
+ * DO NOT EDIT — storyboard-owned library file.
3
+ *
4
+ * Synced from @dfosco/storyboard. The `workspace` surface (Viewfinder).
5
+ * All presentation props (title, subtitle, logo, tab toggles) come from
6
+ * `pages.workspace` in storyboard.config.json — configure there, never edit
7
+ * this file. See src/library/README.md for the full customization surface.
8
+ */
1
9
  import { flows } from 'virtual:storyboard-data-index'
2
10
  import { Workspace } from '@dfosco/storyboard'
11
+ import { getConfig } from '@dfosco/storyboard/config'
12
+ import storyboardConfig from '../../storyboard.config.json'
3
13
 
4
14
  const pageModules = import.meta.glob('/src/prototypes/*.jsx')
15
+ const { pages } = getConfig(storyboardConfig)
5
16
 
6
17
  export default function WorkspacePage() {
7
18
  return (
8
19
  <Workspace
9
- title="Storyboard"
10
- subtitle="Where design work goes"
20
+ {...pages.workspace}
11
21
  flows={flows}
12
22
  hideDefaultFlow={true}
13
23
  pageModules={pageModules}
@@ -0,0 +1,25 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2020",
4
+ "useDefineForClassFields": true,
5
+ "lib": ["ES2020", "DOM", "DOM.Iterable"],
6
+ "module": "ESNext",
7
+ "skipLibCheck": true,
8
+ "moduleResolution": "bundler",
9
+ "allowImportingTsExtensions": true,
10
+ "isolatedModules": true,
11
+ "moduleDetection": "force",
12
+ "noEmit": true,
13
+ "jsx": "react-jsx",
14
+ "strict": false,
15
+ "noUnusedLocals": false,
16
+ "noUnusedParameters": false,
17
+ "noFallthroughCasesInSwitch": true,
18
+ "allowJs": true,
19
+ "baseUrl": ".",
20
+ "paths": {
21
+ "@/*": ["./src/*"]
22
+ }
23
+ },
24
+ "include": ["src"]
25
+ }
@@ -0,0 +1,153 @@
1
+ import { defineConfig } from 'vite'
2
+ import path from 'path'
3
+ import react from '@vitejs/plugin-react'
4
+ import tailwindcss from '@tailwindcss/vite'
5
+ import generouted from '@generouted/react-router/plugin'
6
+ import storyboardData from '@dfosco/storyboard/vite'
7
+ import storyboardServer from '@dfosco/storyboard/vite/server'
8
+ import postcssGlobalData from '@csstools/postcss-global-data'
9
+ import postcssPresetEnv from 'postcss-preset-env'
10
+ import browsers from '@github/browserslist-config'
11
+ import { globSync } from 'glob'
12
+
13
+ const __dirname = path.dirname(new URL(import.meta.url).pathname)
14
+
15
+ export default defineConfig(() => {
16
+ const base = process.env.VITE_BASE_PATH || '/'
17
+
18
+ return {
19
+ base,
20
+ resolve: {
21
+ alias: {
22
+ '@': path.resolve(__dirname, './src'),
23
+ },
24
+ dedupe: ['react', 'react-dom'],
25
+ },
26
+ plugins: [
27
+ tailwindcss(),
28
+ storyboardData(),
29
+ storyboardServer(),
30
+ react(),
31
+ generouted({
32
+ source: {
33
+ routes: './src/prototypes/**/[\\w[-]*.{jsx,tsx,mdx}',
34
+ modals: './src/prototypes/**/[+]*.{jsx,tsx,mdx}',
35
+ },
36
+ }),
37
+ // generouted's built-in watcher only listens for /src/pages/ changes.
38
+ // This plugin triggers a full reload when prototypes are added/removed.
39
+ {
40
+ name: 'prototypes-watcher',
41
+ configureServer(server) {
42
+ const listener = (file = '') => {
43
+ if (file.includes(path.normalize('/src/prototypes/'))) {
44
+ server.ws.send({ type: 'full-reload' })
45
+ }
46
+ }
47
+ server.watcher.on('add', listener)
48
+ server.watcher.on('unlink', listener)
49
+ },
50
+ },
51
+ {
52
+ name: 'base-redirect',
53
+ configureServer(server) {
54
+ const baseNoTrail = base.replace(/\/$/, '')
55
+ server.middlewares.use((req, res, next) => {
56
+ if (req.url === baseNoTrail) {
57
+ res.writeHead(302, { Location: base })
58
+ res.end()
59
+ return
60
+ }
61
+ if (req.url && req.url !== baseNoTrail && !req.url.startsWith(base) && !req.url.startsWith('/@') && !req.url.startsWith('/node_modules/')) {
62
+ const newUrl = baseNoTrail + req.url
63
+ res.writeHead(302, { Location: newUrl })
64
+ res.end()
65
+ return
66
+ }
67
+ next()
68
+ })
69
+ },
70
+ },
71
+ ],
72
+ server: {
73
+ port: 1234,
74
+ fs: { allow: ['..'] },
75
+ warmup: {
76
+ clientFiles: [
77
+ 'src/library/mount.jsx',
78
+ 'src/library/prototypes-entry.jsx',
79
+ 'src/prototypes/**/*.jsx',
80
+ 'src/components/**/*.jsx',
81
+ 'src/templates/**/*.jsx',
82
+ ],
83
+ },
84
+ },
85
+ optimizeDeps: {
86
+ include: [
87
+ 'reshaped',
88
+ '@primer/react',
89
+ '@primer/octicons-react',
90
+ 'prop-types',
91
+ '@base-ui/react/accordion',
92
+ '@base-ui/react/checkbox',
93
+ '@base-ui/react/dialog',
94
+ '@base-ui/react/number-field',
95
+ '@base-ui/react/progress',
96
+ '@base-ui/react/radio',
97
+ '@base-ui/react/radio-group',
98
+ '@base-ui/react/select',
99
+ '@base-ui/react/slider',
100
+ '@base-ui/react/switch',
101
+ '@base-ui/react/tabs',
102
+ '@base-ui/react/tooltip',
103
+ // CommonJS leaf pulled in transitively via @dfosco/storyboard ->
104
+ // unified (markdown). unified does `import extend from 'extend'`,
105
+ // but `extend` is CJS; without pre-bundling Vite serves it raw and
106
+ // the default-import interop fails ("does not provide an export
107
+ // named 'default'"). Forcing it into optimizeDeps gives it a
108
+ // synthesized default export.
109
+ 'extend',
110
+ ],
111
+ },
112
+ esbuild: {
113
+ // Preserve function names so the storyboard inspector shows
114
+ // real component names instead of minified identifiers
115
+ keepNames: true,
116
+ },
117
+ build: {
118
+ chunkSizeWarningLimit: 700,
119
+ rollupOptions: {
120
+ output: {
121
+ manualChunks: {
122
+ 'vendor-react': ['react', 'react-dom', 'react-router-dom'],
123
+ 'vendor-primer': ['@primer/react'],
124
+ 'vendor-octicons': ['@primer/octicons-react'],
125
+ 'vendor-reshaped': ['reshaped'],
126
+ },
127
+ },
128
+ },
129
+ },
130
+ css: {
131
+ postcss: {
132
+ plugins: [
133
+ postcssGlobalData({
134
+ files: globSync(
135
+ 'node_modules/@primer/primitives/dist/css/**/*.css',
136
+ { ignore: ['**/themes/**'] }
137
+ ),
138
+ }),
139
+ postcssPresetEnv({
140
+ stage: 2,
141
+ browsers,
142
+ features: {
143
+ 'nesting-rules': {
144
+ noIsPseudoSelector: true,
145
+ },
146
+ 'focus-visible-pseudo-class': false,
147
+ 'logical-properties-and-values': false,
148
+ },
149
+ }),
150
+ ],
151
+ },
152
+ },
153
+ }})
@@ -0,0 +1,17 @@
1
+ import { defineConfig } from 'vitest/config'
2
+ import react from '@vitejs/plugin-react'
3
+
4
+ export default defineConfig({
5
+ plugins: [react()],
6
+ test: {
7
+ environment: 'jsdom',
8
+ globals: true,
9
+ css: true,
10
+ setupFiles: ['./src/test-setup.ts'],
11
+ server: {
12
+ deps: {
13
+ inline: ['@primer/react'],
14
+ },
15
+ },
16
+ },
17
+ })
@@ -19,6 +19,28 @@ function withTrailingSlash(folder) {
19
19
  return folder.endsWith('/') ? folder : `${folder}/`
20
20
  }
21
21
 
22
+ /**
23
+ * Folder-independent identity for a canvas, mirroring prototype `dirName`
24
+ * matching. Keeps cross-branch dedup stable when a canvas moves between
25
+ * folders (or root ↔ folder). Proto-scoped canvases retain their prototype
26
+ * segment so same-named canvases in different prototypes never collide.
27
+ */
28
+ function canvasMatchKey(id) {
29
+ let raw = String(id || '')
30
+ let isProto = false
31
+ if (raw.startsWith('proto:')) {
32
+ isProto = true
33
+ raw = raw.slice('proto:'.length)
34
+ }
35
+ const segments = raw.split('/').filter(Boolean)
36
+ const name = segments[segments.length - 1] || raw
37
+ if (isProto) {
38
+ const scope = segments.length >= 2 ? segments[segments.length - 2] : ''
39
+ return scope ? `proto:${scope}/${name}` : `proto:${name}`
40
+ }
41
+ return name
42
+ }
43
+
22
44
  function generatedAtMs(manifest) {
23
45
  const value = Date.parse(manifest?.generatedAt || '')
24
46
  return Number.isNaN(value) ? 0 : value
@@ -157,7 +179,7 @@ function addCanvas(index, manifestInfo, item) {
157
179
 
158
180
  const { manifest, folder } = manifestInfo
159
181
  const branch = deriveBranch(manifest, folder)
160
- const key = String(id)
182
+ const key = canvasMatchKey(id)
161
183
 
162
184
  if (!index.canvases[key]) {
163
185
  index.canvases[key] = { id: key, branches: [] }
@@ -144,7 +144,12 @@ try {
144
144
  installedVersion = installedVersion || suffix.slice(1)
145
145
  const commitMsg = `[storyboard-update] Update storyboard to ${installedVersion}`
146
146
 
147
- // Only stage update-related files (package.json, lock files, scaffold outputs)
147
+ // Stage update-related files (package.json, lock files, scaffold outputs).
148
+ // The Tier-1 package-owned infra configs + library are force-synced by the
149
+ // scaffold pass and must be committed so `storyboard update` is fully
150
+ // hands-off — customers never hand-patch these. User-facing *.config.json
151
+ // (storyboard.config.json, terminal.config.json, …) are intentionally NOT
152
+ // staged — those are customer-owned.
148
153
  const filesToStage = [
149
154
  'package.json',
150
155
  'package-lock.json',
@@ -153,11 +158,23 @@ try {
153
158
  '.github/skills',
154
159
  '.github/workflows/scripts/aggregate-artifacts.mjs',
155
160
  'scripts',
161
+ // Tier-1 package-owned infrastructure (force-synced):
162
+ 'vite.config.js',
163
+ 'vitest.config.js',
164
+ 'eslint.config.js',
165
+ 'tsconfig.json',
166
+ 'src/library',
156
167
  ]
157
168
  for (const f of filesToStage) {
158
169
  try { execSync(`git add ${f}`, { cwd: process.cwd(), stdio: 'pipe' }) } catch { /* empty */ }
159
170
  }
160
171
 
172
+ // Svelte was removed from the storyboard base — drop the stale empty stub
173
+ // from existing customer repos so the update sheds it automatically.
174
+ try {
175
+ execSync('git rm --quiet --ignore-unmatch svelte.config.js', { cwd: process.cwd(), stdio: 'pipe' })
176
+ } catch { /* empty */ }
177
+
161
178
  // Only commit if there are staged changes
162
179
  try {
163
180
  execSync('git diff --cached --quiet', { cwd: process.cwd(), stdio: 'pipe' })