@dfosco/storyboard 0.6.8 → 0.6.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dfosco/storyboard",
3
- "version": "0.6.8",
3
+ "version": "0.6.10",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "description": "Storyboard prototyping framework — core engine, React integration, and canvas",
@@ -11,11 +11,27 @@
11
11
  * Schemas may declare `field.dynamic = 'prototypes'`. Pass
12
12
  * `dynamicOptions={{ prototypes: ['my-app', ...] }}` to populate them.
13
13
  */
14
- import { useState, useMemo, useEffect } from 'react'
14
+ import { useState, useMemo, useEffect, useRef } from 'react'
15
15
  import { FormControl, Button, TextInput, Textarea, Flash, Text, ActionMenu, ActionList } from '@primer/react'
16
16
  import { ARTIFACT_SCHEMAS, validateArtifact } from './artifactSchemas.js'
17
17
  import styles from './ArtifactForm.module.css'
18
18
 
19
+ /**
20
+ * Slugify a freeform string into the kebab-case form the `name` field expects.
21
+ * Mirrors the NAME_PATTERN in artifactSchemas.js — lowercase letters, digits,
22
+ * and hyphens. Used to auto-derive `name` from `title` while the user types,
23
+ * until they manually edit `name` themselves.
24
+ */
25
+ function slugifyName(value) {
26
+ return String(value || '')
27
+ .toLowerCase()
28
+ .normalize('NFKD')
29
+ .replace(/[\u0300-\u036f]/g, '')
30
+ .replace(/[^a-z0-9]+/g, '-')
31
+ .replace(/^-+|-+$/g, '')
32
+ .slice(0, 64)
33
+ }
34
+
19
35
  function TypeSelector({ selected, onChange }) {
20
36
  const types = Object.entries(ARTIFACT_SCHEMAS)
21
37
  const current = ARTIFACT_SCHEMAS[selected]
@@ -150,18 +166,66 @@ export default function ArtifactForm({
150
166
  const [errors, setErrors] = useState({})
151
167
  const [submitting, setSubmitting] = useState(false)
152
168
 
169
+ // Tracks whether the `name` field should keep auto-following `title`.
170
+ // Flips to false the first time the user edits `name` directly, so we don't
171
+ // clobber their input. Reset on type change or unmount.
172
+ const nameAutoFollowRef = useRef(true)
173
+
153
174
  // Reset form when fixed type changes (parent-controlled)
154
175
  useEffect(() => {
155
176
  if (!fixedType) return
156
177
  setValues({ ...initialValues(ARTIFACT_SCHEMAS[fixedType]), ...(initialOverride || {}) })
157
178
  setErrors({})
158
179
  setSubmitting(false)
180
+ nameAutoFollowRef.current = true
159
181
  }, [fixedType])
160
182
 
183
+ // Merge late-arriving initial overrides (e.g. a gh-login fetch that resolves
184
+ // after mount) into empty fields. Only fills fields the user hasn't typed
185
+ // into yet, so we never overwrite their input. Stringify the override so the
186
+ // dep check fires on content changes rather than parent re-render identity.
187
+ const overrideKey = useMemo(() => JSON.stringify(initialOverride || {}), [initialOverride])
188
+ useEffect(() => {
189
+ if (!initialOverride || !schema) return
190
+ setValues(prev => {
191
+ let next = prev
192
+ for (const [k, v] of Object.entries(initialOverride)) {
193
+ if (v == null || v === '') continue
194
+ const fieldExists = schema.fields.some(f => f.name === k)
195
+ const isEmpty = prev[k] === '' || prev[k] == null
196
+ if (fieldExists && isEmpty) {
197
+ if (next === prev) next = { ...prev }
198
+ next[k] = v
199
+ }
200
+ }
201
+ return next
202
+ })
203
+ // eslint-disable-next-line react-hooks/exhaustive-deps
204
+ }, [overrideKey, schema])
205
+
206
+ // Derive `name` (kebab-case) from `title` as the user types, until they
207
+ // manually edit `name`. Only applies to schemas that expose both fields
208
+ // (prototype, canvas, flow). Component uses PascalCase and has no title,
209
+ // so it's naturally excluded.
210
+ useEffect(() => {
211
+ if (!schema || !nameAutoFollowRef.current) return
212
+ const hasName = schema.fields.some(f => f.name === 'name' && f.type === 'text')
213
+ const hasTitle = schema.fields.some(f => f.name === 'title')
214
+ if (!hasName || !hasTitle) return
215
+ const derived = slugifyName(values.title)
216
+ if (derived === (values.name || '')) return
217
+ setValues(prev => ({ ...prev, name: derived }))
218
+ if (errors.name) {
219
+ setErrors(prev => { const next = { ...prev }; delete next.name; return next })
220
+ }
221
+ // eslint-disable-next-line react-hooks/exhaustive-deps
222
+ }, [values.title, schema])
223
+
161
224
  function handleTypeChange(newType) {
162
225
  setSelectedType(newType)
163
226
  setValues(initialValues(ARTIFACT_SCHEMAS[newType]))
164
227
  setErrors({})
228
+ nameAutoFollowRef.current = true
165
229
  }
166
230
 
167
231
  const [showAdvanced, setShowAdvanced] = useState(false)
@@ -205,6 +269,9 @@ export default function ArtifactForm({
205
269
  }
206
270
 
207
271
  function handleChange(fieldName, value) {
272
+ if (fieldName === 'name') {
273
+ nameAutoFollowRef.current = false
274
+ }
208
275
  setValues(prev => ({ ...prev, [fieldName]: value }))
209
276
  if (errors[fieldName]) {
210
277
  setErrors(prev => { const next = { ...prev }; delete next[fieldName]; return next })
@@ -27,6 +27,7 @@ export default function CreateDialog({ type, basePath, onClose }) {
27
27
  const schema = schemaKey ? ARTIFACT_SCHEMAS[schemaKey] : null
28
28
  const [prototypes, setPrototypes] = useState([])
29
29
  const [partials, setPartials] = useState([])
30
+ const [ghLogin, setGhLogin] = useState(null)
30
31
 
31
32
  const needsPrototypes = useMemo(() => {
32
33
  if (!schema) return false
@@ -38,6 +39,11 @@ export default function CreateDialog({ type, basePath, onClose }) {
38
39
  return schema.fields.some(f => f.dynamic === 'partials')
39
40
  }, [schema])
40
41
 
42
+ const needsAuthor = useMemo(() => {
43
+ if (!schema) return false
44
+ return schema.fields.some(f => f.name === 'author')
45
+ }, [schema])
46
+
41
47
  useEffect(() => {
42
48
  if (!needsPrototypes || !schemaKey) return
43
49
  const apiBase = (basePath || '/').replace(/\/+$/, '')
@@ -68,6 +74,25 @@ export default function CreateDialog({ type, basePath, onClose }) {
68
74
  .catch(() => {})
69
75
  }, [needsPartials, schemaKey, basePath])
70
76
 
77
+ // Fetch the gh CLI login once per dialog open and prefill it as the default
78
+ // author when the schema supports it (currently only `prototype`).
79
+ useEffect(() => {
80
+ if (!needsAuthor || !schemaKey) return
81
+ const apiBase = (basePath || '/').replace(/\/+$/, '')
82
+ fetch(`${apiBase}/_storyboard/git-user`)
83
+ .then(r => (r.ok ? r.json() : null))
84
+ .then(data => { if (data?.login) setGhLogin(data.login) })
85
+ .catch(() => {})
86
+ }, [needsAuthor, schemaKey, basePath])
87
+
88
+ // Build the initial values object for the form. Late-arriving values (like
89
+ // the gh login fetch above) get merged into empty fields inside ArtifactForm
90
+ // — see the override-merge effect there.
91
+ const initialValues = useMemo(() => {
92
+ if (!needsAuthor || !ghLogin) return undefined
93
+ return { author: ghLogin }
94
+ }, [needsAuthor, ghLogin])
95
+
71
96
  if (!schemaKey || !schema) return null
72
97
 
73
98
  async function handleSubmit({ type: t, values }) {
@@ -120,6 +145,7 @@ export default function CreateDialog({ type, basePath, onClose }) {
120
145
  onSubmit={handleSubmit}
121
146
  onCancel={onClose}
122
147
  dynamicOptions={{ prototypes, partials }}
148
+ initialValues={initialValues}
123
149
  hideHeader
124
150
  />
125
151
  </Dialog>
@@ -54,22 +54,24 @@ export default forwardRef(function PrototypeEmbed({ id: widgetId, props, onUpdat
54
54
 
55
55
  const basePath = (import.meta.env.BASE_URL || '/').replace(/\/$/, '')
56
56
  const baseSegment = basePath.replace(/^\//, '')
57
- // Iframe URL is mode-dependent:
58
- // - DEV: route through the isolated prototypes.html entry so a
59
- // broken prototype's transform/HMR errors stay inside the iframe
60
- // and never poison the canvas (see .agents/plans/vite-isolation.md).
61
- // prototypes.html uses createHashRouter, so /MyProto/SignupForm
62
- // becomes prototypes.html?proto=MyProto#/MyProto/SignupForm.
63
- // - PROD: load the prototype path directly through the canvas SPA's
64
- // BrowserRouter (e.g. /MyProto/SignupForm). prototypes.html is a
65
- // build-time isolation artifact it must not leak into deployed
66
- // URLs (breaks deep links, exposes build internals). basePath
67
- // already carries the /branch--xxx/ prefix on branch deploys, so
68
- // this works for main and branch deploys alike.
69
- // External http(s) URLs are left alone.
70
- const rawSrc = useMemo(() => {
71
- if (!src) return ''
72
- if (/^https?:\/\//.test(src)) return src
57
+ // Two URLs are derived from `src`:
58
+ // - rawSrc the iframe URL. In DEV this routes through the isolated
59
+ // prototypes.html entry so a broken prototype's transform/HMR errors
60
+ // stay inside the iframe (see .agents/plans/vite-isolation.md):
61
+ // /MyProto/SignupForm becomes prototypes.html?proto=MyProto#/MyProto/SignupForm.
62
+ // In PROD it loads the prototype path directly through the canvas SPA
63
+ // (prototypes.html is a build-time isolation artifact that must not
64
+ // leak into deployed URLs).
65
+ // - externalSrcthe URL used by "Open in new tab". Always direct
66
+ // (`${basePath}/<protoPath>`), never prototypes.html, even in dev
67
+ // opening prototypes.html#/... in a fresh tab is a leaky surprise
68
+ // for users navigating from the canvas.
69
+ // External http(s) URLs are left alone in both cases. basePath already
70
+ // carries the /branch--xxx/ prefix on branch deploys, so both work for
71
+ // main and branch deploys alike.
72
+ const { rawSrc, externalSrc } = useMemo(() => {
73
+ if (!src) return { rawSrc: '', externalSrc: '' }
74
+ if (/^https?:\/\//.test(src)) return { rawSrc: src, externalSrc: src }
73
75
  const cleaned = src.replace(/^\/branch--[^/]+/, '')
74
76
  let normalized
75
77
  if (baseSegment && cleaned.startsWith(basePath)) normalized = cleaned
@@ -77,7 +79,7 @@ export default forwardRef(function PrototypeEmbed({ id: widgetId, props, onUpdat
77
79
  else normalized = `${basePath}${cleaned}`
78
80
  // Strip basePath so we can split path/query/hash cleanly and
79
81
  // re-anchor for whichever mode we're in. Any pre-existing #hash on
80
- // the original src is preserved as the iframe URL's hash.
82
+ // the original src is preserved.
81
83
  const withoutBase = baseSegment && normalized.startsWith(basePath)
82
84
  ? normalized.slice(basePath.length) || '/'
83
85
  : normalized
@@ -86,18 +88,20 @@ export default forwardRef(function PrototypeEmbed({ id: widgetId, props, onUpdat
86
88
  const pathAndQuery = hashIdx >= 0 ? withoutBase.slice(0, hashIdx) : withoutBase
87
89
  const routePath = pathAndQuery.startsWith('/') ? pathAndQuery : `/${pathAndQuery}`
88
90
  const suffix = innerHash ? `#${innerHash}` : ''
91
+ // Direct path through the canvas SPA — used in prod for the iframe and
92
+ // always for "Open in new tab".
93
+ const directUrl = `${basePath}${routePath}${suffix}`
89
94
  if (import.meta.env.PROD) {
90
- // Direct path through the canvas SPA — no prototypes.html in
91
- // production URLs.
92
- return `${basePath}${routePath}${suffix}`
95
+ return { rawSrc: directUrl, externalSrc: directUrl }
93
96
  }
94
- // Dev: prototypes.html with ?proto= narrowing. The consumer's
97
+ // Dev iframe: prototypes.html with ?proto= narrowing. The consumer's
95
98
  // prototypes-entry.jsx reads ?proto= and calls getRoutesForProto();
96
99
  // older scaffolds harmlessly ignore the param and load the full tree.
97
100
  const pathOnly = pathAndQuery.split('?')[0]
98
101
  const protoName = pathOnly.split('/').filter(Boolean)[0] || ''
99
102
  const queryStr = protoName ? `?proto=${encodeURIComponent(protoName)}` : ''
100
- return `${basePath}/prototypes.html${queryStr}#${routePath}${suffix}`
103
+ const iframeUrl = `${basePath}/prototypes.html${queryStr}#${routePath}${suffix}`
104
+ return { rawSrc: iframeUrl, externalSrc: directUrl }
101
105
  }, [src, basePath, baseSegment])
102
106
 
103
107
  const scale = zoom / 100
@@ -332,7 +336,7 @@ export default forwardRef(function PrototypeEmbed({ id: widgetId, props, onUpdat
332
336
  } else if (actionId === 'split-screen') {
333
337
  setExpandMode('split')
334
338
  } else if (actionId === 'open-external') {
335
- if (rawSrc) window.open(rawSrc, '_blank', 'noopener')
339
+ if (externalSrc) window.open(externalSrc, '_blank', 'noopener')
336
340
  } else if (actionId === 'refresh-frame') {
337
341
  const iframe = iframeRef.current
338
342
  if (iframe) {
@@ -347,7 +351,7 @@ export default forwardRef(function PrototypeEmbed({ id: widgetId, props, onUpdat
347
351
  onUpdate?.({ zoom: Math.max(25, zoom - step) })
348
352
  }
349
353
  },
350
- }), [rawSrc, zoom, onUpdate])
354
+ }), [externalSrc, zoom, onUpdate])
351
355
 
352
356
  function handlePickRoute(route) {
353
357
  onUpdate?.({ src: route })