@dfosco/storyboard-react 4.1.0 → 4.2.0-beta.0

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.
@@ -68,6 +68,7 @@ export default function MarkdownBlock({ props, onUpdate, resizable }) {
68
68
  const content = readProp(props, 'content', markdownSchema)
69
69
  const width = readProp(props, 'width', markdownSchema)
70
70
  const height = props?.height
71
+ const collapsed = !!props?.collapsed
71
72
  const canEdit = typeof onUpdate === 'function'
72
73
  const [editing, setEditing] = useState(false)
73
74
  const editingActive = canEdit && editing
@@ -121,7 +122,14 @@ export default function MarkdownBlock({ props, onUpdate, resizable }) {
121
122
  setEditHeight(blockRef.current.offsetHeight)
122
123
  }
123
124
  if (textareaRef.current) {
124
- textareaRef.current.focus()
125
+ // Place cursor at end and prevent scroll jump to top
126
+ const len = textareaRef.current.value.length
127
+ textareaRef.current.setSelectionRange(len, len)
128
+ textareaRef.current.focus({ preventScroll: true })
129
+ // Restore the block's scroll position (captured before React swapped the DOM)
130
+ if (blockRef.current) {
131
+ blockRef.current.scrollTop = blockRef.current.dataset.scrollTop || 0
132
+ }
125
133
  }
126
134
  } else {
127
135
  setEditHeight(null)
@@ -132,15 +140,19 @@ export default function MarkdownBlock({ props, onUpdate, resizable }) {
132
140
  <WidgetWrapper>
133
141
  <div
134
142
  ref={blockRef}
135
- className={styles.block}
136
- style={{ width, ...(height ? { height, overflow: 'auto' } : {}), minHeight: editHeight || undefined }}
143
+ className={`${styles.block}${collapsed && !editingActive ? ` ${styles.blockCollapsed}` : ''}`}
144
+ style={{
145
+ width,
146
+ ...(height ? { height, overflow: 'auto' } : {}),
147
+ ...(editHeight ? { height: editHeight, display: 'flex', flexDirection: 'column' } : {}),
148
+ }}
137
149
  >
138
150
  {editingActive ? (
139
151
  <textarea
140
152
  ref={textareaRef}
141
153
  className={styles.editor}
142
154
  data-canvas-allow-text-selection
143
- style={{ minHeight: editHeight ? editHeight - 2 : undefined }}
155
+ style={{ flex: 1 }}
144
156
  value={content}
145
157
  onChange={handleContentChange}
146
158
  onBlur={() => setEditing(false)}
@@ -158,10 +170,19 @@ export default function MarkdownBlock({ props, onUpdate, resizable }) {
158
170
  data-canvas-allow-text-selection={!canEdit ? '' : undefined}
159
171
  onClick={!canEdit ? (e) => e.stopPropagation() : undefined}
160
172
  onCopy={!canEdit ? handleReadOnlyCopy : undefined}
161
- onDoubleClick={canEdit ? () => setEditing(true) : undefined}
173
+ onDoubleClick={canEdit ? () => {
174
+ // Save scroll position before switching to editor
175
+ if (blockRef.current) blockRef.current.dataset.scrollTop = blockRef.current.scrollTop
176
+ setEditing(true)
177
+ } : undefined}
162
178
  role={canEdit ? 'button' : undefined}
163
179
  tabIndex={canEdit ? 0 : undefined}
164
- onKeyDown={canEdit ? (e) => { if (e.key === 'Enter') setEditing(true) } : undefined}
180
+ onKeyDown={canEdit ? (e) => {
181
+ if (e.key === 'Enter') {
182
+ if (blockRef.current) blockRef.current.dataset.scrollTop = blockRef.current.scrollTop
183
+ setEditing(true)
184
+ }
185
+ } : undefined}
165
186
  dangerouslySetInnerHTML={{
166
187
  __html: renderedHtml || (canEdit
167
188
  ? '<p class="placeholder">Double-click to edit…</p>'
@@ -10,6 +10,16 @@
10
10
  font-family: var(--tc-font-stack, system-ui, -apple-system, sans-serif);
11
11
  }
12
12
 
13
+ .blockCollapsed {
14
+ max-height: 360px;
15
+ overflow: hidden;
16
+ }
17
+
18
+ .blockCollapsed .preview {
19
+ mask-image: linear-gradient(to bottom, black 80%, transparent 100%);
20
+ -webkit-mask-image: linear-gradient(to bottom, black 80%, transparent 100%);
21
+ }
22
+
13
23
  .preview {
14
24
  padding: 16px 20px;
15
25
  font-size: 14px;
@@ -206,7 +216,7 @@
206
216
  width: 100%;
207
217
  height: 100%;
208
218
  box-sizing: border-box;
209
- min-height: 120px;
219
+
210
220
  padding: 16px 20px;
211
221
  border: none;
212
222
  outline: none;
@@ -12,6 +12,8 @@
12
12
  font-family: var(--tc-font-stack, system-ui, -apple-system, sans-serif);
13
13
  overflow: auto;
14
14
  position: relative;
15
+ display: flex;
16
+ flex-direction: column;
15
17
  }
16
18
 
17
19
  :global([data-sb-canvas-theme^='dark']) .sticky {
@@ -35,6 +37,7 @@
35
37
  word-break: break-word;
36
38
  cursor: text;
37
39
  min-height: 60px;
40
+ flex: 1;
38
41
  }
39
42
 
40
43
  :global([data-sb-canvas-theme^='dark']) .text {
@@ -0,0 +1,274 @@
1
+ import { useRef, useEffect, useCallback, useState } from 'react'
2
+ import { readProp } from './widgetProps.js'
3
+ import { schemas } from './widgetProps.js'
4
+ import { getTerminalConfig } from '@dfosco/storyboard-core'
5
+ import ResizeHandle from './ResizeHandle.jsx'
6
+ import styles from './TerminalWidget.module.css'
7
+ import overlayStyles from './embedOverlay.module.css'
8
+
9
+ const terminalSchema = schemas['terminal']
10
+
11
+ /**
12
+ * Lazy-load ghostty-web to avoid bundling WASM in prod.
13
+ */
14
+ let ghosttyPromise = null
15
+ function loadGhostty() {
16
+ if (!ghosttyPromise) {
17
+ ghosttyPromise = import('ghostty-web').then(async (mod) => {
18
+ if (mod.init) await mod.init()
19
+ return mod
20
+ })
21
+ }
22
+ return ghosttyPromise
23
+ }
24
+
25
+ /**
26
+ * Build the WebSocket URL for the terminal backend.
27
+ * Includes the base path (e.g. /branch--4.2.0/) so the proxy routes correctly.
28
+ * Passes canvasId as a query parameter for session scoping.
29
+ */
30
+ function getWsUrl(sessionId, prettyName) {
31
+ const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:'
32
+ const base = (typeof import.meta !== 'undefined' && import.meta.env?.BASE_URL) || '/'
33
+ const baseClean = base.endsWith('/') ? base : base + '/'
34
+ const canvasId = window.__storyboardCanvasBridgeState?.canvasId || 'unknown'
35
+ let url = `${protocol}//${location.host}${baseClean}_storyboard/terminal/${sessionId}?canvas=${encodeURIComponent(canvasId)}`
36
+ if (prettyName) url += `&name=${encodeURIComponent(prettyName)}`
37
+ return url
38
+ }
39
+
40
+ /**
41
+ * Calculate terminal cols/rows from pixel dimensions.
42
+ */
43
+ function calcDimensions(widthPx, heightPx) {
44
+ // Approximate character cell size for 13px monospace
45
+ const cellWidth = 7.8
46
+ const cellHeight = 17
47
+ const padding = 24 // 12px each side
48
+ const cols = Math.max(10, Math.floor((widthPx - padding) / cellWidth))
49
+ const rows = Math.max(4, Math.floor((heightPx - padding) / cellHeight))
50
+ return { cols, rows }
51
+ }
52
+
53
+ const DEFAULT_THEME = {
54
+ background: '#0d1117',
55
+ foreground: '#e6edf3',
56
+ cursor: '#e6edf3',
57
+ selectionBackground: '#264f78',
58
+ black: '#484f58',
59
+ red: '#ff7b72',
60
+ green: '#3fb950',
61
+ yellow: '#d29922',
62
+ blue: '#58a6ff',
63
+ magenta: '#bc8cff',
64
+ cyan: '#39d2c0',
65
+ white: '#b1bac4',
66
+ brightBlack: '#6e7681',
67
+ brightRed: '#ffa198',
68
+ brightGreen: '#56d364',
69
+ brightYellow: '#e3b341',
70
+ brightBlue: '#79c0ff',
71
+ brightMagenta: '#d2a8ff',
72
+ brightCyan: '#56d4dd',
73
+ brightWhite: '#f0f6fc',
74
+ }
75
+
76
+ export default function TerminalWidget({ id, props, onUpdate, resizable }) {
77
+ const width = readProp(props, 'width', terminalSchema)
78
+ const height = readProp(props, 'height', terminalSchema)
79
+ const prettyName = props?.prettyName || null
80
+
81
+ const containerRef = useRef(null)
82
+ const termRef = useRef(null)
83
+ const terminalRef = useRef(null)
84
+ const wsRef = useRef(null)
85
+ const [ready, setReady] = useState(false)
86
+ const [error, setError] = useState(null)
87
+ const [sessionEnded, setSessionEnded] = useState(false)
88
+ const [connectAttempt, setConnectAttempt] = useState(0)
89
+
90
+ const handleResize = useCallback((w, h) => {
91
+ onUpdate?.({ width: w, height: h })
92
+ }, [onUpdate])
93
+
94
+ // Initialize terminal
95
+ useEffect(() => {
96
+ if (!containerRef.current) return
97
+
98
+ let disposed = false
99
+ let term = null
100
+ let ws = null
101
+
102
+ async function setup() {
103
+ try {
104
+ const ghostty = await loadGhostty()
105
+ if (disposed) return
106
+
107
+ const dims = calcDimensions(width, height)
108
+ const cfg = getTerminalConfig()
109
+
110
+ term = new ghostty.Terminal({
111
+ fontSize: cfg.fontSize ?? 13,
112
+ fontFamily: cfg.fontFamily ?? "'Ghostty', 'SF Mono', 'Menlo', 'Monaco', 'Courier New', monospace",
113
+ cursorBlink: true,
114
+ cursorStyle: 'bar',
115
+ cols: dims.cols,
116
+ rows: dims.rows,
117
+ theme: { ...DEFAULT_THEME, ...cfg.theme },
118
+ })
119
+
120
+ term.open(containerRef.current)
121
+ termRef.current = term
122
+
123
+ // Connect WebSocket
124
+ const url = getWsUrl(id, prettyName)
125
+ ws = new WebSocket(url)
126
+ wsRef.current = ws
127
+
128
+ ws.onopen = () => {
129
+ if (disposed) return
130
+ setReady(true)
131
+ ws.send(JSON.stringify({ type: 'resize', cols: dims.cols, rows: dims.rows }))
132
+ }
133
+
134
+ ws.onmessage = (e) => {
135
+ if (disposed) return
136
+ const data = typeof e.data === 'string' ? e.data : null
137
+ // Intercept JSON control messages from the server
138
+ if (data && data.startsWith('{')) {
139
+ try {
140
+ const msg = JSON.parse(data)
141
+ if (msg.type === 'session-info' || msg.type === 'conflict' || msg.type === 'detached') {
142
+ // Control message — don't render to terminal
143
+ return
144
+ }
145
+ } catch {
146
+ // Not valid JSON — pass through as terminal data
147
+ }
148
+ }
149
+ term.write(typeof e.data === 'string' ? e.data : new Uint8Array(e.data))
150
+ }
151
+
152
+ ws.onclose = () => {
153
+ if (disposed) return
154
+ setReady(false)
155
+ setSessionEnded(true)
156
+ }
157
+
158
+ ws.onerror = () => {
159
+ if (disposed) return
160
+ setReady(false)
161
+ setSessionEnded(true)
162
+ }
163
+
164
+ // Terminal input → WebSocket
165
+ term.onData((data) => {
166
+ if (ws.readyState === WebSocket.OPEN) {
167
+ ws.send(data)
168
+ }
169
+ })
170
+ } catch (err) {
171
+ if (!disposed) setError(err.message || 'Failed to load terminal')
172
+ }
173
+ }
174
+
175
+ setup()
176
+
177
+ return () => {
178
+ disposed = true
179
+ if (ws && ws.readyState <= WebSocket.OPEN) ws.close()
180
+ if (term) term.dispose()
181
+ termRef.current = null
182
+ wsRef.current = null
183
+ }
184
+ }, [id, connectAttempt])
185
+
186
+ // Resize terminal on dimension changes
187
+ useEffect(() => {
188
+ if (!termRef.current) return
189
+ const timer = setTimeout(() => {
190
+ const dims = calcDimensions(width, height)
191
+ termRef.current?.resize?.(dims.cols, dims.rows)
192
+ if (wsRef.current?.readyState === WebSocket.OPEN) {
193
+ wsRef.current.send(JSON.stringify({ type: 'resize', cols: dims.cols, rows: dims.rows }))
194
+ }
195
+ }, 50)
196
+ return () => clearTimeout(timer)
197
+ }, [width, height])
198
+
199
+ const handleClick = useCallback(() => {
200
+ if (sessionEnded) return
201
+ termRef.current?.focus()
202
+ }, [sessionEnded])
203
+
204
+ const [waking, setWaking] = useState(false)
205
+
206
+ const handleStartSession = useCallback(() => {
207
+ setWaking(true)
208
+ setTimeout(() => {
209
+ setWaking(false)
210
+ setSessionEnded(false)
211
+ setError(null)
212
+ setConnectAttempt(c => c + 1)
213
+ }, 1500)
214
+ }, [])
215
+
216
+ // Show interact gate when session is ready but not interacting
217
+
218
+ const titleLabel = `terminal · ${prettyName || '...'}`
219
+
220
+ return (
221
+ <div className={styles.container}>
222
+ <div className={styles.titleBar}>{titleLabel}</div>
223
+ <div
224
+ ref={terminalRef}
225
+ className={styles.terminal}
226
+ style={{
227
+ ...(typeof width === 'number' ? { width: `${width}px` } : undefined),
228
+ ...(typeof height === 'number' ? { height: `${height}px` } : undefined),
229
+ }}
230
+ onClick={handleClick}
231
+ >
232
+ {error && !sessionEnded && (
233
+ <div className={styles.error}>
234
+ <span>⚠ {error}</span>
235
+ </div>
236
+ )}
237
+ <div ref={containerRef} className={styles.xtermContainer} />
238
+ {sessionEnded && (
239
+ <div
240
+ className={overlayStyles.interactOverlay}
241
+ style={{ backgroundColor: '#0d1117', flexDirection: 'column', gap: 0 }}
242
+ onClick={handleStartSession}
243
+ role="button"
244
+ tabIndex={0}
245
+ aria-label="Start terminal session"
246
+ onKeyDown={(e) => { if (e.key === 'Enter') handleStartSession() }}
247
+ >
248
+ {!waking && (
249
+ <div className={styles.buddyZzz}>
250
+ <span className={styles.z1}>z</span>
251
+ <span className={styles.z2}>z</span>
252
+ <span className={styles.z3}>z</span>
253
+ </div>
254
+ )}
255
+ <span className={overlayStyles.interactHint}>
256
+ {waking ? 'Waking up...' : 'Start terminal session'}
257
+ </span>
258
+ </div>
259
+ )}
260
+ {!ready && !error && !sessionEnded && (
261
+ <div className={styles.loading}>Connecting…</div>
262
+ )}
263
+ </div>
264
+ {resizable && (
265
+ <ResizeHandle
266
+ targetRef={terminalRef}
267
+ onResize={handleResize}
268
+ minWidth={300}
269
+ minHeight={200}
270
+ />
271
+ )}
272
+ </div>
273
+ )
274
+ }
@@ -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
  >