@dfosco/storyboard-react 4.1.0 → 4.2.0-alpha.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.
@@ -0,0 +1,158 @@
1
+ .container {
2
+ position: relative;
3
+ padding-bottom: 0;
4
+ border-radius: var(--base-size-16, 16px);
5
+ }
6
+
7
+ /* Match selection outline border-radius to terminal's rounded corners */
8
+ :global(.tc-drag-surface):has(.container) {
9
+ border-radius: 16px;
10
+ }
11
+
12
+ .titleBar {
13
+ position: absolute;
14
+ top: -28px;
15
+ left: 4px;
16
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
17
+ font-size: 11px;
18
+ color: #8b949e;
19
+ pointer-events: none;
20
+ user-select: none;
21
+ white-space: nowrap;
22
+ z-index: 2;
23
+ }
24
+
25
+ [data-widget-selected] .titleBar {
26
+ color: var(--borderColor-accent-emphasis, #0969da);
27
+ }
28
+
29
+ .terminal {
30
+ position: relative;
31
+ border-radius: var(--base-size-16, 16px);
32
+ overflow: hidden;
33
+ background: #0d1117;
34
+ border: 1px solid var(--borderColor-default, #30363d);
35
+ box-shadow: 0 1px 3px rgba(0, 0, 0, 0.24);
36
+ padding: 8px;
37
+ }
38
+
39
+ .xtermContainer {
40
+ width: 100%;
41
+ height: 100%;
42
+ box-sizing: border-box;
43
+ }
44
+
45
+ /* ghostty-web / xterm.js container overrides */
46
+ .xtermContainer :global(.xterm) {
47
+ width: 100%;
48
+ height: 100%;
49
+ padding: 12px;
50
+ }
51
+
52
+ .xtermContainer :global(.xterm-viewport) {
53
+ overflow-y: auto;
54
+ }
55
+
56
+ .xtermContainer :global(.xterm-viewport::-webkit-scrollbar) {
57
+ width: 6px;
58
+ }
59
+
60
+ .xtermContainer :global(.xterm-viewport::-webkit-scrollbar-thumb) {
61
+ background: rgba(255, 255, 255, 0.15);
62
+ border-radius: 3px;
63
+ }
64
+
65
+ .xtermContainer :global(.xterm-viewport::-webkit-scrollbar-track) {
66
+ background: transparent;
67
+ }
68
+
69
+ .loading {
70
+ position: absolute;
71
+ inset: 0;
72
+ display: flex;
73
+ align-items: center;
74
+ justify-content: center;
75
+ color: #8b949e;
76
+ font-size: 13px;
77
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
78
+ background: #0d1117;
79
+ z-index: 1;
80
+ }
81
+
82
+ .error {
83
+ position: absolute;
84
+ inset: 0;
85
+ display: flex;
86
+ align-items: center;
87
+ justify-content: center;
88
+ color: #f85149;
89
+ font-size: 13px;
90
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
91
+ background: #0d1117;
92
+ z-index: 1;
93
+ }
94
+
95
+ .mutedPrompt {
96
+ color: #484f58;
97
+ font-family: 'SF Mono', 'Fira Code', 'Cascadia Code', Menlo, monospace;
98
+ font-size: 16px;
99
+ position: absolute;
100
+ top: 12px;
101
+ left: 16px;
102
+ pointer-events: none;
103
+ user-select: none;
104
+ }
105
+
106
+ /* ── Terminal Zzz Animation ── */
107
+
108
+ .buddyZzz {
109
+ display: flex;
110
+ gap: 8px;
111
+ align-items: baseline;
112
+ margin-bottom: 16px;
113
+ font-family: 'SF Mono', 'Fira Code', 'Cascadia Code', Menlo, monospace;
114
+ pointer-events: none;
115
+ user-select: none;
116
+ }
117
+
118
+ .z1 {
119
+ font-size: 18px;
120
+ color: rgba(255, 255, 255, 0.6);
121
+ animation: zFloat 2.4s ease-in-out infinite;
122
+ animation-delay: 0s;
123
+ opacity: 0;
124
+ }
125
+
126
+ .z2 {
127
+ font-size: 22px;
128
+ color: rgba(255, 255, 255, 0.5);
129
+ animation: zFloat 2.4s ease-in-out infinite;
130
+ animation-delay: 0.8s;
131
+ opacity: 0;
132
+ }
133
+
134
+ .z3 {
135
+ font-size: 28px;
136
+ font-weight: 600;
137
+ color: rgba(255, 255, 255, 0.4);
138
+ animation: zFloat 2.4s ease-in-out infinite;
139
+ animation-delay: 1.6s;
140
+ opacity: 0;
141
+ }
142
+
143
+ @keyframes zFloat {
144
+ 0% {
145
+ opacity: 0;
146
+ transform: translateY(0);
147
+ }
148
+ 15% {
149
+ opacity: 1;
150
+ }
151
+ 70% {
152
+ opacity: 0.4;
153
+ }
154
+ 100% {
155
+ opacity: 0;
156
+ transform: translateY(-24px);
157
+ }
158
+ }
@@ -1,7 +1,9 @@
1
1
  import { useState, useCallback, useRef, useEffect, useSyncExternalStore } from 'react'
2
2
  import { Tooltip } from '@primer/react'
3
3
  import { EyeIcon as OcticonEye, EyeClosedIcon as OcticonEyeClosed, CodeIcon as OcticonCode, UnwrapIcon as OcticonUnwrap, ImageIcon as OcticonImage, UnfoldIcon as OcticonUnfold, FoldIcon as OcticonFold } from '@primer/octicons-react'
4
+ import { getConnectorConfig, getInteractGate } from './widgetConfig.js'
4
5
  import styles from './WidgetChrome.module.css'
6
+ import overlayStyles from './embedOverlay.module.css'
5
7
 
6
8
  const STICKY_NOTE_COLORS = {
7
9
  yellow: { bg: '#fff8c5', border: '#d4a72c', dot: '#e8c846' },
@@ -393,6 +395,7 @@ function ColorPickerFeature({ currentColor, options, onColorChange }) {
393
395
  */
394
396
  export default function WidgetChrome({
395
397
  widgetId,
398
+ widgetType,
396
399
  features = [],
397
400
  selected = false,
398
401
  multiSelected = false,
@@ -402,6 +405,7 @@ export default function WidgetChrome({
402
405
  onDeselect, // eslint-disable-line no-unused-vars
403
406
  onAction,
404
407
  onUpdate,
408
+ onConnectorDragStart,
405
409
  children,
406
410
  readOnly = false,
407
411
  }) {
@@ -452,6 +456,55 @@ export default function WidgetChrome({
452
456
  const showFeatures = showToolbar && !multiSelected
453
457
  const menuFeatures = features.filter((f) => f.menu)
454
458
 
459
+ // Interact gate — declarative overlay from widgets.config.json
460
+ const gate = widgetType ? getInteractGate(widgetType) : { enabled: false }
461
+ const [interacting, setInteracting] = useState(false)
462
+ const slotRef = useRef(null)
463
+
464
+ // Exit interact mode on click outside or double-Escape
465
+ const lastEscapeRef = useRef(0)
466
+ useEffect(() => {
467
+ if (!gate.enabled || !interacting) return
468
+ const handleMouseDown = (e) => {
469
+ if (slotRef.current && !slotRef.current.contains(e.target)) {
470
+ setInteracting(false)
471
+ }
472
+ }
473
+ const handleKeyDown = (e) => {
474
+ if (e.key === 'Escape') {
475
+ const now = Date.now()
476
+ if (now - lastEscapeRef.current < 500) {
477
+ // Double-Escape: exit interact mode but keep widget selected
478
+ e.stopPropagation()
479
+ e.preventDefault()
480
+ setInteracting(false)
481
+ lastEscapeRef.current = 0
482
+ } else {
483
+ // First Escape: let it pass to widget, record timestamp
484
+ lastEscapeRef.current = now
485
+ }
486
+ }
487
+ }
488
+ document.addEventListener('mousedown', handleMouseDown, true)
489
+ document.addEventListener('keydown', handleKeyDown, true)
490
+ return () => {
491
+ document.removeEventListener('mousedown', handleMouseDown, true)
492
+ document.removeEventListener('keydown', handleKeyDown, true)
493
+ }
494
+ }, [gate.enabled, interacting])
495
+
496
+ // Exit interact mode when deselected
497
+ useEffect(() => {
498
+ if (!selected && !hovered && interacting) setInteracting(false)
499
+ }, [selected, hovered, interacting])
500
+
501
+ const handleGateClick = useCallback((e) => {
502
+ e.stopPropagation()
503
+ setInteracting(true)
504
+ // Also trigger selection so the widget gets selected
505
+ onSelect?.()
506
+ }, [onSelect])
507
+
455
508
  return (
456
509
  <div
457
510
  className={styles.chromeContainer}
@@ -460,9 +513,41 @@ export default function WidgetChrome({
460
513
  onMouseEnter={(readOnly && !hasFeatures) ? undefined : handleMouseEnter}
461
514
  onMouseLeave={(readOnly && !hasFeatures) ? undefined : handleMouseLeave}
462
515
  >
463
- <div className={`tc-drag-surface ${styles.widgetSlot} ${selected ? styles.widgetSlotSelected : ''} ${multiSelected ? styles.widgetSlotMultiSelected : ''}`} data-widget-selected={selected || undefined}>
516
+ <div ref={slotRef} className={`tc-drag-surface ${styles.widgetSlot} ${selected ? styles.widgetSlotSelected : ''} ${multiSelected ? styles.widgetSlotMultiSelected : ''}`} data-widget-selected={selected || undefined} data-widget-interacting={interacting || undefined}>
464
517
  {children}
518
+ {gate.enabled && !interacting && (
519
+ <div
520
+ className={overlayStyles.interactOverlay}
521
+ onClick={handleGateClick}
522
+ role="button"
523
+ tabIndex={0}
524
+ aria-label={gate.label}
525
+ >
526
+ <span className={overlayStyles.interactHint}>{gate.label}</span>
527
+ </div>
528
+ )}
465
529
  </div>
530
+ {!readOnly && onConnectorDragStart && (() => {
531
+ const connConfig = widgetType ? getConnectorConfig(widgetType) : null
532
+ return ['top', 'bottom', 'left', 'right']
533
+ .filter((a) => !connConfig || connConfig.anchors[a] !== 'unavailable')
534
+ .map((anchor) => {
535
+ const disabled = connConfig?.anchors[anchor] === 'disabled'
536
+ return (
537
+ <div
538
+ key={anchor}
539
+ className={`${styles.anchorPort} ${styles[`anchorPort${anchor[0].toUpperCase()}${anchor.slice(1)}`]} ${disabled ? styles.anchorPortDisabled : ''}`}
540
+ onPointerDown={disabled ? undefined : (e) => {
541
+ e.stopPropagation()
542
+ e.nativeEvent?.stopImmediatePropagation?.()
543
+ e.preventDefault()
544
+ onConnectorDragStart(widgetId, anchor, e)
545
+ }}
546
+ data-anchor={anchor}
547
+ />
548
+ )
549
+ })
550
+ })()}
466
551
  <div
467
552
  className={styles.toolbar}
468
553
  >
@@ -4,6 +4,88 @@
4
4
  position: relative;
5
5
  }
6
6
 
7
+ /* Connector anchor ports — positioned at widget edge centers */
8
+ .anchorPort {
9
+ position: absolute;
10
+ width: 12px;
11
+ height: 12px;
12
+ border-radius: 50%;
13
+ background: var(--bgColor-accent-emphasis, #2f81f7);
14
+ border: 3px solid var(--bgColor-default, #fff);
15
+ opacity: 0;
16
+ transition: opacity 0.15s ease, width 0.1s ease, height 0.1s ease, margin 0.1s ease;
17
+ cursor: crosshair;
18
+ z-index: 100;
19
+ pointer-events: auto;
20
+ }
21
+
22
+ /* Invisible expanded hit area — 15px padding around the visible dot */
23
+ .anchorPort::before {
24
+ content: '';
25
+ position: absolute;
26
+ top: -15px;
27
+ left: -15px;
28
+ right: -15px;
29
+ bottom: -15px;
30
+ }
31
+
32
+ .chromeContainer:hover .anchorPort {
33
+ opacity: 0.6;
34
+ }
35
+
36
+ .anchorPort:hover {
37
+ opacity: 1;
38
+ width: 18px;
39
+ height: 18px;
40
+ }
41
+
42
+ .anchorPortTop {
43
+ top: -6px;
44
+ left: 50%;
45
+ margin-left: -6px;
46
+ }
47
+ .anchorPortTop:hover {
48
+ margin-left: -9px;
49
+ top: -9px;
50
+ }
51
+
52
+ .anchorPortBottom {
53
+ bottom: -6px;
54
+ left: 50%;
55
+ margin-left: -6px;
56
+ }
57
+ .anchorPortBottom:hover {
58
+ margin-left: -9px;
59
+ bottom: -9px;
60
+ }
61
+
62
+ .anchorPortLeft {
63
+ left: -6px;
64
+ top: 50%;
65
+ margin-top: -6px;
66
+ }
67
+ .anchorPortLeft:hover {
68
+ margin-top: -9px;
69
+ left: -9px;
70
+ }
71
+
72
+ .anchorPortRight {
73
+ right: -6px;
74
+ top: 50%;
75
+ margin-top: -6px;
76
+ }
77
+ .anchorPortRight:hover {
78
+ margin-top: -9px;
79
+ right: -9px;
80
+ }
81
+
82
+ .anchorPortDisabled {
83
+ background: var(--fgColor-muted, #8b949e);
84
+ opacity: 0;
85
+ cursor: not-allowed;
86
+ pointer-events: none;
87
+ }
88
+
7
89
  /* Widget slot — contains the actual widget; selection outline targets this */
8
90
  .widgetSlot {
9
91
  position: relative;
@@ -6,6 +6,8 @@ import ImageWidget from './ImageWidget.jsx'
6
6
  import FigmaEmbed from './FigmaEmbed.jsx'
7
7
  import CodePenEmbed from './CodePenEmbed.jsx'
8
8
  import StoryWidget from './StoryWidget.jsx'
9
+ import TerminalWidget from './TerminalWidget.jsx'
10
+ import ActionWidget from './ActionWidget.jsx'
9
11
 
10
12
  /**
11
13
  * Maps widget type strings to their React components.
@@ -20,6 +22,8 @@ export const widgetRegistry = {
20
22
  'figma-embed': FigmaEmbed,
21
23
  'codepen-embed': CodePenEmbed,
22
24
  'story': StoryWidget,
25
+ 'terminal': TerminalWidget,
26
+ 'action': ActionWidget,
23
27
  }
24
28
 
25
29
  /**
@@ -151,6 +151,19 @@ export function getWidgetMeta(type) {
151
151
  return { label: def.label, icon: def.icon }
152
152
  }
153
153
 
154
+ /**
155
+ * Get the interact gate config for a widget type.
156
+ * @returns {{ enabled: boolean, label: string }}
157
+ */
158
+ export function getInteractGate(type) {
159
+ const def = widgetTypes[type]
160
+ if (!def || !def.interactGate) return { enabled: false, label: 'Click to interact' }
161
+ return {
162
+ enabled: true,
163
+ label: def.interactGateLabel || 'Click to interact',
164
+ }
165
+ }
166
+
154
167
  /**
155
168
  * Get all widget types as an array of { type, label, icon } for menus.
156
169
  * Excludes link-preview, image, and figma-embed which are created via paste only.
@@ -160,3 +173,68 @@ export function getMenuWidgetTypes() {
160
173
  .filter(([type]) => type !== 'link-preview' && type !== 'image' && type !== 'figma-embed' && type !== 'codepen-embed' && type !== 'story')
161
174
  .map(([type, def]) => ({ type, label: def.label, icon: def.icon }))
162
175
  }
176
+
177
+ /**
178
+ * Get the connector configuration for a widget type.
179
+ * @param {string} type — widget type string
180
+ * @returns {{ anchors: Record<string, string>, accept: string[], exclude: string[], defaults: Object|undefined }}
181
+ */
182
+ export function getConnectorConfig(type) {
183
+ const def = widgetTypes[type]?.connectors
184
+ return {
185
+ anchors: def?.anchors ?? { top: 'available', bottom: 'available', left: 'available', right: 'available' },
186
+ accept: def?.accept ?? ['*'],
187
+ exclude: def?.exclude ?? [],
188
+ defaults: def?.defaults,
189
+ }
190
+ }
191
+
192
+ /**
193
+ * Check if a specific anchor is available on a widget type.
194
+ * @param {string} type — widget type string
195
+ * @param {string} anchor — anchor name (top/bottom/left/right)
196
+ * @returns {'available' | 'disabled' | 'unavailable'}
197
+ */
198
+ export function getAnchorState(type, anchor) {
199
+ const config = getConnectorConfig(type)
200
+ return config.anchors[anchor] ?? 'available'
201
+ }
202
+
203
+ /**
204
+ * Get the connector styling defaults from config.
205
+ * @returns {Object} connector default styles
206
+ */
207
+ export function getConnectorDefaults() {
208
+ const defaults = widgetsConfig.connectorDefaults ?? {}
209
+ return {
210
+ controlOffset: defaults.controlOffset ?? 80,
211
+ stroke: defaults.stroke ?? 'var(--fgColor-accent, #0969da)',
212
+ strokeWidth: defaults.strokeWidth ?? 4,
213
+ hoverStroke: defaults.hoverStroke ?? 'var(--fgColor-danger, #cf222e)',
214
+ hoverStrokeWidth: defaults.hoverStrokeWidth ?? 5,
215
+ endpointRadius: defaults.endpointRadius ?? 6,
216
+ endpointFill: defaults.endpointFill ?? 'var(--fgColor-accent, #0969da)',
217
+ endpointStroke: defaults.endpointStroke ?? 'var(--bgColor-default, #ffffff)',
218
+ endpointStrokeWidth: defaults.endpointStrokeWidth ?? 3,
219
+ hitAreaStrokeWidth: defaults.hitAreaStrokeWidth ?? 16,
220
+ dragStroke: defaults.dragStroke ?? 'var(--fgColor-accent, #0969da)',
221
+ dragStrokeWidth: defaults.dragStrokeWidth ?? 2,
222
+ dragDasharray: defaults.dragDasharray ?? '6 4',
223
+ dragOpacity: defaults.dragOpacity ?? 0.7,
224
+ startEndpoint: defaults.startEndpoint ?? 'circle',
225
+ endEndpoint: defaults.endEndpoint ?? 'circle',
226
+ }
227
+ }
228
+
229
+ /**
230
+ * Check if a connection from sourceType to targetType is allowed.
231
+ * @param {string} targetType — widget type receiving the connection
232
+ * @param {string} sourceType — widget type initiating the connection
233
+ * @returns {boolean}
234
+ */
235
+ export function canAcceptConnection(targetType, sourceType) {
236
+ const config = getConnectorConfig(targetType)
237
+ if (config.exclude.includes(sourceType)) return false
238
+ if (config.accept.includes('*')) return true
239
+ return config.accept.includes(sourceType)
240
+ }
@@ -129,3 +129,4 @@ export const prototypeEmbedSchema = schemas['prototype']
129
129
  export const linkPreviewSchema = schemas['link-preview']
130
130
  export const imageSchema = schemas['image']
131
131
  export const figmaEmbedSchema = schemas['figma-embed']
132
+ export const terminalSchema = schemas['terminal']
package/src/context.jsx CHANGED
@@ -30,6 +30,21 @@ for (const [name, data] of Object.entries(canvases || {})) {
30
30
  })
31
31
  }
32
32
  }
33
+ // Sort each group's pages by pageOrder from .meta.json (if available)
34
+ for (const [, pages] of canvasGroupMap) {
35
+ const pageOrder = pages[0]?._canvasMeta?.pageOrder
36
+ if (Array.isArray(pageOrder)) {
37
+ const orderMap = new Map()
38
+ pageOrder.forEach((entry, idx) => {
39
+ if (typeof entry === 'string' && !entry.startsWith('sep-')) orderMap.set(entry, idx)
40
+ })
41
+ pages.sort((a, b) => {
42
+ const ai = orderMap.has(a.name) ? orderMap.get(a.name) : Infinity
43
+ const bi = orderMap.has(b.name) ? orderMap.get(b.name) : Infinity
44
+ return ai - bi
45
+ })
46
+ }
47
+ }
33
48
 
34
49
  // Build a map from story route paths → story names at module load time
35
50
  const storyRouteMap = new Map()