@dfosco/storyboard 0.9.2 → 0.9.4

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 (35) hide show
  1. package/dist/storyboard-ui.css +1 -1
  2. package/dist/storyboard-ui.js +1 -10
  3. package/dist/storyboard-ui.js.map +1 -1
  4. package/dist/tailwind.css +1 -1
  5. package/package.json +1 -4
  6. package/scaffold/AGENTS.md +2 -0
  7. package/scaffold/skills/storyboard-core/SKILL.md +63 -171
  8. package/scaffold/skills/storyboard-widget/SKILL.md +172 -0
  9. package/scaffold/skills/tools/SKILL.md +12 -12
  10. package/src/core/canvas/collision.js +41 -19
  11. package/src/core/canvas/customWidgets.js +92 -0
  12. package/src/core/canvas/customWidgets.test.js +69 -0
  13. package/src/core/canvas/server.js +4 -3
  14. package/src/core/index.js +13 -0
  15. package/src/core/mountStoryboardCore.js +10 -0
  16. package/src/core/stores/configSchema.js +2 -1
  17. package/src/core/stores/widgetRegistry.js +184 -0
  18. package/src/core/stores/widgetRegistry.test.js +109 -0
  19. package/src/core/ui/CoreUIBar.jsx +1 -1
  20. package/src/core/ui-entry.js +0 -6
  21. package/src/core/vite/server-plugin.js +12 -0
  22. package/src/internals/BranchBar/BranchBar.jsx +27 -20
  23. package/src/internals/canvas/CanvasPage.bridge.test.jsx +2 -0
  24. package/src/internals/canvas/CanvasPage.dragdrop.test.jsx +2 -0
  25. package/src/internals/canvas/CanvasPage.jsx +19 -3
  26. package/src/internals/canvas/CanvasPage.multiselect.test.jsx +2 -0
  27. package/src/internals/canvas/widgets/WidgetChrome.jsx +50 -5
  28. package/src/internals/canvas/widgets/WidgetChrome.test.jsx +65 -0
  29. package/src/internals/canvas/widgets/index.js +18 -4
  30. package/src/internals/canvas/widgets/widgetConfig.js +202 -45
  31. package/src/internals/canvas/widgets/widgetConfig.merged.test.js +144 -0
  32. package/src/internals/context.jsx +1 -17
  33. package/src/core/ui/design-modes.ts +0 -7
  34. package/src/core/ui/viewfinder.ts +0 -7
  35. package/src/core/workshop/ui/mount.ts +0 -6
@@ -0,0 +1,184 @@
1
+ /**
2
+ * Widget Registry — consumer-supplied custom widget types.
3
+ *
4
+ * Consumers register widgets through `mountStoryboardCore({ widgets })`:
5
+ *
6
+ * mountStoryboardCore(config, {
7
+ * widgets: {
8
+ * 'my-thing': {
9
+ * component: MyThing,
10
+ * label: 'My Thing',
11
+ * icon: 'sparkle',
12
+ * chrome: { enabled: true }, // optional, default true
13
+ * interaction: {
14
+ * selectable: true, // default true
15
+ * movable: true, // default true
16
+ * resize: { enabled: true, prod: false }, // default disabled
17
+ * expandable: false,
18
+ * splitScreen: false,
19
+ * interactGate: false,
20
+ * interactGateLabel: 'Click to interact',
21
+ * },
22
+ * connectors: { anchors: {...}, accept: ['*'], exclude: [] },
23
+ * features: [ ... ], // toolbar actions
24
+ * unlisted: false, // hide from "+ Add widget"
25
+ * props: { width: { type: 'number', default: 400 }, ... },
26
+ * },
27
+ * },
28
+ * })
29
+ *
30
+ * Definitions registered through this store are merged with — and override —
31
+ * the built-in widget configs from `widgets.config.json` on a per-type basis.
32
+ *
33
+ * Built-in core widgets (sticky-note, markdown, image, etc.) ALSO live in
34
+ * this registry once `seedCoreWidgets()` runs at module load. Consumer
35
+ * overrides for those types replace the core entry entirely; if a consumer
36
+ * wants to extend a core widget they should re-declare the merged definition.
37
+ *
38
+ * Framework-agnostic (zero npm dependencies). React typing is structural —
39
+ * the `component` field is opaque from this store's perspective.
40
+ *
41
+ * @typedef {object} WidgetChromeOptions
42
+ * @property {boolean} [enabled] Render component inside WidgetChrome. Default: true.
43
+ * When false, the widget renders raw with no toolbar,
44
+ * no anchor ports, no select handle, no interact gate.
45
+ * Consumer owns 100% of the visible UI.
46
+ *
47
+ * @typedef {object} WidgetInteractionOptions
48
+ * @property {boolean} [selectable] Default: true. False → no select handle, single-click
49
+ * does not select.
50
+ * @property {boolean} [movable] Default: true. False → drag surface class is omitted,
51
+ * widget cannot be dragged.
52
+ * @property {{ enabled: boolean, prod?: boolean }} [resize]
53
+ * Default: disabled. Same shape as widgets.config.json.
54
+ * @property {boolean} [expandable] Default: false. Adds the "Expand" action.
55
+ * @property {boolean} [splitScreen] Default: false. Eligible as a split-screen pane.
56
+ * @property {boolean} [interactGate] Default: false. Overlays "Click to interact" until clicked.
57
+ * @property {string} [interactGateLabel] Default: 'Click to interact'.
58
+ *
59
+ * @typedef {object} WidgetDefinition
60
+ * @property {*} [component] React component or render function.
61
+ * Required for consumer widgets; not stored for
62
+ * core widgets when they fall back to widgets.config.json.
63
+ * @property {string} [label] Display label for menus and selectors.
64
+ * @property {string} [icon] Icon key from ICON_REGISTRY.
65
+ * @property {WidgetChromeOptions} [chrome]
66
+ * @property {WidgetInteractionOptions} [interaction]
67
+ * @property {object} [connectors] { anchors, accept, exclude, defaults }.
68
+ * @property {Array<object>} [features] Toolbar feature list.
69
+ * @property {boolean} [unlisted] Hide from "+ Add widget" menu.
70
+ * @property {Record<string, object>} [props] Prop schema.
71
+ *
72
+ * The following back-compat fields are still read (mirrors widgets.config.json
73
+ * legacy shape so an unchanged JSON entry continues to work after migration):
74
+ * @property {{ enabled: boolean, prod?: boolean }} [resize]
75
+ * @property {boolean} [expandable]
76
+ * @property {boolean} [splitScreen]
77
+ * @property {boolean} [interactGate]
78
+ * @property {string} [interactGateLabel]
79
+ */
80
+
81
+ /** @type {Map<string, WidgetDefinition>} */
82
+ let _registry = new Map()
83
+
84
+ /** @type {Set<Function>} */
85
+ const _listeners = new Set()
86
+
87
+ let _snapshotVersion = 0
88
+
89
+ /**
90
+ * Replace the entire registry from a consumer-supplied map.
91
+ * Pass null/undefined to clear.
92
+ * @param {Record<string, WidgetDefinition>|null|undefined} map
93
+ */
94
+ export function initWidgetRegistry(map) {
95
+ _registry = new Map()
96
+ if (map && typeof map === 'object') {
97
+ for (const [type, def] of Object.entries(map)) {
98
+ if (def && typeof def === 'object') {
99
+ _registry.set(type, def)
100
+ }
101
+ }
102
+ }
103
+ _notify()
104
+ }
105
+
106
+ /**
107
+ * Register a single widget type (consumer-side). Replaces any existing entry.
108
+ * @param {string} type
109
+ * @param {WidgetDefinition} definition
110
+ */
111
+ export function registerWidget(type, definition) {
112
+ if (!type || typeof type !== 'string') {
113
+ throw new Error('[storyboard] registerWidget: type must be a non-empty string')
114
+ }
115
+ if (!definition || typeof definition !== 'object') {
116
+ throw new Error(`[storyboard] registerWidget("${type}"): definition must be an object`)
117
+ }
118
+ _registry.set(type, definition)
119
+ _notify()
120
+ }
121
+
122
+ /**
123
+ * Remove a registry entry. Built-in widgets re-seeded via seedCoreWidgets()
124
+ * are unaffected; only the consumer override is removed.
125
+ * @param {string} type
126
+ */
127
+ export function unregisterWidget(type) {
128
+ if (_registry.delete(type)) _notify()
129
+ }
130
+
131
+ /**
132
+ * Get the registered definition for a widget type, or null.
133
+ * Consumers should prefer `getMergedWidgetDefinition()` which also folds in
134
+ * the static widgets.config.json shape.
135
+ * @param {string} type
136
+ * @returns {WidgetDefinition|null}
137
+ */
138
+ export function getWidgetDefinition(type) {
139
+ return _registry.get(type) || null
140
+ }
141
+
142
+ /**
143
+ * Snapshot of the entire registry as a plain object.
144
+ * @returns {Record<string, WidgetDefinition>}
145
+ */
146
+ export function getAllWidgetDefinitions() {
147
+ return Object.fromEntries(_registry)
148
+ }
149
+
150
+ /**
151
+ * Subscribe to registry changes. Returns unsubscribe fn.
152
+ * Compatible with `useSyncExternalStore`.
153
+ * @param {Function} callback
154
+ * @returns {Function}
155
+ */
156
+ export function subscribeToWidgetRegistry(callback) {
157
+ _listeners.add(callback)
158
+ return () => _listeners.delete(callback)
159
+ }
160
+
161
+ /**
162
+ * Snapshot version string for change detection.
163
+ * Compatible with `useSyncExternalStore`.
164
+ * @returns {string}
165
+ */
166
+ export function getWidgetRegistrySnapshot() {
167
+ return String(_snapshotVersion)
168
+ }
169
+
170
+ function _notify() {
171
+ _snapshotVersion++
172
+ for (const cb of _listeners) {
173
+ try { cb() } catch (err) {
174
+ console.error('[storyboard] Error in widgetRegistry subscriber:', err)
175
+ }
176
+ }
177
+ }
178
+
179
+ /** Reset all state. Only for tests. */
180
+ export function _resetWidgetRegistry() {
181
+ _registry = new Map()
182
+ _listeners.clear()
183
+ _snapshotVersion = 0
184
+ }
@@ -0,0 +1,109 @@
1
+ import { describe, it, expect, beforeEach } from 'vitest'
2
+ import {
3
+ initWidgetRegistry,
4
+ registerWidget,
5
+ unregisterWidget,
6
+ getWidgetDefinition,
7
+ getAllWidgetDefinitions,
8
+ subscribeToWidgetRegistry,
9
+ getWidgetRegistrySnapshot,
10
+ _resetWidgetRegistry,
11
+ } from './widgetRegistry.js'
12
+
13
+ describe('widgetRegistry', () => {
14
+ beforeEach(() => {
15
+ _resetWidgetRegistry()
16
+ })
17
+
18
+ it('starts empty', () => {
19
+ expect(getAllWidgetDefinitions()).toEqual({})
20
+ expect(getWidgetDefinition('anything')).toBeNull()
21
+ })
22
+
23
+ it('initWidgetRegistry seeds the store with valid entries', () => {
24
+ const Component = () => null
25
+ initWidgetRegistry({
26
+ 'my-thing': { component: Component, label: 'My Thing' },
27
+ 'other': { label: 'Other' },
28
+ })
29
+ expect(getWidgetDefinition('my-thing')).toEqual({ component: Component, label: 'My Thing' })
30
+ expect(getWidgetDefinition('other')).toEqual({ label: 'Other' })
31
+ expect(Object.keys(getAllWidgetDefinitions()).sort()).toEqual(['my-thing', 'other'])
32
+ })
33
+
34
+ it('initWidgetRegistry replaces previous entries', () => {
35
+ initWidgetRegistry({ a: { label: 'A' } })
36
+ initWidgetRegistry({ b: { label: 'B' } })
37
+ expect(getWidgetDefinition('a')).toBeNull()
38
+ expect(getWidgetDefinition('b')).toEqual({ label: 'B' })
39
+ })
40
+
41
+ it('initWidgetRegistry ignores null/undefined and non-object entries', () => {
42
+ initWidgetRegistry({
43
+ ok: { label: 'OK' },
44
+ nullish: null,
45
+ undef: undefined,
46
+ notObj: 'string',
47
+ })
48
+ expect(Object.keys(getAllWidgetDefinitions())).toEqual(['ok'])
49
+ })
50
+
51
+ it('initWidgetRegistry with null/undefined clears the store', () => {
52
+ initWidgetRegistry({ a: { label: 'A' } })
53
+ initWidgetRegistry(null)
54
+ expect(getAllWidgetDefinitions()).toEqual({})
55
+ })
56
+
57
+ it('registerWidget adds a single entry', () => {
58
+ registerWidget('foo', { label: 'Foo' })
59
+ expect(getWidgetDefinition('foo')).toEqual({ label: 'Foo' })
60
+ })
61
+
62
+ it('registerWidget replaces an existing entry', () => {
63
+ registerWidget('foo', { label: 'Foo' })
64
+ registerWidget('foo', { label: 'Foo2' })
65
+ expect(getWidgetDefinition('foo')).toEqual({ label: 'Foo2' })
66
+ })
67
+
68
+ it('registerWidget throws on invalid type', () => {
69
+ expect(() => registerWidget('', { label: 'X' })).toThrow(/non-empty string/)
70
+ expect(() => registerWidget(null, { label: 'X' })).toThrow(/non-empty string/)
71
+ })
72
+
73
+ it('registerWidget throws on invalid definition', () => {
74
+ expect(() => registerWidget('foo', null)).toThrow(/must be an object/)
75
+ expect(() => registerWidget('foo', 'string')).toThrow(/must be an object/)
76
+ })
77
+
78
+ it('unregisterWidget removes an entry', () => {
79
+ registerWidget('foo', { label: 'Foo' })
80
+ unregisterWidget('foo')
81
+ expect(getWidgetDefinition('foo')).toBeNull()
82
+ })
83
+
84
+ it('unregisterWidget on missing entry is a no-op', () => {
85
+ expect(() => unregisterWidget('nope')).not.toThrow()
86
+ })
87
+
88
+ it('subscribeToWidgetRegistry fires on init/register/unregister', () => {
89
+ let calls = 0
90
+ const unsubscribe = subscribeToWidgetRegistry(() => { calls++ })
91
+ initWidgetRegistry({ a: { label: 'A' } })
92
+ expect(calls).toBe(1)
93
+ registerWidget('b', { label: 'B' })
94
+ expect(calls).toBe(2)
95
+ unregisterWidget('a')
96
+ expect(calls).toBe(3)
97
+ unsubscribe()
98
+ registerWidget('c', { label: 'C' })
99
+ expect(calls).toBe(3)
100
+ })
101
+
102
+ it('getWidgetRegistrySnapshot returns a stable string per state', () => {
103
+ const a = getWidgetRegistrySnapshot()
104
+ expect(a).toBe(getWidgetRegistrySnapshot())
105
+ registerWidget('foo', { label: 'Foo' })
106
+ const b = getWidgetRegistrySnapshot()
107
+ expect(b).not.toBe(a)
108
+ })
109
+ })
@@ -116,7 +116,7 @@ function menuVisibleInMode(menu, mode) {
116
116
  return menu.modes.includes('*') || menu.modes.includes(mode)
117
117
  }
118
118
 
119
- // Subscribable mode state for useExternalStore (replaces Svelte modeState)
119
+ // Subscribable mode state for useExternalStore
120
120
  const modeState = {
121
121
  get mode() { return getCurrentMode() },
122
122
  get modes() { return getRegisteredModes() },
@@ -22,9 +22,3 @@ export { mountDevTools, unmountDevTools } from './devtools/devtools.js'
22
22
 
23
23
  // Comments UI
24
24
  export { mountComments } from './comments/ui/mount.js'
25
-
26
- // Viewfinder dashboard
27
- export { mountViewfinder, unmountViewfinder } from './ui/viewfinder.ts'
28
-
29
- // Design modes
30
- export { mountDesignModesUI as mountDesignModes } from './ui/design-modes.ts'
@@ -14,6 +14,7 @@ import fs from 'node:fs'
14
14
  import path from 'node:path'
15
15
  import { parse as parseJsonc } from 'jsonc-parser'
16
16
  import { getConfig } from '../stores/configSchema.js'
17
+ import { initServerWidgets } from '../canvas/customWidgets.js'
17
18
  import { createDevLogger, setDevLogger } from '../logger/devLogger.js'
18
19
  import { serverFeatures as workshopFeatures } from '../workshop/features/registry-server.js'
19
20
  import { docsHandler, collectFiles } from './docs-handler.js'
@@ -161,6 +162,17 @@ export default function storyboardServer() {
161
162
  base = viteConfig.base || '/'
162
163
  config = readConfig(root)
163
164
  isDev = viteConfig.command === 'serve'
165
+
166
+ // Seed the server-side custom-widget registry from the consumer's
167
+ // `storyboard.config.json.widgets` block. Consumer entries override
168
+ // built-in widget metadata for collision detection, default sizes,
169
+ // prompt-exec config, etc. Server-side does not load React components
170
+ // — those are seeded in the browser via `mountStoryboardCore({ widgets })`.
171
+ try {
172
+ initServerWidgets(config?.widgets || null)
173
+ } catch (err) {
174
+ console.warn('[storyboard] Failed to init custom widget registry:', err.message)
175
+ }
164
176
  },
165
177
 
166
178
  configureServer(server) {
@@ -11,6 +11,7 @@ import { useState, useEffect, useMemo } from 'react'
11
11
  import { createPortal } from 'react-dom'
12
12
  import { GitBranchIcon } from '@primer/octicons-react'
13
13
  import { useBranches } from './useBranches.js'
14
+ import ChromeSlot from '../../core/ui/ChromeSlot.jsx'
14
15
  import css from './BranchBar.module.css'
15
16
 
16
17
  /** Check if we're in local dev mode (respects ?prodMode simulation). */
@@ -78,28 +79,34 @@ export default function BranchBar({ basePath }) {
78
79
  }
79
80
 
80
81
  return createPortal(
81
- <div className={css.bar} data-branch-bar>
82
- <div
83
- className={`${css.barInner}${isLocalDev ? '' : ` ${css.barProd}`}`}
84
- style={domainColor ? { '--sb-branch-bar-bg': domainColor } : undefined}
85
- >
86
- <span className={css.barLabel}>
87
- {isLocalDev && window.__SB_DEV_DOMAIN__ && <>
88
- <span className={css.barDomainName}>⌘ {window.__SB_DEV_DOMAIN__}</span>
89
- <span className={css.barSeparator}>·</span>
90
- </>}
91
- <GitBranchIcon size={12} />
92
- <span className={css.barBranchName}>{currentBranch}</span>
93
- {isLocalDev && <>
94
- <span className={css.barSeparator}>·</span>
95
- <span>Local development</span>
96
- </>}
97
- </span>
98
- <div className={css.barActions}>
99
- <button className={css.barAction} onClick={hideChrome}>Hide</button>
82
+ <ChromeSlot id="branch-bar:root" surface="branch-bar" ctx={{ currentBranch, isLocalDev }}>
83
+ <div className={css.bar} data-branch-bar>
84
+ <div
85
+ className={`${css.barInner}${isLocalDev ? '' : ` ${css.barProd}`}`}
86
+ style={domainColor ? { '--sb-branch-bar-bg': domainColor } : undefined}
87
+ >
88
+ <ChromeSlot id="branch-bar:label" surface="branch-bar" ctx={{ currentBranch, isLocalDev }}>
89
+ <span className={css.barLabel}>
90
+ {isLocalDev && window.__SB_DEV_DOMAIN__ && <>
91
+ <span className={css.barDomainName}>⌘ {window.__SB_DEV_DOMAIN__}</span>
92
+ <span className={css.barSeparator}>·</span>
93
+ </>}
94
+ <GitBranchIcon size={12} />
95
+ <span className={css.barBranchName}>{currentBranch}</span>
96
+ {isLocalDev && <>
97
+ <span className={css.barSeparator}>·</span>
98
+ <span>Local development</span>
99
+ </>}
100
+ </span>
101
+ </ChromeSlot>
102
+ <div className={css.barActions}>
103
+ <ChromeSlot id="branch-bar:hide-button" surface="branch-bar" ctx={{ currentBranch, isLocalDev }}>
104
+ <button className={css.barAction} onClick={hideChrome}>Hide</button>
105
+ </ChromeSlot>
106
+ </div>
100
107
  </div>
101
108
  </div>
102
- </div>,
109
+ </ChromeSlot>,
103
110
  portalContainer
104
111
  )
105
112
  }
@@ -72,6 +72,8 @@ vi.mock('./widgets/widgetConfig.js', async () => {
72
72
  getAnchorState: () => ({}),
73
73
  canAcceptConnection: () => false,
74
74
  isSplitScreenCapable: () => false,
75
+ getChromeOptions: () => ({ enabled: true }),
76
+ getInteractionOptions: () => ({ selectable: true, movable: true, resize: null, expandable: false, splitScreen: false, interactGate: false, interactGateLabel: 'Click to interact' }),
75
77
  schemas: {},
76
78
  getMenuWidgetTypes: () => [],
77
79
  getConnectorDefaults: actual.getConnectorDefaults,
@@ -57,6 +57,8 @@ vi.mock('./widgets/widgetConfig.js', async () => {
57
57
  getAnchorState: () => ({}),
58
58
  canAcceptConnection: () => false,
59
59
  isSplitScreenCapable: () => false,
60
+ getChromeOptions: () => ({ enabled: true }),
61
+ getInteractionOptions: () => ({ selectable: true, movable: true, resize: null, expandable: false, splitScreen: false, interactGate: false, interactGateLabel: 'Click to interact' }),
60
62
  schemas: {},
61
63
  getMenuWidgetTypes: () => [],
62
64
  getConnectorDefaults: actual.getConnectorDefaults,
@@ -6,7 +6,7 @@ import { shouldPreventCanvasTextSelection } from './textSelection.js'
6
6
  import { getCanvasThemeVars, getCanvasPrimerAttrs } from './canvasTheme.js'
7
7
  import { getWidgetComponent } from './widgets/index.js'
8
8
  import { schemas, getDefaults } from './widgets/widgetProps.js'
9
- import { getFeatures, isResizable, isExpandable, getAnchorState, canAcceptConnection, isSplitScreenCapable } from './widgets/widgetConfig.js'
9
+ import { getFeatures, isResizable, isExpandable, getAnchorState, canAcceptConnection, isSplitScreenCapable, getChromeOptions, getInteractionOptions } from './widgets/widgetConfig.js'
10
10
  import { createPasteContext, resolvePaste } from './widgets/pasteRules.js'
11
11
  import { getPasteRules } from '../../core/index.js'
12
12
  import { isTerminalResizable, getTerminalDimensions } from '../../core/index.js'
@@ -346,7 +346,7 @@ function computeCanvasBounds(widgets, componentEntries) {
346
346
  }
347
347
 
348
348
  /** Renders a single JSON-defined widget by type lookup. */
349
- function WidgetRenderer({ widget, onUpdate, widgetRef, onRefreshGitHub, canRefreshGitHub, multiSelected }) {
349
+ function WidgetRenderer({ widget, onUpdate, widgetRef, onRefreshGitHub, canRefreshGitHub, multiSelected, selected, onSelect }) {
350
350
  const Component = getWidgetComponent(widget.type)
351
351
  if (!Component) {
352
352
  console.warn(`[canvas] Unknown widget type: ${widget.type}`)
@@ -355,7 +355,10 @@ function WidgetRenderer({ widget, onUpdate, widgetRef, onRefreshGitHub, canRefre
355
355
  const resizable = (widget.type === 'terminal' || widget.type === 'agent')
356
356
  ? isTerminalResizable(widget.props?.agentId) && !!onUpdate
357
357
  : isResizable(widget.type) && !!onUpdate
358
- // Only pass ref to forwardRef-wrapped components (e.g. PrototypeEmbed)
358
+ // Standard contract: every custom widget component receives these props.
359
+ // selected / onSelect are especially important for chrome-disabled custom
360
+ // widgets that own their visible UI and need to render their own select
361
+ // affordance.
359
362
  const elementProps = {
360
363
  id: widget.id,
361
364
  props: widget.props,
@@ -364,6 +367,8 @@ function WidgetRenderer({ widget, onUpdate, widgetRef, onRefreshGitHub, canRefre
364
367
  onRefreshGitHub,
365
368
  canRefreshGitHub,
366
369
  multiSelected,
370
+ selected,
371
+ onSelect,
367
372
  }
368
373
  if (Component.$$typeof === Symbol.for('react.forward_ref')) {
369
374
  elementProps.ref = widgetRef
@@ -536,6 +541,12 @@ const ChromeWrappedWidget = memo(function ChromeWrappedWidget({
536
541
  onUpdate?.(widget.id, updates)
537
542
  }, [onUpdate, widget.id])
538
543
 
544
+ // Per-widget chrome + interaction switches from the (consumer-merged)
545
+ // widget definition. Defaults match historical behavior, so core widgets
546
+ // (sticky-note, image, terminal, etc.) are unaffected.
547
+ const chromeOpts = useMemo(() => getChromeOptions(widget.type), [widget.type])
548
+ const interactionOpts = useMemo(() => getInteractionOptions(widget.type), [widget.type])
549
+
539
550
  return (
540
551
  <WidgetChrome
541
552
  widgetId={widget.id}
@@ -554,6 +565,9 @@ const ChromeWrappedWidget = memo(function ChromeWrappedWidget({
554
565
  currentRole={widget.props?.role || defaultHubRole}
555
566
  onRoleChange={onRoleChange ? (roleId) => onRoleChange(widget.id, roleId) : undefined}
556
567
  readOnly={readOnly}
568
+ chromeEnabled={chromeOpts.enabled}
569
+ movable={interactionOpts.movable}
570
+ selectable={interactionOpts.selectable}
557
571
  >
558
572
  <WidgetRenderer
559
573
  widget={widget}
@@ -562,6 +576,8 @@ const ChromeWrappedWidget = memo(function ChromeWrappedWidget({
562
576
  onRefreshGitHub={onRefreshGitHub}
563
577
  canRefreshGitHub={canRefreshGitHub}
564
578
  multiSelected={multiSelected}
579
+ selected={selected}
580
+ onSelect={onSelect}
565
581
  />
566
582
  </WidgetChrome>
567
583
  )
@@ -100,6 +100,8 @@ vi.mock('./widgets/widgetConfig.js', async () => {
100
100
  isExpandable: () => false,
101
101
  isSplitScreenCapable: () => false,
102
102
  getInteractGate: () => ({ enabled: false, label: 'Click to interact' }),
103
+ getChromeOptions: () => ({ enabled: true }),
104
+ getInteractionOptions: () => ({ selectable: true, movable: true, resize: null, expandable: false, splitScreen: false, interactGate: false, interactGateLabel: 'Click to interact' }),
103
105
  getWidgetMeta: () => null,
104
106
  getConnectorConfig: actual.getConnectorConfig,
105
107
  getAnchorState: actual.getAnchorState,
@@ -344,6 +344,25 @@ function ColorPickerFeature({ currentColor, options, onColorChange }) {
344
344
  * useImperativeHandle(ref, () => ({ handleAction(actionId) { ... } }))
345
345
  * WidgetChrome will call widgetRef.current.handleAction(actionId) for
346
346
  * non-standard actions (anything other than 'delete').
347
+ *
348
+ * ## Per-widget chrome / interaction switches
349
+ *
350
+ * Three booleans (read from the widget definition by CanvasPage) gate the
351
+ * built-in chrome surface. Defaults match historical behavior so existing
352
+ * widgets are unaffected.
353
+ *
354
+ * - `chromeEnabled` (default true) — When false, the entire WidgetChrome
355
+ * wrapper short-circuits and renders `children` inside a minimal
356
+ * `<div data-widget-id>` shell. No toolbar, no anchors, no select handle,
357
+ * no interact gate. Use this for fully custom client widgets that own
358
+ * their own visual chrome.
359
+ * - `movable` (default true) — When false, the `tc-drag-surface` class is
360
+ * omitted from the inner slot so tiny-canvas won't treat the widget as
361
+ * draggable. Useful for pinned/locked widgets (background stickers,
362
+ * decorative chrome).
363
+ * - `selectable` (default true) — When false, the select handle button is
364
+ * not rendered and select-via-handle is unavailable. Canvas-level click
365
+ * selection (configured elsewhere) is independent of this flag.
347
366
  */
348
367
  export default function WidgetChrome({
349
368
  widgetId,
@@ -363,6 +382,9 @@ export default function WidgetChrome({
363
382
  onRoleChange,
364
383
  children,
365
384
  readOnly = false,
385
+ chromeEnabled = true,
386
+ movable = true,
387
+ selectable = true,
366
388
  }) {
367
389
  const [hovered, setHovered] = useState(false)
368
390
  const altHeld = useAltKey()
@@ -461,6 +483,29 @@ export default function WidgetChrome({
461
483
  onSelect?.()
462
484
  }, [onSelect])
463
485
 
486
+ // ── chrome.enabled: false short-circuit ──
487
+ // Render the widget bare with just a minimal positioning shell. Keep
488
+ // data-widget-id so DOM lookups (anchor port queries, scroll-to-widget,
489
+ // selection state propagation) still work. All hooks above remain in
490
+ // play so React's hook ordering is stable regardless of this flag.
491
+ if (!chromeEnabled) {
492
+ return (
493
+ <div
494
+ className={styles.chromeContainer}
495
+ data-widget-id={widgetId}
496
+ data-widget-chrome-disabled
497
+ data-widget-selected={selected || undefined}
498
+ >
499
+ {children}
500
+ </div>
501
+ )
502
+ }
503
+
504
+ // Drag surface class is omitted when `movable: false` — tiny-canvas only
505
+ // initiates a drag from elements matching `data-tc-handle=".tc-drag-handle,
506
+ // .tc-drag-surface"`, so dropping the class effectively pins the widget.
507
+ const slotDragClass = movable ? 'tc-drag-surface' : ''
508
+
464
509
  return (
465
510
  <div
466
511
  className={styles.chromeContainer}
@@ -470,7 +515,7 @@ export default function WidgetChrome({
470
515
  onMouseEnter={(readOnly && !hasFeatures) ? undefined : handleMouseEnter}
471
516
  onMouseLeave={(readOnly && !hasFeatures) ? undefined : handleMouseLeave}
472
517
  >
473
- <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}>
518
+ <div ref={slotRef} className={`${slotDragClass} ${styles.widgetSlot} ${selected ? styles.widgetSlotSelected : ''} ${multiSelected ? styles.widgetSlotMultiSelected : ''}`} data-widget-selected={selected || undefined} data-widget-interacting={interacting || undefined}>
474
519
  {children}
475
520
  {gate.enabled && !interacting && !readOnly && (
476
521
  <div
@@ -645,12 +690,12 @@ export default function WidgetChrome({
645
690
  </div>
646
691
  )}
647
692
 
648
- {!readOnly && (
649
- <Tooltip text={selected ? "Click and drag to move" : "Select"} direction="n">
693
+ {!readOnly && selectable && (
694
+ <Tooltip text={selected ? (movable ? "Click and drag to move" : "Selected") : "Select"} direction="n">
650
695
  <button
651
- className={`tc-drag-handle ${styles.selectHandle} ${selected ? styles.selectHandleActive : ''}`}
696
+ className={`${movable ? 'tc-drag-handle ' : ''}${styles.selectHandle} ${selected ? styles.selectHandleActive : ''}`}
652
697
  onClick={handleHandleClick}
653
- aria-label={selected ? "Drag to move widget" : "Select widget"}
698
+ aria-label={selected ? (movable ? "Drag to move widget" : "Widget selected") : "Select widget"}
654
699
  aria-pressed={selected}
655
700
  />
656
701
  </Tooltip>
@@ -0,0 +1,65 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { render } from '@testing-library/react'
3
+ import WidgetChrome from './WidgetChrome.jsx'
4
+
5
+ function renderChrome(extra = {}) {
6
+ return render(
7
+ <WidgetChrome widgetId="w1" widgetType="sticky-note" {...extra}>
8
+ <div data-testid="inner">inner</div>
9
+ </WidgetChrome>
10
+ )
11
+ }
12
+
13
+ describe('WidgetChrome chrome/interaction switches', () => {
14
+ it('renders the full chrome shell by default', () => {
15
+ const { container, getByTestId } = renderChrome()
16
+ expect(getByTestId('inner')).toBeTruthy()
17
+ expect(container.querySelector('[data-widget-id="w1"]')).toBeTruthy()
18
+ // Drag surface is present (allows tiny-canvas drag) and select handle exists
19
+ expect(container.querySelector('.tc-drag-surface')).toBeTruthy()
20
+ expect(container.querySelector('.tc-drag-handle')).toBeTruthy()
21
+ // Not flagged as chrome-disabled
22
+ expect(container.querySelector('[data-widget-chrome-disabled]')).toBeNull()
23
+ })
24
+
25
+ it('chromeEnabled=false short-circuits to a minimal shell', () => {
26
+ const { container, getByTestId } = renderChrome({ chromeEnabled: false })
27
+ expect(getByTestId('inner')).toBeTruthy()
28
+ const root = container.querySelector('[data-widget-id="w1"]')
29
+ expect(root).toBeTruthy()
30
+ expect(root.hasAttribute('data-widget-chrome-disabled')).toBe(true)
31
+ // No toolbar, no anchors, no select handle
32
+ expect(container.querySelector('.tc-drag-surface')).toBeNull()
33
+ expect(container.querySelector('.tc-drag-handle')).toBeNull()
34
+ // Inner content rendered directly
35
+ expect(root.contains(getByTestId('inner'))).toBe(true)
36
+ })
37
+
38
+ it('chromeEnabled=false propagates selected state for custom widgets', () => {
39
+ const { container } = renderChrome({ chromeEnabled: false, selected: true })
40
+ const root = container.querySelector('[data-widget-id="w1"]')
41
+ expect(root.getAttribute('data-widget-selected')).toBe('true')
42
+ })
43
+
44
+ it('movable=false omits the tc-drag-surface class from the slot', () => {
45
+ const { container } = renderChrome({ movable: false })
46
+ expect(container.querySelector('.tc-drag-surface')).toBeNull()
47
+ // Select handle still renders (selectable default true) and is no longer
48
+ // a drag handle — should NOT carry tc-drag-handle.
49
+ const handle = container.querySelector('button[aria-label*="Select"], button[aria-label*="selected"], button[aria-pressed]')
50
+ expect(handle).toBeTruthy()
51
+ expect(handle.className.includes('tc-drag-handle')).toBe(false)
52
+ })
53
+
54
+ it('selectable=false hides the select handle', () => {
55
+ const { container } = renderChrome({ selectable: false })
56
+ // The select-handle button has aria-pressed; no other button in chrome
57
+ // exposes aria-pressed by default (color picker, etc. use aria-label only)
58
+ expect(container.querySelector('button[aria-pressed]')).toBeNull()
59
+ })
60
+
61
+ it('chromeEnabled=false skips select handle even when selectable=true', () => {
62
+ const { container } = renderChrome({ chromeEnabled: false, selectable: true })
63
+ expect(container.querySelector('button[aria-pressed]')).toBeNull()
64
+ })
65
+ })