@dfosco/storyboard-react 4.2.0-beta.3 → 4.2.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.
Files changed (89) hide show
  1. package/package.json +10 -11
  2. package/src/AuthModal/AuthModal.jsx +6 -8
  3. package/src/BranchBar/BranchBar.jsx +20 -6
  4. package/src/BranchBar/BranchBar.module.css +13 -4
  5. package/src/BranchBar/useBranches.js +20 -6
  6. package/src/BranchBar/useBranches.test.js +68 -0
  7. package/src/CommandPalette/CommandPalette.jsx +480 -187
  8. package/src/CommandPalette/command-palette.css +142 -78
  9. package/src/Icon.jsx +157 -58
  10. package/src/Viewfinder.jsx +562 -207
  11. package/src/Viewfinder.module.css +434 -93
  12. package/src/Workspace.jsx +7 -0
  13. package/src/canvas/CanvasPage.bridge.test.jsx +14 -6
  14. package/src/canvas/CanvasPage.dragdrop.test.jsx +11 -7
  15. package/src/canvas/CanvasPage.jsx +739 -219
  16. package/src/canvas/CanvasPage.module.css +13 -15
  17. package/src/canvas/CanvasPage.multiselect.test.jsx +17 -6
  18. package/src/canvas/ConnectorLayer.jsx +121 -165
  19. package/src/canvas/ConnectorLayer.module.css +69 -0
  20. package/src/canvas/PageSelector.test.jsx +15 -6
  21. package/src/canvas/canvasApi.js +68 -2
  22. package/src/canvas/canvasReloadGuard.test.js +1 -1
  23. package/src/canvas/connectorGeometry.js +132 -0
  24. package/src/canvas/hotPoolDevLogs.js +25 -0
  25. package/src/canvas/useCanvas.js +1 -1
  26. package/src/canvas/useMarqueeSelect.js +30 -4
  27. package/src/canvas/widgets/CodePenEmbed.jsx +1 -0
  28. package/src/canvas/widgets/ComponentSetWidget.jsx +199 -0
  29. package/src/canvas/widgets/ComponentSetWidget.module.css +89 -0
  30. package/src/canvas/widgets/ComponentWidget.jsx +1 -0
  31. package/src/canvas/widgets/CropOverlay.jsx +219 -0
  32. package/src/canvas/widgets/CropOverlay.module.css +118 -0
  33. package/src/canvas/widgets/ExpandedPane.jsx +474 -0
  34. package/src/canvas/widgets/ExpandedPane.module.css +179 -0
  35. package/src/canvas/widgets/ExpandedPane.test.jsx +240 -0
  36. package/src/canvas/widgets/ExpandedPaneTopBar.jsx +111 -0
  37. package/src/canvas/widgets/ExpandedPaneTopBar.module.css +59 -0
  38. package/src/canvas/widgets/ExpandedPaneTopBar.test.jsx +45 -0
  39. package/src/canvas/widgets/FigmaEmbed.jsx +62 -47
  40. package/src/canvas/widgets/FigmaEmbed.module.css +61 -0
  41. package/src/canvas/widgets/ImageWidget.jsx +130 -9
  42. package/src/canvas/widgets/ImageWidget.module.css +30 -0
  43. package/src/canvas/widgets/LinkPreview.jsx +113 -5
  44. package/src/canvas/widgets/LinkPreview.module.css +127 -0
  45. package/src/canvas/widgets/MarkdownBlock.jsx +167 -17
  46. package/src/canvas/widgets/MarkdownBlock.module.css +148 -0
  47. package/src/canvas/widgets/PromptWidget.jsx +414 -0
  48. package/src/canvas/widgets/PromptWidget.module.css +273 -0
  49. package/src/canvas/widgets/PrototypeEmbed.jsx +77 -39
  50. package/src/canvas/widgets/PrototypeEmbed.module.css +117 -0
  51. package/src/canvas/widgets/PrototypeEmbed.test.jsx +2 -2
  52. package/src/canvas/widgets/ResizeHandle.jsx +17 -6
  53. package/src/canvas/widgets/StoryWidget.jsx +73 -15
  54. package/src/canvas/widgets/TerminalReadWidget.jsx +146 -0
  55. package/src/canvas/widgets/TerminalReadWidget.module.css +94 -0
  56. package/src/canvas/widgets/TerminalWidget.jsx +445 -67
  57. package/src/canvas/widgets/TerminalWidget.module.css +271 -8
  58. package/src/canvas/widgets/TilesWidget.jsx +300 -0
  59. package/src/canvas/widgets/TilesWidget.module.css +133 -0
  60. package/src/canvas/widgets/WidgetChrome.jsx +74 -153
  61. package/src/canvas/widgets/WidgetChrome.module.css +30 -1
  62. package/src/canvas/widgets/embedInteraction.test.jsx +24 -26
  63. package/src/canvas/widgets/expandUtils.js +560 -0
  64. package/src/canvas/widgets/expandUtils.test.js +155 -0
  65. package/src/canvas/widgets/index.js +9 -0
  66. package/src/canvas/widgets/snapshotDisplay.test.jsx +23 -71
  67. package/src/canvas/widgets/tilePool.js +23 -0
  68. package/src/canvas/widgets/tiles/diagonal-bl.png +0 -0
  69. package/src/canvas/widgets/tiles/diagonal-br.png +0 -0
  70. package/src/canvas/widgets/tiles/diagonal-tl.png +0 -0
  71. package/src/canvas/widgets/tiles/leaf.png +0 -0
  72. package/src/canvas/widgets/tiles/quarter-tl.png +0 -0
  73. package/src/canvas/widgets/tiles/quarter-tr.png +0 -0
  74. package/src/canvas/widgets/tiles/solid-a.png +0 -0
  75. package/src/canvas/widgets/tiles/solid-b.png +0 -0
  76. package/src/canvas/widgets/widgetConfig.js +55 -4
  77. package/src/canvas/widgets/widgetIcons.jsx +190 -0
  78. package/src/canvas/widgets/widgetProps.js +1 -0
  79. package/src/context.jsx +48 -20
  80. package/src/hooks/useConfig.js +14 -0
  81. package/src/hooks/usePrototypeReloadGuard.js +64 -0
  82. package/src/hooks/useSceneData.js +1 -0
  83. package/src/hooks/useThemeState.test.js +1 -1
  84. package/src/index.js +8 -2
  85. package/src/story/ComponentSetPage.jsx +186 -0
  86. package/src/story/ComponentSetPage.module.css +121 -0
  87. package/src/story/StoryPage.jsx +32 -2
  88. package/src/vite/data-plugin.js +363 -67
  89. package/src/vite/data-plugin.test.js +1 -1
@@ -0,0 +1,133 @@
1
+ .container {
2
+ padding: 8px;
3
+ user-select: none;
4
+ cursor: default;
5
+ }
6
+
7
+ .toolbar {
8
+ display: flex;
9
+ align-items: center;
10
+ gap: 4px;
11
+ padding: 4px 0 8px;
12
+ }
13
+
14
+ .toolbarBtn {
15
+ all: unset;
16
+ display: inline-flex;
17
+ align-items: center;
18
+ justify-content: center;
19
+ width: 28px;
20
+ height: 28px;
21
+ border-radius: 6px;
22
+ font-size: 14px;
23
+ cursor: pointer;
24
+ color: var(--fgColor-default, #1f2328);
25
+ background: var(--bgColor-muted, #f6f8fa);
26
+ transition: background 0.12s ease;
27
+ }
28
+
29
+ .toolbarBtn:hover {
30
+ background: var(--bgColor-neutral-muted, #d0d7de);
31
+ }
32
+
33
+ .toolbarBtn:disabled {
34
+ opacity: 0.35;
35
+ cursor: default;
36
+ }
37
+
38
+ .toolbarLabel {
39
+ font-size: 12px;
40
+ font-weight: 600;
41
+ color: var(--fgColor-muted, #656d76);
42
+ min-width: 32px;
43
+ text-align: center;
44
+ }
45
+
46
+ .toolbarSep {
47
+ width: 1px;
48
+ height: 16px;
49
+ background: var(--borderColor-muted, #d0d7de);
50
+ margin: 0 4px;
51
+ }
52
+
53
+ .grid {
54
+ display: grid;
55
+ border-radius: 6px;
56
+ overflow: hidden;
57
+ }
58
+
59
+ .tile {
60
+ position: relative;
61
+ overflow: hidden;
62
+ cursor: pointer;
63
+ border: 2px solid transparent;
64
+ border-radius: 4px;
65
+ transition: border-color 0.12s ease, opacity 0.15s ease, transform 0.12s ease;
66
+ background: var(--bgColor-muted, #f6f8fa);
67
+ }
68
+
69
+ .tile:hover {
70
+ border-color: var(--borderColor-accent-emphasis, #0969da);
71
+ }
72
+
73
+ .selected {
74
+ border-color: var(--fgColor-accent, #0969da);
75
+ box-shadow: 0 0 0 2px var(--fgColor-accent, #0969da);
76
+ }
77
+
78
+ .pasteTarget {
79
+ border-color: var(--fgColor-success, #1a7f37);
80
+ box-shadow: 0 0 0 2px var(--fgColor-success, #1a7f37);
81
+ animation: pulse 1s infinite;
82
+ }
83
+
84
+ .dragOver {
85
+ border-color: var(--fgColor-accent, #0969da);
86
+ background: var(--bgColor-accent-muted, #ddf4ff);
87
+ transform: scale(1.04);
88
+ }
89
+
90
+ .dragging {
91
+ opacity: 0.4;
92
+ }
93
+
94
+ .tileImage {
95
+ width: 100%;
96
+ height: 100%;
97
+ object-fit: cover;
98
+ display: block;
99
+ pointer-events: none;
100
+ }
101
+
102
+ .emptyTile {
103
+ display: block;
104
+ width: 100%;
105
+ height: 100%;
106
+ background: var(--bgColor-muted, #f6f8fa);
107
+ }
108
+
109
+ .pasteHint {
110
+ position: absolute;
111
+ inset: 0;
112
+ display: flex;
113
+ align-items: center;
114
+ justify-content: center;
115
+ background: rgba(0, 0, 0, 0.45);
116
+ color: white;
117
+ font-size: 11px;
118
+ font-weight: 600;
119
+ letter-spacing: 0.5px;
120
+ pointer-events: none;
121
+ }
122
+
123
+ .hint {
124
+ padding: 6px 0 2px;
125
+ text-align: center;
126
+ font-size: 11px;
127
+ color: var(--fgColor-muted, #656d76);
128
+ }
129
+
130
+ @keyframes pulse {
131
+ 0%, 100% { box-shadow: 0 0 0 2px var(--fgColor-success, #1a7f37); }
132
+ 50% { box-shadow: 0 0 0 4px var(--fgColor-success, #1a7f37); }
133
+ }
@@ -1,7 +1,7 @@
1
1
  import { useState, useCallback, useRef, useEffect, useSyncExternalStore } from 'react'
2
2
  import { Tooltip } from '@primer/react'
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
3
  import { getConnectorConfig, getInteractGate } from './widgetConfig.js'
4
+ import { ICON_REGISTRY } from './widgetIcons.jsx'
5
5
  import styles from './WidgetChrome.module.css'
6
6
  import overlayStyles from './embedOverlay.module.css'
7
7
 
@@ -14,75 +14,6 @@ const STICKY_NOTE_COLORS = {
14
14
  orange: { bg: '#fff1e5', border: '#d18616', dot: '#e8a844' },
15
15
  }
16
16
 
17
- function DeleteIcon() {
18
- return (
19
- <svg width="12" height="12" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
20
- <path d="M11 1.75V3h2.25a.75.75 0 0 1 0 1.5H2.75a.75.75 0 0 1 0-1.5H5V1.75C5 .784 5.784 0 6.75 0h2.5C10.216 0 11 .784 11 1.75ZM4.496 6.675l.66 6.6a.25.25 0 0 0 .249.225h5.19a.25.25 0 0 0 .249-.225l.66-6.6a.75.75 0 0 1 1.492.15l-.66 6.6A1.748 1.748 0 0 1 10.595 15h-5.19a1.75 1.75 0 0 1-1.741-1.575l-.66-6.6a.75.75 0 1 1 1.492-.15ZM6.5 1.75V3h3V1.75a.25.25 0 0 0-.25-.25h-2.5a.25.25 0 0 0-.25.25Z" />
21
- </svg>
22
- )
23
- }
24
-
25
- function ZoomInIcon() {
26
- return (
27
- <svg width="12" height="12" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
28
- <path d="M7.75 2a.75.75 0 0 1 .75.75V7h4.25a.75.75 0 0 1 0 1.5H8.5v4.25a.75.75 0 0 1-1.5 0V8.5H2.75a.75.75 0 0 1 0-1.5H7V2.75A.75.75 0 0 1 7.75 2Z" />
29
- </svg>
30
- )
31
- }
32
-
33
- function ZoomOutIcon() {
34
- return (
35
- <svg width="12" height="12" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
36
- <path d="M2.75 7.25h10.5a.75.75 0 0 1 0 1.5H2.75a.75.75 0 0 1 0-1.5Z" />
37
- </svg>
38
- )
39
- }
40
-
41
- function EditIcon() {
42
- return (
43
- <svg width="12" height="12" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
44
- <path d="M11.013 1.427a1.75 1.75 0 0 1 2.474 0l1.086 1.086a1.75 1.75 0 0 1 0 2.474l-8.61 8.61c-.21.21-.47.364-.756.445l-3.251.93a.75.75 0 0 1-.927-.928l.929-3.25c.081-.286.235-.547.445-.758l8.61-8.61Zm.176 4.823L9.75 4.81l-6.286 6.287a.253.253 0 0 0-.064.108l-.558 1.953 1.953-.558a.253.253 0 0 0 .108-.064Zm1.238-3.763a.25.25 0 0 0-.354 0L10.811 3.75l1.439 1.44 1.263-1.263a.25.25 0 0 0 0-.354Z" />
45
- </svg>
46
- )
47
- }
48
-
49
- function OpenExternalIcon() {
50
- return (
51
- <svg width="12" height="12" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
52
- <path d="M3.75 2h3.5a.75.75 0 0 1 0 1.5h-3.5a.25.25 0 0 0-.25.25v8.5c0 .138.112.25.25.25h8.5a.25.25 0 0 0 .25-.25v-3.5a.75.75 0 0 1 1.5 0v3.5A1.75 1.75 0 0 1 12.25 14h-8.5A1.75 1.75 0 0 1 2 12.25v-8.5C2 2.784 2.784 2 3.75 2Zm6.854-1h4.146a.25.25 0 0 1 .25.25v4.146a.25.25 0 0 1-.427.177L13.03 4.03 9.28 7.78a.751.751 0 0 1-1.042-.018.751.751 0 0 1-.018-1.042l3.75-3.75-1.543-1.543A.25.25 0 0 1 10.604 1Z" />
53
- </svg>
54
- )
55
- }
56
-
57
- function EyeIcon() {
58
- return <OcticonEye size={12} />
59
- }
60
-
61
- function EyeClosedIcon() {
62
- return <OcticonEyeClosed size={12} />
63
- }
64
-
65
- function CodeIcon() {
66
- return <OcticonCode size={12} />
67
- }
68
-
69
- function UnwrapIcon() {
70
- return <OcticonUnwrap size={12} />
71
- }
72
-
73
- function ImageIcon() {
74
- return <OcticonImage size={12} />
75
- }
76
-
77
- function CopyIcon() {
78
- return (
79
- <svg width="12" height="12" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
80
- <path d="M0 6.75C0 5.784.784 5 1.75 5h1.5a.75.75 0 0 1 0 1.5h-1.5a.25.25 0 0 0-.25.25v7.5c0 .138.112.25.25.25h7.5a.25.25 0 0 0 .25-.25v-1.5a.75.75 0 0 1 1.5 0v1.5A1.75 1.75 0 0 1 9.25 16h-7.5A1.75 1.75 0 0 1 0 14.25Z" />
81
- <path d="M5 1.75C5 .784 5.784 0 6.75 0h7.5C15.216 0 16 .784 16 1.75v7.5A1.75 1.75 0 0 1 14.25 11h-7.5A1.75 1.75 0 0 1 5 9.25Zm1.75-.25a.25.25 0 0 0-.25.25v7.5c0 .138.112.25.25.25h7.5a.25.25 0 0 0 .25-.25v-7.5a.25.25 0 0 0-.25-.25Z" />
82
- </svg>
83
- )
84
- }
85
-
86
17
  function MoreIcon() {
87
18
  return (
88
19
  <svg width="12" height="12" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
@@ -91,14 +22,6 @@ function MoreIcon() {
91
22
  )
92
23
  }
93
24
 
94
- function LinkIcon() {
95
- return (
96
- <svg width="12" height="12" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
97
- <path d="m7.775 3.275 1.25-1.25a3.5 3.5 0 1 1 4.95 4.95l-2.5 2.5a3.5 3.5 0 0 1-4.95 0 .751.751 0 0 1 .018-1.042.751.751 0 0 1 1.042-.018 1.998 1.998 0 0 0 2.83 0l2.5-2.5a2.002 2.002 0 0 0-2.83-2.83l-1.25 1.25a.751.751 0 0 1-1.042-.018.751.751 0 0 1-.018-1.042Zm-4.69 9.64a1.998 1.998 0 0 0 2.83 0l1.25-1.25a.751.751 0 0 1 1.042.018.751.751 0 0 1 .018 1.042l-1.25 1.25a3.5 3.5 0 1 1-4.95-4.95l2.5-2.5a3.5 3.5 0 0 1 4.95 0 .751.751 0 0 1-.018 1.042.751.751 0 0 1-1.042.018 1.998 1.998 0 0 0-2.83 0l-2.5 2.5a1.998 1.998 0 0 0 0 2.83Z" />
98
- </svg>
99
- )
100
- }
101
-
102
25
  function ChevronDownIcon() {
103
26
  return (
104
27
  <svg width="12" height="12" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
@@ -107,62 +30,6 @@ function ChevronDownIcon() {
107
30
  )
108
31
  }
109
32
 
110
- function DownloadIcon() {
111
- return (
112
- <svg width="12" height="12" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
113
- <path d="M2.75 14A1.75 1.75 0 0 1 1 12.25v-2.5a.75.75 0 0 1 1.5 0v2.5c0 .138.112.25.25.25h10.5a.25.25 0 0 0 .25-.25v-2.5a.75.75 0 0 1 1.5 0v2.5A1.75 1.75 0 0 1 13.25 14Z" />
114
- <path d="M7.25 7.689V2a.75.75 0 0 1 1.5 0v5.689l1.97-1.969a.749.749 0 1 1 1.06 1.06l-3.25 3.25a.749.749 0 0 1-1.06 0L4.22 6.78a.749.749 0 1 1 1.06-1.06Z" />
115
- </svg>
116
- )
117
- }
118
-
119
- function ExpandIcon() {
120
- return (
121
- <svg width="12" height="12" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
122
- <path d="M1.75 10a.75.75 0 0 1 .75.75v2.5c0 .138.112.25.25.25h2.5a.75.75 0 0 1 0 1.5h-2.5A1.75 1.75 0 0 1 1 13.25v-2.5a.75.75 0 0 1 .75-.75Zm12.5 0a.75.75 0 0 1 .75.75v2.5A1.75 1.75 0 0 1 13.25 15h-2.5a.75.75 0 0 1 0-1.5h2.5a.25.25 0 0 0 .25-.25v-2.5a.75.75 0 0 1 .75-.75ZM2.75 1h2.5a.75.75 0 0 1 0 1.5h-2.5a.25.25 0 0 0-.25.25v2.5a.75.75 0 0 1-1.5 0v-2.5C1 1.784 1.784 1 2.75 1Zm10.5 0C14.216 1 15 1.784 15 2.75v2.5a.75.75 0 0 1-1.5 0v-2.5a.25.25 0 0 0-.25-.25h-2.5a.75.75 0 0 1 0-1.5Z" />
123
- </svg>
124
- )
125
- }
126
-
127
- function SyncIcon() {
128
- return (
129
- <svg width="12" height="12" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
130
- <path d="M1.705 8.005a.75.75 0 0 1 .834.656 5.5 5.5 0 0 0 9.592 2.97l-1.204-1.204a.25.25 0 0 1 .177-.427h3.646a.25.25 0 0 1 .25.25v3.646a.25.25 0 0 1-.427.177l-1.38-1.38A7.002 7.002 0 0 1 1.05 8.84a.75.75 0 0 1 .656-.834ZM8 2.5a5.487 5.487 0 0 0-4.131 1.869l1.204 1.204A.25.25 0 0 1 4.896 6H1.25A.25.25 0 0 1 1 5.75V2.104a.25.25 0 0 1 .427-.177l1.38 1.38A7.002 7.002 0 0 1 14.95 7.16a.75.75 0 0 1-1.49.178A5.5 5.5 0 0 0 8 2.5Z" />
131
- </svg>
132
- )
133
- }
134
-
135
- function UnfoldIcon() {
136
- return <OcticonUnfold size={12} />
137
- }
138
-
139
- function FoldIcon() {
140
- return <OcticonFold size={12} />
141
- }
142
-
143
- /** Icon registry — maps icon name strings from config to React components. */
144
- const ICON_REGISTRY = {
145
- 'trash': DeleteIcon,
146
- 'zoom-in': ZoomInIcon,
147
- 'zoom-out': ZoomOutIcon,
148
- 'edit': EditIcon,
149
- 'open-external': OpenExternalIcon,
150
- 'eye': EyeIcon,
151
- 'eye-closed': EyeClosedIcon,
152
- 'code': CodeIcon,
153
- 'unwrap': UnwrapIcon,
154
- 'image': ImageIcon,
155
- 'copy': CopyIcon,
156
- 'link': LinkIcon,
157
- 'more': MoreIcon,
158
- 'chevron-down': ChevronDownIcon,
159
- 'download': DownloadIcon,
160
- 'expand': ExpandIcon,
161
- 'sync': SyncIcon,
162
- 'unfold': UnfoldIcon,
163
- 'fold': FoldIcon,
164
- }
165
-
166
33
  /** Danger-styled actions in the overflow menu. */
167
34
  const DANGER_ACTIONS = new Set(['delete'])
168
35
 
@@ -228,7 +95,7 @@ function WidgetOverflowMenu({ widgetId, menuFeatures, onAction }) {
228
95
  const canvasId = window.__storyboardCanvasBridgeState?.canvasId || ''
229
96
  navigator.clipboard.writeText(`${canvasId}::${widgetId}`).catch(() => {})
230
97
  } else {
231
- onAction?.(action)
98
+ onAction?.(action, { altKey: altHeld })
232
99
  }
233
100
  setOpen(false)
234
101
  }, [widgetId, onAction, altHeld])
@@ -319,7 +186,7 @@ function DropdownFeature({ feature, onAction }) {
319
186
  className={styles.overflowItem}
320
187
  onClick={(e) => {
321
188
  e.stopPropagation()
322
- onAction?.(action)
189
+ onAction?.(action, { altKey: altHeld })
323
190
  setOpen(false)
324
191
  }}
325
192
  >
@@ -338,13 +205,35 @@ function DropdownFeature({ feature, onAction }) {
338
205
  }
339
206
 
340
207
  /**
341
- * ColorPicker feature button — shows a dot that reveals color options on hover.
208
+ * ColorPicker feature button — shows a dot that reveals color options on click.
209
+ * Closes on click-outside or Escape.
342
210
  */
343
211
  function ColorPickerFeature({ currentColor, options, onColorChange }) {
344
212
  const palette = STICKY_NOTE_COLORS[currentColor] ?? STICKY_NOTE_COLORS.yellow
213
+ const [open, setOpen] = useState(false)
214
+ const wrapperRef = useRef(null)
215
+
216
+ useEffect(() => {
217
+ if (!open) return
218
+ function handleClickOutside(e) {
219
+ if (wrapperRef.current && !wrapperRef.current.contains(e.target)) {
220
+ setOpen(false)
221
+ }
222
+ }
223
+ function handleEscape(e) {
224
+ if (e.key === 'Escape') setOpen(false)
225
+ }
226
+ document.addEventListener('pointerdown', handleClickOutside, true)
227
+ document.addEventListener('keydown', handleEscape)
228
+ return () => {
229
+ document.removeEventListener('pointerdown', handleClickOutside, true)
230
+ document.removeEventListener('keydown', handleEscape)
231
+ }
232
+ }, [open])
345
233
 
346
234
  return (
347
235
  <div
236
+ ref={wrapperRef}
348
237
  className={styles.colorPickerWrapper}
349
238
  onMouseDown={(e) => e.stopPropagation()}
350
239
  onPointerDown={(e) => e.stopPropagation()}
@@ -354,10 +243,11 @@ function ColorPickerFeature({ currentColor, options, onColorChange }) {
354
243
  style={{ background: palette.dot }}
355
244
  aria-label="Change color"
356
245
  title="Change color"
246
+ onClick={() => setOpen((prev) => !prev)}
357
247
  >
358
248
  <span className={styles.colorDotInner} style={{ background: palette.dot }} />
359
249
  </button>
360
- <div className={styles.colorPopup}>
250
+ <div className={`${styles.colorPopup} ${open ? styles.colorPopupOpen : ''}`}>
361
251
  {(options || Object.keys(STICKY_NOTE_COLORS)).map((colorName) => {
362
252
  const c = STICKY_NOTE_COLORS[colorName]
363
253
  if (!c) return null
@@ -369,6 +259,7 @@ function ColorPickerFeature({ currentColor, options, onColorChange }) {
369
259
  onClick={(e) => {
370
260
  e.stopPropagation()
371
261
  onColorChange(colorName)
262
+ setOpen(false)
372
263
  }}
373
264
  title={colorName}
374
265
  aria-label={`Set color to ${colorName}`}
@@ -433,13 +324,13 @@ export default function WidgetChrome({
433
324
  e.stopPropagation()
434
325
  // Standard actions go through onAction (handled by CanvasPage)
435
326
  if (actionId === 'delete' || actionId === 'copy') {
436
- onAction?.(actionId)
327
+ onAction?.(actionId, { altKey: e.altKey })
437
328
  return
438
329
  }
439
330
  // Widget-specific actions go through the widget's imperative ref
440
331
  if (widgetRef?.current?.handleAction) {
441
- widgetRef.current.handleAction(actionId)
442
- return
332
+ const handled = widgetRef.current.handleAction(actionId)
333
+ if (handled !== false) return
443
334
  }
444
335
  // Fallback to generic handler
445
336
  onAction?.(actionId)
@@ -515,7 +406,7 @@ export default function WidgetChrome({
515
406
  >
516
407
  <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}>
517
408
  {children}
518
- {gate.enabled && !interacting && (
409
+ {gate.enabled && !interacting && !readOnly && (
519
410
  <div
520
411
  className={overlayStyles.interactOverlay}
521
412
  onClick={handleGateClick}
@@ -560,6 +451,7 @@ export default function WidgetChrome({
560
451
  <div className={`${styles.toolbarContent} ${showToolbar ? styles.toolbarContentVisible : ''}`}>
561
452
  {showFeatures && (
562
453
  <div className={styles.featureButtons}>
454
+ {/* eslint-disable-next-line react-hooks/refs */}
563
455
  {features.map((feature) => {
564
456
  // Menu features are rendered in WidgetOverflowMenu
565
457
  if (feature.menu) return null
@@ -581,11 +473,16 @@ export default function WidgetChrome({
581
473
 
582
474
  // Toggle-private: swap icon/label based on current state
583
475
  if (feature.action === 'toggle-private') {
476
+ const isTerminal = widgetType === 'terminal' || widgetType === 'agent'
584
477
  if (widgetProps?.private) {
585
478
  Icon = ICON_REGISTRY['eye-closed']
586
- label = 'Private image — only visible locally'
479
+ label = isTerminal
480
+ ? 'Private terminal — snapshots hidden from git'
481
+ : 'Private image — only visible locally'
587
482
  } else {
588
- label = 'Published image — deployed with canvas'
483
+ label = isTerminal
484
+ ? 'Public terminal — snapshots committed to git'
485
+ : 'Published image — deployed with canvas'
589
486
  }
590
487
  }
591
488
 
@@ -594,10 +491,34 @@ export default function WidgetChrome({
594
491
  label = 'Show component'
595
492
  }
596
493
 
494
+ // Expand-output toggle: swap icon/label, hide when no session
495
+ if (feature.action === 'expand-output') {
496
+ const hasSession = widgetRef?.current?.getState?.('hasSession')
497
+ if (!hasSession) return null
498
+ const isActive = widgetRef?.current?.getState?.('showOutput')
499
+ if (isActive) {
500
+ Icon = ICON_REGISTRY['fold']
501
+ label = 'Hide output'
502
+ } else {
503
+ label = 'Show output'
504
+ }
505
+ }
506
+
507
+ // Open-terminal: hide when no session
508
+ if (feature.action === 'open-terminal') {
509
+ const hasSession = widgetRef?.current?.getState?.('hasSession')
510
+ if (!hasSession) return null
511
+ }
512
+
513
+ // Determine active state for toggle buttons
514
+ const isActive = feature.active || (
515
+ feature.action === 'expand-output' && widgetRef?.current?.getState?.('showOutput')
516
+ )
517
+
597
518
  return (
598
519
  <Tooltip key={feature.id} text={label} direction="n">
599
520
  <button
600
- className={styles.featureBtn}
521
+ className={`${styles.featureBtn}${isActive ? ` ${styles.featureBtnActive}` : ''}`}
601
522
  onClick={(e) => handleActionClick(feature.action, e)}
602
523
  aria-label={label}
603
524
  >
@@ -612,12 +533,12 @@ export default function WidgetChrome({
612
533
  <DropdownFeature
613
534
  key={feature.id}
614
535
  feature={feature}
615
- onAction={(actionId) => {
536
+ onAction={(actionId, opts) => {
616
537
  if (widgetRef?.current?.handleAction) {
617
- widgetRef.current.handleAction(actionId)
618
- } else {
619
- onAction?.(actionId)
538
+ const handled = widgetRef.current.handleAction(actionId)
539
+ if (handled !== false) return
620
540
  }
541
+ onAction?.(actionId, opts)
621
542
  }}
622
543
  />
623
544
  )
@@ -629,13 +550,13 @@ export default function WidgetChrome({
629
550
  <WidgetOverflowMenu
630
551
  widgetId={widgetId}
631
552
  menuFeatures={menuFeatures}
632
- onAction={(actionId) => {
553
+ onAction={(actionId, opts) => {
633
554
  // Route overflow menu actions through the widget ref first
634
555
  if (actionId !== 'delete' && actionId !== 'copy' && widgetRef?.current?.handleAction) {
635
- widgetRef.current.handleAction(actionId)
636
- } else {
637
- onAction?.(actionId)
556
+ const handled = widgetRef.current.handleAction(actionId)
557
+ if (handled !== false) return
638
558
  }
559
+ onAction?.(actionId, opts)
639
560
  }}
640
561
  />
641
562
  )}
@@ -19,6 +19,16 @@
19
19
  pointer-events: auto;
20
20
  }
21
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
+
22
32
  .chromeContainer:hover .anchorPort {
23
33
  opacity: 0.6;
24
34
  }
@@ -183,6 +193,25 @@
183
193
  border-color: var(--borderColor-default, #484f58);
184
194
  }
185
195
 
196
+ .featureBtnActive {
197
+ background: var(--bgColor-accent-emphasis, #0969da);
198
+ border-color: var(--bgColor-accent-emphasis, #0969da);
199
+ color: var(--fgColor-onEmphasis, #ffffff);
200
+ }
201
+
202
+ :global([data-sb-canvas-theme^='dark']) .featureBtnActive {
203
+ background: var(--bgColor-accent-emphasis, #388bfd);
204
+ border-color: var(--bgColor-accent-emphasis, #388bfd);
205
+ color: var(--fgColor-onEmphasis, #ffffff);
206
+ }
207
+
208
+ .featureBtnActive:hover {
209
+ background: var(--bgColor-accent-emphasis, #0969da);
210
+ border-color: var(--bgColor-accent-emphasis, #0969da);
211
+ color: var(--fgColor-onEmphasis, #ffffff);
212
+ filter: brightness(1.1);
213
+ }
214
+
186
215
  /* Select handle — right-aligned rounded rect */
187
216
  .selectHandle {
188
217
  all: unset;
@@ -269,7 +298,7 @@
269
298
  0 4px 12px rgba(0, 0, 0, 0.45);
270
299
  }
271
300
 
272
- .colorPickerWrapper:hover .colorPopup {
301
+ .colorPopupOpen {
273
302
  opacity: 1;
274
303
  pointer-events: auto;
275
304
  }
@@ -56,72 +56,70 @@ describe('Embed interaction overlay', () => {
56
56
  resizable: false,
57
57
  }
58
58
 
59
- it('renders "Click to open" hint when no snapshot exists', () => {
59
+ it('renders "Click to interact" hint when no snapshot exists', () => {
60
60
  render(<PrototypeEmbed {...defaultProps} />)
61
61
 
62
- const hint = screen.getByText('Click to open')
62
+ const hint = screen.getByText('Click to interact')
63
63
  expect(hint).toBeInTheDocument()
64
- // CSS modules mangle class names, just check the element exists
65
64
  })
66
65
 
67
66
  it('enters interactive mode on single click (not double-click)', async () => {
68
67
  const { container } = render(<PrototypeEmbed {...defaultProps} />)
69
68
 
70
- // Overlay should exist before interaction
71
- const overlay = screen.getByRole('button', { name: /click to open/i })
69
+ // Overlay should exist before interaction; iframe is always rendered
70
+ const overlay = screen.getByRole('button', { name: /click to interact with prototype/i })
72
71
  expect(overlay).toBeInTheDocument()
73
- expect(container.querySelector('iframe')).not.toBeInTheDocument()
74
- expect(screen.getByText('Design Overview')).toBeInTheDocument()
72
+ expect(container.querySelector('iframe')).toBeInTheDocument()
75
73
 
76
74
  // Single click should remove the overlay (enter interactive mode)
77
75
  fireEvent.click(overlay)
78
76
 
79
77
  // Overlay should no longer exist
80
- expect(screen.queryByRole('button', { name: /click to open/i })).not.toBeInTheDocument()
78
+ expect(screen.queryByRole('button', { name: /click to interact/i })).not.toBeInTheDocument()
81
79
  expect(container.querySelector('iframe')).toBeInTheDocument()
82
80
 
83
81
  fireEvent.pointerDown(document.body)
84
- expect(screen.getByRole('button', { name: /click to open/i })).toBeInTheDocument()
85
- expect(container.querySelector('iframe')).not.toBeInTheDocument()
82
+ expect(screen.getByRole('button', { name: /click to interact with prototype/i })).toBeInTheDocument()
83
+ expect(container.querySelector('iframe')).toBeInTheDocument()
86
84
  })
87
85
 
88
86
  it('does not enter interactive mode on shift+click (preserves multi-select)', () => {
89
87
  render(<PrototypeEmbed {...defaultProps} />)
90
88
 
91
- const overlay = screen.getByRole('button', { name: /click to open/i })
89
+ const overlay = screen.getByRole('button', { name: /click to interact with prototype/i })
92
90
  fireEvent.click(overlay, { shiftKey: true })
93
91
 
94
92
  // Overlay should still exist (did not enter interactive mode)
95
- expect(screen.getByRole('button', { name: /click to open/i })).toBeInTheDocument()
93
+ expect(screen.getByRole('button', { name: /click to interact with prototype/i })).toBeInTheDocument()
96
94
  })
97
95
 
98
96
  it('does not enter interactive mode on meta+click (preserves multi-select)', () => {
99
97
  render(<PrototypeEmbed {...defaultProps} />)
100
98
 
101
- const overlay = screen.getByRole('button', { name: /click to open/i })
99
+ const overlay = screen.getByRole('button', { name: /click to interact with prototype/i })
102
100
  fireEvent.click(overlay, { metaKey: true })
103
101
 
104
- expect(screen.getByRole('button', { name: /click to open/i })).toBeInTheDocument()
102
+ expect(screen.getByRole('button', { name: /click to interact with prototype/i })).toBeInTheDocument()
105
103
  })
106
104
 
107
105
  it('supports keyboard interaction (Enter key) with event prevention', () => {
108
106
  render(<PrototypeEmbed {...defaultProps} />)
109
107
 
110
- const overlay = screen.getByRole('button', { name: /click to open/i })
108
+ const overlay = screen.getByRole('button', { name: /click to interact with prototype/i })
111
109
  const event = { key: 'Enter', preventDefault: vi.fn(), stopPropagation: vi.fn() }
112
110
  fireEvent.keyDown(overlay, event)
113
111
 
114
- expect(screen.queryByRole('button', { name: /click to open/i })).not.toBeInTheDocument()
112
+ expect(screen.queryByRole('button', { name: /click to interact/i })).not.toBeInTheDocument()
115
113
  })
116
114
 
117
115
  it('supports keyboard interaction (Space key) with event prevention', () => {
118
116
  render(<PrototypeEmbed {...defaultProps} />)
119
117
 
120
- const overlay = screen.getByRole('button', { name: /click to open/i })
118
+ const overlay = screen.getByRole('button', { name: /click to interact with prototype/i })
121
119
  const event = { key: ' ', preventDefault: vi.fn(), stopPropagation: vi.fn() }
122
120
  fireEvent.keyDown(overlay, event)
123
121
 
124
- expect(screen.queryByRole('button', { name: /click to open/i })).not.toBeInTheDocument()
122
+ expect(screen.queryByRole('button', { name: /click to interact/i })).not.toBeInTheDocument()
125
123
  })
126
124
  })
127
125
 
@@ -143,7 +141,7 @@ describe('Embed interaction overlay', () => {
143
141
  const { container } = render(<FigmaEmbed {...defaultProps} />)
144
142
 
145
143
  const overlay = screen.getByRole('button', { name: /click to interact/i })
146
- expect(container.querySelector('iframe')).not.toBeInTheDocument()
144
+ expect(container.querySelector('iframe')).toBeInTheDocument()
147
145
  fireEvent.click(overlay)
148
146
 
149
147
  expect(screen.queryByRole('button', { name: /click to interact/i })).not.toBeInTheDocument()
@@ -151,7 +149,7 @@ describe('Embed interaction overlay', () => {
151
149
 
152
150
  fireEvent.pointerDown(document.body)
153
151
  expect(screen.getByRole('button', { name: /click to interact/i })).toBeInTheDocument()
154
- expect(container.querySelector('iframe')).not.toBeInTheDocument()
152
+ expect(container.querySelector('iframe')).toBeInTheDocument()
155
153
  })
156
154
  })
157
155
 
@@ -162,20 +160,20 @@ describe('Embed interaction overlay', () => {
162
160
  resizable: false,
163
161
  }
164
162
 
165
- it('mounts iframe only after user activation', () => {
163
+ it('mounts iframe and shows overlay initially, removes overlay on click', () => {
166
164
  const { container } = render(<StoryWidget {...defaultProps} />)
167
165
 
168
- const overlay = screen.getByRole('button', { name: /click to open story component/i })
169
- expect(container.querySelector('iframe')).not.toBeInTheDocument()
166
+ const overlay = screen.getByRole('button', { name: /click to interact$/i })
167
+ expect(container.querySelector('iframe')).toBeInTheDocument()
170
168
 
171
169
  fireEvent.click(overlay)
172
170
 
173
- expect(screen.queryByRole('button', { name: /click to open story component/i })).not.toBeInTheDocument()
171
+ expect(screen.queryByRole('button', { name: /click to interact/i })).not.toBeInTheDocument()
174
172
  expect(container.querySelector('iframe')).toBeInTheDocument()
175
173
 
176
174
  fireEvent.pointerDown(document.body)
177
- expect(screen.getByRole('button', { name: /click to open story component/i })).toBeInTheDocument()
178
- expect(container.querySelector('iframe')).not.toBeInTheDocument()
175
+ expect(screen.getByRole('button', { name: /click to interact$/i })).toBeInTheDocument()
176
+ expect(container.querySelector('iframe')).toBeInTheDocument()
179
177
  })
180
178
  })
181
179