@dfosco/storyboard-react 4.2.1 → 4.2.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,11 +1,11 @@
1
1
  {
2
2
  "name": "@dfosco/storyboard-react",
3
- "version": "4.2.1",
3
+ "version": "4.2.3",
4
4
  "type": "module",
5
5
  "dependencies": {
6
6
  "@base-ui/react": "^1.4.0",
7
- "@dfosco/storyboard-core": "4.2.1",
8
- "@dfosco/tiny-canvas": "4.2.1",
7
+ "@dfosco/storyboard-core": "4.2.3",
8
+ "@dfosco/tiny-canvas": "4.2.3",
9
9
  "@neodrag/react": "^2.3.1",
10
10
  "@radix-ui/react-dialog": "^1.1.15",
11
11
  "@radix-ui/react-visually-hidden": "^1.2.4",
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * AuthModal — Global PAT entry dialog for comments authentication.
3
3
  * Mounted at app root, triggered by:
4
- * - Svelte CoreUIBar (comments tool / "C" shortcut) via 'storyboard:open-auth-modal' event
4
+ * - CoreUIBar (comments tool / "C" shortcut) via 'storyboard:open-auth-modal' event
5
5
  * - ViewfinderNew sidebar login button via same event
6
6
  */
7
7
  import { useState, useEffect, useCallback } from 'react'
@@ -15,6 +15,15 @@ function checkLocalDev() {
15
15
  return window.__SB_LOCAL_DEV__ === true
16
16
  }
17
17
 
18
+ /** Read the dev domain color injected by the server plugin, validated via CSS.supports. */
19
+ function getDevDomainColor() {
20
+ if (typeof window === 'undefined') return null
21
+ const color = window.__SB_DEV_DOMAIN_COLOR__
22
+ if (!color) return null
23
+ if (typeof CSS !== 'undefined' && CSS.supports && !CSS.supports('color', color)) return null
24
+ return color
25
+ }
26
+
18
27
  export default function BranchBar({ basePath }) {
19
28
  const [hidden, setHidden] = useState(
20
29
  () => typeof document !== 'undefined' && document.documentElement.classList.contains('storyboard-chrome-hidden')
@@ -33,6 +42,7 @@ export default function BranchBar({ basePath }) {
33
42
 
34
43
  const isLocalDev = checkLocalDev()
35
44
  const isOnBranch = currentBranch !== 'main'
45
+ const domainColor = isLocalDev ? getDevDomainColor() : null
36
46
 
37
47
  useEffect(() => {
38
48
  const observer = new MutationObserver(() => {
@@ -52,8 +62,15 @@ export default function BranchBar({ basePath }) {
52
62
 
53
63
  return (
54
64
  <div className={css.bar} data-branch-bar>
55
- <div className={`${css.barInner}${isLocalDev ? '' : ` ${css.barProd}`}`}>
65
+ <div
66
+ className={`${css.barInner}${isLocalDev ? '' : ` ${css.barProd}`}`}
67
+ style={domainColor ? { '--sb-branch-bar-bg': domainColor } : undefined}
68
+ >
56
69
  <span className={css.barLabel}>
70
+ {isLocalDev && window.__SB_DEV_DOMAIN__ && <>
71
+ <span className={css.barDomainName}>⌘ {window.__SB_DEV_DOMAIN__}</span>
72
+ <span className={css.barSeparator}>·</span>
73
+ </>}
57
74
  <GitBranchIcon size={12} />
58
75
  <span className={css.barBranchName}>{currentBranch}</span>
59
76
  {isLocalDev && <>
@@ -16,7 +16,7 @@
16
16
  justify-content: center;
17
17
  gap: 8px;
18
18
  height: 32px;
19
- background: hsl(212, 92%, 45%);
19
+ background: var(--sb-branch-bar-bg, hsl(212, 92%, 45%));
20
20
  color: #fff;
21
21
  padding: 4px 12px;
22
22
  position: relative;
@@ -72,6 +72,14 @@
72
72
  white-space: nowrap;
73
73
  }
74
74
 
75
+ .barDomainName {
76
+ font-weight: 400;
77
+ max-width: 200px;
78
+ overflow: hidden;
79
+ text-overflow: ellipsis;
80
+ white-space: nowrap;
81
+ }
82
+
75
83
  .barSeparator {
76
84
  opacity: 0.6;
77
85
  margin: 0 2px;
@@ -880,7 +880,7 @@ function buildPaletteItems(basePath, onCreateAction, onNavigateToPage) {
880
880
 
881
881
  /**
882
882
  * StoryboardCommandPalette — React command palette using react-cmdk.
883
- * Mounted at app root, listens for custom events from Svelte CoreUIBar.
883
+ * Mounted at app root, listens for custom events from CoreUIBar.
884
884
  */
885
885
  export default function StoryboardCommandPalette({ basePath }) {
886
886
  const [open, setOpen] = useState(false)
@@ -225,7 +225,7 @@ function CardActionsMenu({ typeLabel, onEdit, onDelete }) {
225
225
  <KebabHorizontalIcon size={16} />
226
226
  </Menu.Trigger>
227
227
  <Menu.Portal>
228
- <Menu.Positioner className={css.actionsMenuPositioner} side="bottom" alignment="end">
228
+ <Menu.Positioner className={css.actionsMenuPositioner} side="inline-end" alignment="end" sideOffset={8}>
229
229
  <Menu.Popup className={css.actionsMenu} onClick={(e) => { e.preventDefault(); e.stopPropagation() }}>
230
230
  <Menu.Item
231
231
  className={css.actionsMenuItem}
@@ -486,6 +486,7 @@ function ArtifactCard({ item, basePath, starred, onToggleStar, onItemDeleted })
486
486
  <div className={css.cardHeader}>
487
487
  <span className={css.cardBadge}>{getTypeLabel(item.type)}</span>
488
488
  <div className={css.cardActions}>
489
+ <StarBtn active={starred} onClick={() => onToggleStar(item.id)} inline />
489
490
  {item.flows?.length > 0 && <FlowsDropdown flows={item.flows} basePath={basePath} />}
490
491
  {item.pages?.length > 1 && <PagesDropdown pages={item.pages} basePath={basePath} />}
491
492
  {canEditDelete && (
@@ -504,7 +505,6 @@ function ArtifactCard({ item, basePath, starred, onToggleStar, onItemDeleted })
504
505
  {item.name}
505
506
  {isExternal && <span className={css.externalBadge}>↗</span>}
506
507
  </div>
507
- <StarBtn active={starred} onClick={() => onToggleStar(item.id)} inline />
508
508
  </div>
509
509
  {item.description && (
510
510
  <div className={css.cardDescription}>{item.description}</div>
@@ -582,12 +582,13 @@ function FlowsDropdown({ flows, basePath }) {
582
582
  <Menu.Item
583
583
  key={flow.key}
584
584
  className={css.flowsItem}
585
- onClick={(e) => {
586
- e.preventDefault()
587
- window.location.href = withBase(basePath, flow.route)
588
- }}
589
585
  >
590
- {flow.meta?.title || flow.name}
586
+ <a
587
+ href={withBase(basePath, flow.route)}
588
+ className={css.flowsItemLink}
589
+ >
590
+ {flow.meta?.title || flow.name}
591
+ </a>
591
592
  </Menu.Item>
592
593
  ))}
593
594
  </Menu.Popup>
@@ -619,12 +620,13 @@ function PagesDropdown({ pages, basePath }) {
619
620
  <Menu.Item
620
621
  key={page.route}
621
622
  className={css.flowsItem}
622
- onClick={(e) => {
623
- e.preventDefault()
624
- window.location.href = withBase(basePath, page.route)
625
- }}
626
623
  >
627
- {page.name}
624
+ <a
625
+ href={withBase(basePath, page.route)}
626
+ className={css.flowsItemLink}
627
+ >
628
+ {page.name}
629
+ </a>
628
630
  </Menu.Item>
629
631
  ))}
630
632
  </Menu.Popup>
@@ -1118,6 +1118,12 @@
1118
1118
  color: var(--fgColor-default, #1a1a1a);
1119
1119
  }
1120
1120
 
1121
+ .flowsItemLink {
1122
+ display: block;
1123
+ color: inherit;
1124
+ text-decoration: none;
1125
+ }
1126
+
1121
1127
  /* Avatar stack */
1122
1128
 
1123
1129
  .avatarStack {
@@ -2075,7 +2075,7 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
2075
2075
  return () => document.removeEventListener('storyboard:canvas:toggle-snap', handleSnapToggle)
2076
2076
  }, [canvasId])
2077
2077
 
2078
- // Broadcast snap state to Svelte toolbar
2078
+ // Broadcast snap state to toolbar
2079
2079
  useEffect(() => {
2080
2080
  document.dispatchEvent(new CustomEvent('storyboard:canvas:snap-state', {
2081
2081
  detail: { snapEnabled }
@@ -2083,7 +2083,7 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
2083
2083
  snapEnabledRef.current = snapEnabled
2084
2084
  }, [snapEnabled])
2085
2085
 
2086
- // Respond to snap-state requests from Svelte toolbar (handles mount-order race)
2086
+ // Respond to snap-state requests from toolbar (handles mount-order race)
2087
2087
  useEffect(() => {
2088
2088
  function handleRequest() {
2089
2089
  document.dispatchEvent(new CustomEvent('storyboard:canvas:snap-state', {
@@ -2094,7 +2094,7 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
2094
2094
  return () => document.removeEventListener('storyboard:canvas:snap-state-request', handleRequest)
2095
2095
  }, [])
2096
2096
 
2097
- // Listen for gridSize from Svelte toolbar config
2097
+ // Listen for gridSize from toolbar config
2098
2098
  useEffect(() => {
2099
2099
  function handleGridSize(e) {
2100
2100
  const size = e.detail?.gridSize
@@ -2638,7 +2638,7 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
2638
2638
  return () => document.removeEventListener('keydown', handleKeyDown)
2639
2639
  }, [handleUndo, handleRedo, handleDuplicateSelected, handleDuplicateWithConnectors, handleSelectAll])
2640
2640
 
2641
- // Listen for undo/redo from CoreUIBar (Svelte toolbar)
2641
+ // Listen for undo/redo from CoreUIBar
2642
2642
  useEffect(() => {
2643
2643
  function handleUndoEvent() { handleUndo() }
2644
2644
  function handleRedoEvent() { handleRedo() }
@@ -2650,7 +2650,7 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
2650
2650
  }
2651
2651
  }, [handleUndo, handleRedo])
2652
2652
 
2653
- // Broadcast undo/redo availability to Svelte toolbar
2653
+ // Broadcast undo/redo availability to toolbar
2654
2654
  useEffect(() => {
2655
2655
  document.dispatchEvent(new CustomEvent('storyboard:canvas:undo-redo-state', {
2656
2656
  detail: { canUndo: undoRedo.canUndo, canRedo: undoRedo.canRedo }
@@ -104,11 +104,15 @@ export default function PageSelector({ currentName, pages: initialPages, isLocal
104
104
  const currentLabel = currentPage?.title || currentName.split('/').pop()
105
105
  const currentIndex = realPages.findIndex(p => p.name === currentName)
106
106
 
107
- const navigateTo = useCallback((page) => {
107
+ const getPageHref = useCallback((page) => {
108
108
  const base = (import.meta.env?.BASE_URL || '/').replace(/\/$/, '')
109
- window.location.href = base + page.route
109
+ return base + page.route
110
110
  }, [])
111
111
 
112
+ const navigateTo = useCallback((page) => {
113
+ window.location.href = getPageHref(page)
114
+ }, [getPageHref])
115
+
112
116
  const handleSelect = useCallback(
113
117
  (page) => {
114
118
  if (page.name !== currentName) {
@@ -119,13 +123,21 @@ export default function PageSelector({ currentName, pages: initialPages, isLocal
119
123
  [currentName, navigateTo],
120
124
  )
121
125
 
122
- // Click handler with 300ms delay (mouse only) to distinguish from dblclick
126
+ // Click handler with 300ms delay (mouse only) to distinguish from dblclick.
127
+ // Cmd/Ctrl+click is handled natively by the <a> tag.
123
128
  const handleItemClick = useCallback((page, e) => {
124
129
  if (didDragRef.current) {
125
130
  didDragRef.current = false
131
+ e.preventDefault()
126
132
  return
127
133
  }
128
- if (editingPage) return
134
+ if (editingPage) {
135
+ e.preventDefault()
136
+ return
137
+ }
138
+ // Cmd/Ctrl+click or middle-click → let browser handle natively
139
+ if (e?.metaKey || e?.ctrlKey || e?.button === 1) return
140
+ e.preventDefault()
129
141
  // Keyboard Enter/Space → navigate immediately
130
142
  if (!e?.nativeEvent || e.nativeEvent instanceof KeyboardEvent) {
131
143
  handleSelect(page)
@@ -465,15 +477,7 @@ export default function PageSelector({ currentName, pages: initialPages, isLocal
465
477
  role="option"
466
478
  aria-selected={page.name === currentName}
467
479
  className={`${styles.item} ${page.name === currentName ? styles.itemActive : ''} ${dragIndex === index ? styles.itemDragging : ''}`}
468
- onClick={(e) => handleItemClick(page, e)}
469
480
  onDoubleClick={() => handleItemDblClick(page)}
470
- onKeyDown={(e) => {
471
- if (e.key === 'Enter' || e.key === ' ') {
472
- e.preventDefault()
473
- handleSelect(page)
474
- }
475
- }}
476
- tabIndex={0}
477
481
  draggable={isLocalDev && !isEditing}
478
482
  onDragStart={(e) => handleDragStart(index, e)}
479
483
  onDragOver={(e) => handleDragOver(index, e)}
@@ -499,22 +503,27 @@ export default function PageSelector({ currentName, pages: initialPages, isLocal
499
503
  onBlur={handleRenameCommit}
500
504
  />
501
505
  ) : (
502
- <>
506
+ <a
507
+ href={getPageHref(page)}
508
+ className={styles.itemLink}
509
+ onClick={(e) => handleItemClick(page, e)}
510
+ onDoubleClick={(e) => e.stopPropagation()}
511
+ >
503
512
  <span className={styles.itemContent}>{page.title}</span>
504
- {isLocalDev && (
505
- <button
506
- className={styles.duplicateBtn}
507
- onClick={(e) => handleDuplicate(page, e)}
508
- onDoubleClick={(e) => e.stopPropagation()}
509
- title="Duplicate page"
510
- aria-label="Duplicate page"
511
- >
512
- <svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
513
- <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.25ZM5 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" />
514
- </svg>
515
- </button>
516
- )}
517
- </>
513
+ </a>
514
+ )}
515
+ {!isEditing && isLocalDev && (
516
+ <button
517
+ className={styles.duplicateBtn}
518
+ onClick={(e) => handleDuplicate(page, e)}
519
+ onDoubleClick={(e) => e.stopPropagation()}
520
+ title="Duplicate page"
521
+ aria-label="Duplicate page"
522
+ >
523
+ <svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
524
+ <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.25ZM5 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" />
525
+ </svg>
526
+ </button>
518
527
  )}
519
528
  </li>
520
529
  )
@@ -83,6 +83,15 @@
83
83
  white-space: nowrap;
84
84
  }
85
85
 
86
+ .itemLink {
87
+ display: flex;
88
+ align-items: center;
89
+ flex: 1;
90
+ min-width: 0;
91
+ color: inherit;
92
+ text-decoration: none;
93
+ }
94
+
86
95
  .dragHandle {
87
96
  opacity: 0;
88
97
  color: var(--fgColor-muted, #656d76);
@@ -73,15 +73,24 @@ export default forwardRef(function ComponentSetWidget({ id: widgetId, props, onU
73
73
  useEffect(() => {
74
74
  function handleMessage(e) {
75
75
  if (e.source !== iframeRef.current?.contentWindow) return
76
- if (e.data?.type !== 'storyboard:component-set:select') return
77
- const newSelected = e.data.exportName || ''
78
- if (newSelected !== selected) {
79
- onUpdate?.({ selected: newSelected })
76
+ if (e.data?.type === 'storyboard:component-set:select') {
77
+ const newSelected = e.data.exportName || ''
78
+ if (newSelected !== selected) {
79
+ onUpdate?.({ selected: newSelected })
80
+ }
81
+ } else if (e.data?.type === 'storyboard:component-set:resize') {
82
+ // Auto-size widget to fit the grid content (+ header height)
83
+ const headerH = 32
84
+ const newW = Math.max(200, Math.ceil(e.data.width))
85
+ const newH = Math.max(60, Math.ceil(e.data.height) + headerH)
86
+ if (newW !== width || newH !== height) {
87
+ onUpdate?.({ width: newW, height: newH })
88
+ }
80
89
  }
81
90
  }
82
91
  window.addEventListener('message', handleMessage)
83
92
  return () => window.removeEventListener('message', handleMessage)
84
- }, [selected, onUpdate])
93
+ }, [selected, width, height, onUpdate])
85
94
 
86
95
  const handleResize = useCallback((w, h) => {
87
96
  onUpdate?.({ width: w, height: h })
@@ -5,17 +5,17 @@
5
5
  * - A crop region with drag handles (corners + edges)
6
6
  * - Dark overlay on excluded area (via box-shadow)
7
7
  * - Rule-of-thirds grid
8
- * - A floating bar anchored to the crop region with Save / Undo / Cancel + dimensions
8
+ *
9
+ * The confirmation bar (CropBar) is rendered separately by ImageWidget,
10
+ * outside the WidgetWrapper, to avoid overflow clipping.
9
11
  *
10
12
  * Props:
11
13
  * containerWidth / containerHeight — pixel dimensions of the image container
12
- * naturalWidth / naturalHeight natural image dimensions
13
- * onSave(cropRect) — called with { x, y, width, height } in natural pixels
14
+ * cropRect current crop rectangle { x, y, width, height } in display pixels
15
+ * onCropRectChange(rect) — called when the user drags the crop region
14
16
  * onCancel() — exit crop mode without saving
15
- * onUndo() — revert to previous image (only when canUndo is true)
16
- * canUndo — whether undo is available
17
17
  */
18
- import { useState, useRef, useCallback, useEffect } from 'react'
18
+ import { useRef, useCallback, useEffect } from 'react'
19
19
  import styles from './CropOverlay.module.css'
20
20
 
21
21
  const MIN_CROP = 20
@@ -52,23 +52,13 @@ function XIcon() {
52
52
  export default function CropOverlay({
53
53
  containerWidth,
54
54
  containerHeight,
55
- naturalWidth,
56
- naturalHeight,
57
- onSave,
55
+ cropRect,
56
+ onCropRectChange,
58
57
  onCancel,
59
- onUndo,
60
- canUndo,
61
58
  }) {
62
59
  const cw = containerWidth || 400
63
60
  const ch = containerHeight || 300
64
61
 
65
- const [cropRect, setCropRect] = useState({
66
- x: Math.round(cw * 0.05),
67
- y: Math.round(ch * 0.05),
68
- width: Math.round(cw * 0.9),
69
- height: Math.round(ch * 0.9),
70
- })
71
-
72
62
  const dragging = useRef(null)
73
63
 
74
64
  // Escape key cancels crop
@@ -118,7 +108,7 @@ export default function CropOverlay({
118
108
  }
119
109
  }
120
110
 
121
- setCropRect({ x, y, width, height })
111
+ onCropRectChange({ x, y, width, height })
122
112
  }
123
113
 
124
114
  const onUp = () => {
@@ -129,21 +119,7 @@ export default function CropOverlay({
129
119
 
130
120
  window.addEventListener('pointermove', onMove)
131
121
  window.addEventListener('pointerup', onUp)
132
- }, [cropRect, cw, ch])
133
-
134
- const handleSave = useCallback(() => {
135
- const scaleX = (naturalWidth || cw) / cw
136
- const scaleY = (naturalHeight || ch) / ch
137
- onSave?.({
138
- x: Math.round(cropRect.x * scaleX),
139
- y: Math.round(cropRect.y * scaleY),
140
- width: Math.round(cropRect.width * scaleX),
141
- height: Math.round(cropRect.height * scaleY),
142
- })
143
- }, [cropRect, cw, ch, naturalWidth, naturalHeight, onSave])
144
-
145
- const cropW = Math.round(((naturalWidth || cw) / cw) * cropRect.width)
146
- const cropH = Math.round(((naturalHeight || ch) / ch) * cropRect.height)
122
+ }, [cropRect, cw, ch, onCropRectChange])
147
123
 
148
124
  return (
149
125
  <div
@@ -170,48 +146,32 @@ export default function CropOverlay({
170
146
  ))}
171
147
  </div>
172
148
 
173
- <FloatingCropBar
174
- cropRect={cropRect}
175
- containerWidth={cw}
176
- cropW={cropW}
177
- cropH={cropH}
178
- onSave={handleSave}
179
- onUndo={onUndo}
180
- onCancel={onCancel}
181
- canUndo={canUndo}
182
- />
183
149
  </div>
184
150
  )
185
151
  }
186
152
 
187
- function FloatingCropBar({ cropRect, containerWidth, cropW, cropH, onSave, onUndo, onCancel, canUndo }) {
188
- const barHeight = 36
189
- const gap = 10
190
- const centerX = cropRect.x + cropRect.width / 2
191
- const aboveY = cropRect.y - barHeight - gap
192
- const belowY = cropRect.y + cropRect.height + gap
193
-
194
- const anchorBelow = aboveY < 0
195
- const barTop = anchorBelow ? belowY : aboveY
196
- const barLeft = clamp(centerX, 80, containerWidth - 80)
197
-
153
+ /**
154
+ * CropBar — crop confirmation toolbar rendered outside the image widget.
155
+ * Positioned by the parent (ImageWidget) in place of the WidgetChrome toolbar.
156
+ */
157
+ export function CropBar({ cropW, cropH, onSave, onUndo, onCancel, canUndo }) {
198
158
  return (
199
159
  <div
200
- className={`${styles.floatingBar} ${anchorBelow ? styles.floatingBarBelow : ''}`}
201
- style={{ top: barTop, left: barLeft }}
160
+ className={styles.cropBar}
202
161
  onPointerDown={(e) => e.stopPropagation()}
162
+ onMouseDown={(e) => e.stopPropagation()}
203
163
  >
204
164
  <span className={styles.dimensions}>{cropW} × {cropH}</span>
205
165
  <span className={styles.separator} />
206
- <button className={`${styles.floatingBtn} ${styles.floatingBtnSave}`} onClick={onSave}>
166
+ <button className={`${styles.cropBarBtn} ${styles.cropBarBtnSave}`} onClick={onSave}>
207
167
  <CheckIcon /> Save
208
168
  </button>
209
169
  {canUndo && (
210
- <button className={styles.floatingBtn} onClick={onUndo}>
170
+ <button className={styles.cropBarBtn} onClick={onUndo}>
211
171
  <UndoIcon /> Undo
212
172
  </button>
213
173
  )}
214
- <button className={`${styles.floatingBtn} ${styles.floatingBtnCancel}`} onClick={onCancel}>
174
+ <button className={`${styles.cropBarBtn} ${styles.cropBarBtnCancel}`} onClick={onCancel}>
215
175
  <XIcon />
216
176
  </button>
217
177
  </div>
@@ -47,27 +47,29 @@
47
47
  .handleW { left: -6px; top: 50%; transform: translateY(-50%); cursor: w-resize; }
48
48
  .handleE { right: -6px; top: 50%; transform: translateY(-50%); cursor: e-resize; }
49
49
 
50
- /* ── Floating crop bar ────────────────────────────────────── */
50
+ /* ── Crop confirmation bar (rendered outside widget by ImageWidget) ── */
51
51
 
52
- .floatingBar {
53
- position: absolute;
52
+ .cropBar {
54
53
  display: flex;
55
54
  align-items: center;
55
+ justify-content: center;
56
56
  gap: 6px;
57
57
  padding: 4px 8px;
58
- background: rgba(0, 0, 0, 0.75);
59
- backdrop-filter: blur(8px);
60
- -webkit-backdrop-filter: blur(8px);
61
- border-radius: 8px;
62
- box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3);
63
- z-index: 20;
64
- transform: translateX(-50%);
65
- transition: top 80ms ease-out, left 80ms ease-out;
66
- pointer-events: auto;
58
+ background: var(--bgColor-default, #ffffff);
59
+ border: 1.6px solid var(--borderColor-muted, #d0d7de);
60
+ border-radius: 20px;
61
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
67
62
  white-space: nowrap;
63
+ pointer-events: auto;
68
64
  }
69
65
 
70
- .floatingBtn {
66
+ :global([data-sb-canvas-theme^='dark']) .cropBar {
67
+ background: var(--bgColor-muted, #161b22);
68
+ border-color: var(--borderColor-muted, #373e47);
69
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
70
+ }
71
+
72
+ .cropBarBtn {
71
73
  all: unset;
72
74
  cursor: pointer;
73
75
  display: flex;
@@ -77,42 +79,76 @@
77
79
  border-radius: 6px;
78
80
  font-size: 12px;
79
81
  font-weight: 500;
80
- color: #ffffff;
82
+ color: var(--fgColor-default, #1f2328);
81
83
  transition: background 100ms;
82
84
  }
83
85
 
84
- .floatingBtn:hover {
85
- background: rgba(255, 255, 255, 0.15);
86
+ :global([data-sb-canvas-theme^='dark']) .cropBarBtn {
87
+ color: var(--fgColor-default, #e6edf3);
86
88
  }
87
89
 
88
- .floatingBtnSave {
90
+ .cropBarBtn:hover {
91
+ background: var(--bgColor-neutral-muted, #eaeef2);
92
+ }
93
+
94
+ :global([data-sb-canvas-theme^='dark']) .cropBarBtn:hover {
95
+ background: var(--bgColor-neutral-muted, #272c33);
96
+ }
97
+
98
+ .cropBarBtnSave {
89
99
  background: var(--bgColor-success-emphasis, #1a7f37);
90
- color: #ffffff;
100
+ color: var(--fgColor-onEmphasis, #ffffff);
91
101
  }
92
102
 
93
- .floatingBtnSave:hover {
103
+ .cropBarBtnSave:hover {
94
104
  background: var(--bgColor-success-emphasis, #2da44e);
105
+ filter: brightness(1.1);
106
+ }
107
+
108
+ :global([data-sb-canvas-theme^='dark']) .cropBarBtnSave {
109
+ background: var(--bgColor-success-emphasis, #238636);
110
+ color: var(--fgColor-onEmphasis, #ffffff);
95
111
  }
96
112
 
97
- .floatingBtnCancel {
98
- color: rgba(255, 255, 255, 0.7);
113
+ :global([data-sb-canvas-theme^='dark']) .cropBarBtnSave:hover {
114
+ background: var(--bgColor-success-emphasis, #2ea043);
115
+ filter: brightness(1.1);
99
116
  }
100
117
 
101
- .floatingBtnCancel:hover {
102
- color: #ffffff;
103
- background: rgba(255, 255, 255, 0.1);
118
+ .cropBarBtnCancel {
119
+ color: var(--fgColor-muted, #656d76);
120
+ }
121
+
122
+ :global([data-sb-canvas-theme^='dark']) .cropBarBtnCancel {
123
+ color: var(--fgColor-muted, #8b949e);
124
+ }
125
+
126
+ .cropBarBtnCancel:hover {
127
+ color: var(--fgColor-default, #1f2328);
128
+ }
129
+
130
+ :global([data-sb-canvas-theme^='dark']) .cropBarBtnCancel:hover {
131
+ color: var(--fgColor-default, #e6edf3);
104
132
  }
105
133
 
106
134
  .dimensions {
107
135
  font-size: 11px;
108
136
  font-weight: 500;
109
- color: rgba(255, 255, 255, 0.6);
137
+ color: var(--fgColor-muted, #656d76);
110
138
  padding: 0 4px;
111
139
  font-variant-numeric: tabular-nums;
112
140
  }
113
141
 
142
+ :global([data-sb-canvas-theme^='dark']) .dimensions {
143
+ color: var(--fgColor-muted, #8b949e);
144
+ }
145
+
114
146
  .separator {
115
147
  width: 1px;
116
148
  height: 16px;
117
- background: rgba(255, 255, 255, 0.2);
149
+ background: var(--borderColor-muted, #d0d7de);
150
+ }
151
+
152
+ :global([data-sb-canvas-theme^='dark']) .separator {
153
+ background: var(--borderColor-muted, #373e47);
118
154
  }
@@ -2,7 +2,7 @@ import { useRef, useCallback, useState, useMemo, forwardRef, useImperativeHandle
2
2
  import WidgetWrapper from './WidgetWrapper.jsx'
3
3
  import ResizeHandle from './ResizeHandle.jsx'
4
4
  import ExpandedPane from './ExpandedPane.jsx'
5
- import CropOverlay from './CropOverlay.jsx'
5
+ import CropOverlay, { CropBar } from './CropOverlay.jsx'
6
6
  import { readProp } from './widgetProps.js'
7
7
  import { schemas } from './widgetConfig.js'
8
8
  import { toggleImagePrivacy, cropAndUpload } from '../canvasApi.js'
@@ -29,6 +29,7 @@ const ImageWidget = forwardRef(function ImageWidget({ id, props, onUpdate, resiz
29
29
  const [expandMode, setExpandMode] = useState(null)
30
30
  const expanded = expandMode !== null
31
31
  const [cropping, setCropping] = useState(false)
32
+ const [cropRect, setCropRect] = useState(null)
32
33
  const [previousSrc, setPreviousSrc] = useState(null)
33
34
  const [containerSize, setContainerSize] = useState(null)
34
35
 
@@ -54,11 +55,22 @@ const ImageWidget = forwardRef(function ImageWidget({ id, props, onUpdate, resiz
54
55
  onUpdate?.({ width: newWidth, height: newHeight })
55
56
  }, [naturalRatio, width, height, onUpdate])
56
57
 
57
- const handleCropSave = useCallback(async (cropRect) => {
58
- if (!src) return
58
+ const cw = containerSize?.width || width || 400
59
+ const ch = containerSize?.height || height || 300
60
+
61
+ const handleCropSave = useCallback(async () => {
62
+ if (!src || !cropRect) return
63
+ const scaleX = (naturalSize?.width || cw) / cw
64
+ const scaleY = (naturalSize?.height || ch) / ch
65
+ const naturalCropRect = {
66
+ x: Math.round(cropRect.x * scaleX),
67
+ y: Math.round(cropRect.y * scaleY),
68
+ width: Math.round(cropRect.width * scaleX),
69
+ height: Math.round(cropRect.height * scaleY),
70
+ }
59
71
  const canvasId = window.__storyboardCanvasBridgeState?.canvasId || ''
60
72
  try {
61
- const result = await cropAndUpload(src, cropRect, canvasId)
73
+ const result = await cropAndUpload(src, naturalCropRect, canvasId)
62
74
  if (result.success) {
63
75
  setPreviousSrc(src)
64
76
  onUpdate?.({ src: result.filename })
@@ -67,10 +79,12 @@ const ImageWidget = forwardRef(function ImageWidget({ id, props, onUpdate, resiz
67
79
  console.error('[canvas] Failed to crop image:', err)
68
80
  }
69
81
  setCropping(false)
70
- }, [src, onUpdate])
82
+ setCropRect(null)
83
+ }, [src, cropRect, naturalSize, cw, ch, onUpdate])
71
84
 
72
85
  const handleCropCancel = useCallback(() => {
73
86
  setCropping(false)
87
+ setCropRect(null)
74
88
  }, [])
75
89
 
76
90
  const handleCropUndo = useCallback(() => {
@@ -79,6 +93,7 @@ const ImageWidget = forwardRef(function ImageWidget({ id, props, onUpdate, resiz
79
93
  setPreviousSrc(null)
80
94
  }
81
95
  setCropping(false)
96
+ setCropRect(null)
82
97
  }, [previousSrc, onUpdate])
83
98
 
84
99
  useImperativeHandle(ref, () => ({
@@ -88,7 +103,15 @@ const ImageWidget = forwardRef(function ImageWidget({ id, props, onUpdate, resiz
88
103
  if (actionId === 'crop-image') {
89
104
  // Measure container at activation time (not during render)
90
105
  const el = containerRef.current
91
- if (el) setContainerSize({ width: el.offsetWidth, height: el.offsetHeight })
106
+ const w = el?.offsetWidth || width || 400
107
+ const h = el?.offsetHeight || height || 300
108
+ if (el) setContainerSize({ width: w, height: h })
109
+ setCropRect({
110
+ x: Math.round(w * 0.05),
111
+ y: Math.round(h * 0.05),
112
+ width: Math.round(w * 0.9),
113
+ height: Math.round(h * 0.9),
114
+ })
92
115
  setCropping(true)
93
116
  return true
94
117
  }
@@ -104,12 +127,22 @@ const ImageWidget = forwardRef(function ImageWidget({ id, props, onUpdate, resiz
104
127
  } else if (actionId === 'download-image') {
105
128
  if (!src) return
106
129
  const url = getImageUrl(src)
107
- const a = document.createElement('a')
108
- a.href = url
109
- a.download = src.replace(/^~/, '')
110
- document.body.appendChild(a)
111
- a.click()
112
- document.body.removeChild(a)
130
+ fetch(url)
131
+ .then((r) => {
132
+ if (!r.ok) throw new Error(`HTTP ${r.status}`)
133
+ return r.blob()
134
+ })
135
+ .then((blob) => {
136
+ const blobUrl = URL.createObjectURL(blob)
137
+ const a = document.createElement('a')
138
+ a.href = blobUrl
139
+ a.download = src.replace(/^~/, '')
140
+ document.body.appendChild(a)
141
+ a.click()
142
+ document.body.removeChild(a)
143
+ URL.revokeObjectURL(blobUrl)
144
+ })
145
+ .catch((err) => console.error('[canvas] Failed to download image:', err))
113
146
  } else if (actionId === 'copy-as-png') {
114
147
  if (!src) return
115
148
  const url = getImageUrl(src)
@@ -132,6 +165,12 @@ const ImageWidget = forwardRef(function ImageWidget({ id, props, onUpdate, resiz
132
165
  const sizeStyle = {}
133
166
  if (typeof width === 'number') sizeStyle.width = `${width}px`
134
167
 
168
+ // Compute crop dimensions in natural pixels for the CropBar display
169
+ const scaleX = cropRect ? ((naturalSize?.width || cw) / cw) : 1
170
+ const scaleY = cropRect ? ((naturalSize?.height || ch) / ch) : 1
171
+ const cropW = cropRect ? Math.round(cropRect.width * scaleX) : 0
172
+ const cropH = cropRect ? Math.round(cropRect.height * scaleY) : 0
173
+
135
174
  return (
136
175
  <>
137
176
  <WidgetWrapper className={styles.imageWrapper}>
@@ -150,16 +189,13 @@ const ImageWidget = forwardRef(function ImageWidget({ id, props, onUpdate, resiz
150
189
  Private
151
190
  </span>
152
191
  )}
153
- {cropping && (
192
+ {cropping && cropRect && (
154
193
  <CropOverlay
155
- containerWidth={containerSize?.width || width || 400}
156
- containerHeight={containerSize?.height || height || 300}
157
- naturalWidth={naturalSize?.width}
158
- naturalHeight={naturalSize?.height}
159
- onSave={handleCropSave}
194
+ containerWidth={cw}
195
+ containerHeight={ch}
196
+ cropRect={cropRect}
197
+ onCropRectChange={setCropRect}
160
198
  onCancel={handleCropCancel}
161
- onUndo={handleCropUndo}
162
- canUndo={!!previousSrc}
163
199
  />
164
200
  )}
165
201
  </div>
@@ -173,6 +209,18 @@ const ImageWidget = forwardRef(function ImageWidget({ id, props, onUpdate, resiz
173
209
  )}
174
210
  </div>
175
211
  </WidgetWrapper>
212
+ {cropping && cropRect && (
213
+ <div className={styles.cropBarSlot}>
214
+ <CropBar
215
+ cropW={cropW}
216
+ cropH={cropH}
217
+ onSave={handleCropSave}
218
+ onCancel={handleCropCancel}
219
+ onUndo={handleCropUndo}
220
+ canUndo={!!previousSrc}
221
+ />
222
+ </div>
223
+ )}
176
224
  {expanded && (
177
225
  <ImageExpandPane
178
226
  widgetId={id}
@@ -28,11 +28,23 @@
28
28
  pointer-events: none;
29
29
  }
30
30
 
31
- /* Hide the widget toolbar when crop is active (WidgetChrome reads this) */
31
+ /* Hide the widget toolbar when crop is active (replaced by crop bar) */
32
32
  .container[data-crop-active] {
33
33
  overflow: visible;
34
34
  }
35
35
 
36
+ /* Crop bar slot — positioned below the widget like the WidgetChrome toolbar */
37
+ .cropBarSlot {
38
+ display: flex;
39
+ align-items: center;
40
+ justify-content: center;
41
+ position: absolute;
42
+ left: 0;
43
+ right: 0;
44
+ top: calc(100% + 10px);
45
+ z-index: 20;
46
+ }
47
+
36
48
  .privateBadge {
37
49
  position: absolute;
38
50
  top: 20px;
@@ -31,6 +31,9 @@ function postProcessHtml(html) {
31
31
  // Unwrap <details><summary>...</summary><video ...></details> → just the <video>
32
32
  out = out.replace(/<details[^>]*>\s*<summary[^>]*>[\s\S]*?<\/summary>\s*(<video[\s\S]*?<\/video>)\s*<\/details>/gi, '$1')
33
33
 
34
+ // Force remaining <details> elements open so content is visible
35
+ out = out.replace(/<details(?![^>]*\bopen\b)/gi, '<details open')
36
+
34
37
  // Convert bare video URLs (wrapped in <p>) into <video> elements
35
38
  out = out.replace(VIDEO_URL_LINE_RE, (_, url) =>
36
39
  `<video src="${url}" controls preload="none"></video>`
@@ -52,6 +55,14 @@ function postProcessHtml(html) {
52
55
  return `<input ${before}${after}>`
53
56
  })
54
57
 
58
+ // Mark @mention links with a data attribute for pill styling.
59
+ // GitHub API HTML uses class="user-mention" but remark output won't have it.
60
+ // Match <a ...>@username</a> linking to github.com profiles.
61
+ out = out.replace(/<a\s([^>]*)>(@[a-zA-Z0-9_-]+)<\/a>/g, (match, attrs, text) => {
62
+ if (match.includes('data-mention')) return match
63
+ return `<a ${attrs} data-mention>${text}</a>`
64
+ })
65
+
55
66
  return out
56
67
  }
57
68
 
@@ -241,6 +241,22 @@
241
241
  text-decoration: underline;
242
242
  }
243
243
 
244
+ /* @mention pills — blue background + text like GitHub */
245
+ .issueBody a[data-mention] {
246
+ background: var(--bgColor-accent-muted, #ddf4ff);
247
+ color: var(--fgColor-accent, #0969da);
248
+ padding: 1px 6px;
249
+ border-radius: 4px;
250
+ font-weight: 600;
251
+ text-decoration: none;
252
+ }
253
+
254
+ .issueBody a[data-mention]:hover {
255
+ background: var(--bgColor-accent-emphasis, #0969da);
256
+ color: var(--fgColor-onEmphasis, #ffffff);
257
+ text-decoration: none;
258
+ }
259
+
244
260
  .issueBody img {
245
261
  max-width: 100%;
246
262
  height: auto;
@@ -400,15 +416,32 @@
400
416
  margin: 16px 0;
401
417
  }
402
418
 
403
- /* Details/summary — show content expanded, no collapse UI */
419
+ /* Details/summary — interactive collapsible sections */
404
420
  .issueBody details {
405
- border: none;
406
- margin: 0;
421
+ border: 1px solid var(--borderColor-muted, #d8dee4);
422
+ border-radius: 6px;
423
+ margin: 12px 0;
407
424
  padding: 0;
408
425
  }
409
426
 
410
427
  .issueBody summary {
411
- display: none;
428
+ display: list-item;
429
+ cursor: pointer;
430
+ pointer-events: auto;
431
+ font-weight: 600;
432
+ padding: 8px 12px;
433
+ list-style: disclosure-closed inside;
434
+ user-select: none;
435
+ }
436
+
437
+ .issueBody details[open] > summary {
438
+ list-style-type: disclosure-open;
439
+ border-bottom: 1px solid var(--borderColor-muted, #d8dee4);
440
+ margin-bottom: 0;
441
+ }
442
+
443
+ .issueBody details > :not(summary) {
444
+ padding: 0 12px;
412
445
  }
413
446
 
414
447
  .error {
@@ -491,6 +524,8 @@
491
524
  .expandedIssueBody * { pointer-events: auto; }
492
525
  .expandedIssueBody a { color: var(--fgColor-accent, #0969da); text-decoration: none; }
493
526
  .expandedIssueBody a:hover { text-decoration: underline; }
527
+ .expandedIssueBody a[data-mention] { background: var(--bgColor-accent-muted, #ddf4ff); color: var(--fgColor-accent, #0969da); padding: 1px 6px; border-radius: 4px; font-weight: 600; text-decoration: none; }
528
+ .expandedIssueBody a[data-mention]:hover { background: var(--bgColor-accent-emphasis, #0969da); color: var(--fgColor-onEmphasis, #ffffff); text-decoration: none; }
494
529
  .expandedIssueBody img { max-width: 100%; height: auto; border-radius: 6px; margin: 8px 0; display: block; }
495
530
  .expandedIssueBody video { max-width: 100%; height: auto; border-radius: 6px; margin: 8px 0; display: block; }
496
531
  .expandedIssueBody h1 { font-size: 20px; font-weight: 700; margin: 16px 0 8px; border-bottom: 1px solid var(--borderColor-muted, #d8dee4); padding-bottom: 4px; }
@@ -504,6 +539,10 @@
504
539
  .expandedIssueBody ol { margin: 0 0 12px; padding-left: 24px; list-style: decimal; }
505
540
  .expandedIssueBody li { margin: 0 0 4px; display: list-item; }
506
541
  .expandedIssueBody blockquote { border-left: 4px solid var(--borderColor-default, #d0d7de); margin: 12px 0; padding: 4px 16px; color: var(--fgColor-muted, #656d76); }
542
+ .expandedIssueBody details { border: 1px solid var(--borderColor-muted, #d8dee4); border-radius: 6px; margin: 12px 0; padding: 0; }
543
+ .expandedIssueBody summary { display: list-item; cursor: pointer; font-weight: 600; padding: 8px 12px; list-style: disclosure-closed inside; user-select: none; }
544
+ .expandedIssueBody details[open] > summary { list-style-type: disclosure-open; border-bottom: 1px solid var(--borderColor-muted, #d8dee4); margin-bottom: 0; }
545
+ .expandedIssueBody details > :not(summary) { padding: 0 12px; }
507
546
 
508
547
  /* ── Expanded plain link view ─────────────────────────────────────── */
509
548
 
@@ -92,6 +92,13 @@
92
92
  border-radius: 4px;
93
93
  }
94
94
 
95
+ /* Hide toolbar and connector anchors when a child widget is in crop mode */
96
+ .chromeContainer:has([data-crop-active]) .toolbar,
97
+ .chromeContainer:has([data-crop-active]) .anchorPort {
98
+ visibility: hidden;
99
+ pointer-events: none;
100
+ }
101
+
95
102
  .widgetSlotSelected {
96
103
  outline: 4px solid var(--bgColor-accent-emphasis, #2f81f7);
97
104
  outline-offset: 2px;
package/src/context.jsx CHANGED
@@ -263,7 +263,7 @@ function StoryboardProviderInner({ flowName, sceneName, recordName, recordParam,
263
263
  cleanup = mountDesignModes()
264
264
  })
265
265
  .catch(() => {
266
- // Svelte UI not available — degrade gracefully
266
+ // UI not available — degrade gracefully
267
267
  })
268
268
 
269
269
  return () => cleanup?.()
package/src/index.js CHANGED
@@ -34,7 +34,7 @@ export { installHashPreserver } from './hashPreserver.js'
34
34
  export { FormContext } from './context/FormContext.js'
35
35
 
36
36
  // Design mode hook (keep — React apps may still read mode state)
37
- // ModeSwitch and ToolbarShell UI moved to @dfosco/storyboard-svelte-ui
37
+
38
38
 
39
39
  // Workspace dashboard
40
40
  export { default as Workspace } from './Workspace.jsx'
@@ -84,7 +84,8 @@ export default function ComponentSetPage({ name }) {
84
84
 
85
85
  const gridRef = useRef(null)
86
86
 
87
- // Measure all cell content elements and snap cells to the largest
87
+ // Measure all cell content elements and snap cells to the largest.
88
+ // Posts the total grid size to the parent widget so it can auto-size.
88
89
  useLayoutEffect(() => {
89
90
  const grid = gridRef.current
90
91
  if (!grid || !exports) return
@@ -101,6 +102,17 @@ export default function ComponentSetPage({ name }) {
101
102
  }
102
103
  grid.style.setProperty('--cell-snap-w', `${maxW}px`)
103
104
  grid.style.setProperty('--cell-snap-h', `${maxH}px`)
105
+
106
+ // Post total grid size to parent widget
107
+ if (isEmbed && window.parent !== window) {
108
+ requestAnimationFrame(() => {
109
+ window.parent.postMessage({
110
+ type: 'storyboard:component-set:resize',
111
+ width: grid.scrollWidth,
112
+ height: grid.scrollHeight,
113
+ }, '*')
114
+ })
115
+ }
104
116
  }
105
117
 
106
118
  // Measure after fonts load and initial paint
@@ -110,7 +122,7 @@ export default function ComponentSetPage({ name }) {
110
122
  const ro = new ResizeObserver(measure)
111
123
  for (const el of cells) ro.observe(el)
112
124
  return () => ro.disconnect()
113
- }, [exports, layout])
125
+ }, [exports, layout, isEmbed])
114
126
 
115
127
  const handleSelect = useCallback((exportName) => {
116
128
  const params = new URLSearchParams(location.search)
@@ -1,12 +1,11 @@
1
1
  /* ComponentSetPage — grid layout for all exports of a story */
2
2
 
3
3
  .grid {
4
- background-color: var(--bgColor-muted, #f6f8fa);
4
+ background-color: var(--bgColor-default, #ffffff);
5
5
  display: flex;
6
6
  flex-wrap: nowrap;
7
- gap: 12px;
8
- padding: 12px;
9
- min-height: 100vh;
7
+ gap: 0;
8
+ padding: 0;
10
9
  width: max-content;
11
10
  min-width: 100%;
12
11
  box-sizing: border-box;
@@ -24,20 +23,30 @@
24
23
  flex: 0 0 auto;
25
24
  display: flex;
26
25
  flex-direction: column;
27
- border: 2px solid var(--borderColor-muted, #d8dee4);
28
- border-radius: 2px;
26
+ border: 1px solid var(--borderColor-muted, #d8dee4);
27
+ border-radius: 0;
29
28
  overflow: hidden;
30
29
  transition: border-color 120ms ease, box-shadow 120ms ease;
31
30
  position: relative;
32
- background: var(--bgColor-default, #ffffff);
31
+ background: var(--bgColor-muted, #f6f8fa);
32
+ }
33
+
34
+ /* Collapse double borders between adjacent cells */
35
+ .grid[data-layout="horizontal"] .cell + .cell {
36
+ border-left: none;
33
37
  }
34
38
 
35
- /* In horizontal layout, each cell snaps to the widest component */
39
+ .grid[data-layout="vertical"] .cell + .cell {
40
+ border-top: none;
41
+ }
42
+
43
+ /* In horizontal layout, cells snap to widest and tallest component */
36
44
  .grid[data-layout="horizontal"] .cell {
37
45
  min-width: var(--cell-snap-w, 200px);
46
+ min-height: var(--cell-snap-h, 60px);
38
47
  }
39
48
 
40
- /* In vertical layout, each cell snaps to the tallest component */
49
+ /* In vertical layout, cells snap to widest and tallest component */
41
50
  .grid[data-layout="vertical"] .cell {
42
51
  min-height: var(--cell-snap-h, 120px);
43
52
  min-width: 100%;
@@ -58,14 +67,12 @@
58
67
  font-size: 11px;
59
68
  font-weight: 600;
60
69
  color: var(--fgColor-muted, #656d76);
61
- background: var(--bgColor-muted, #f6f8fa);
70
+ background: var(--bgColor-default, #ffffff);
62
71
  border-bottom: 1px solid var(--borderColor-muted, #d8dee4);
63
72
  cursor: pointer;
64
73
  user-select: none;
65
74
  transition: background 100ms ease, color 100ms ease;
66
75
  flex-shrink: 0;
67
- /* Round top corners to match cell */
68
- border-radius: 6px 6px 0 0;
69
76
  }
70
77
 
71
78
  .cellLabel:hover {
@@ -99,6 +106,7 @@
99
106
  flex: 1;
100
107
  overflow: visible;
101
108
  position: relative;
109
+ padding: 12px;
102
110
  }
103
111
 
104
112
  .error {