@bycrux/editor 0.4.4 → 0.4.6

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,6 +1,6 @@
1
1
  {
2
2
  "name": "@bycrux/editor",
3
- "version": "0.4.4",
3
+ "version": "0.4.6",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "exports": {
@@ -122,14 +122,14 @@ export default function AddElementMenu({ project, selectedSlideId, adapter, onAd
122
122
 
123
123
  return (
124
124
  <div className="flex flex-col gap-2">
125
- <div className="flex items-center gap-2">
125
+ <div className="grid grid-cols-2 gap-2">
126
126
  {adapter.generateImage && (
127
127
  <Button
128
128
  size="sm"
129
129
  variant="outline"
130
130
  disabled={disabled}
131
131
  onClick={() => { setShowPrompt(p => !p); setGenError(null) }}
132
- className="text-xs"
132
+ className="text-xs w-full justify-center whitespace-nowrap"
133
133
  >
134
134
  + AI Image
135
135
  </Button>
@@ -139,7 +139,7 @@ export default function AddElementMenu({ project, selectedSlideId, adapter, onAd
139
139
  variant="outline"
140
140
  disabled={disabled}
141
141
  onClick={() => fileInputRef.current?.click()}
142
- className="text-xs"
142
+ className="text-xs w-full justify-center whitespace-nowrap"
143
143
  >
144
144
  + Upload Image
145
145
  </Button>
@@ -156,7 +156,7 @@ export default function AddElementMenu({ project, selectedSlideId, adapter, onAd
156
156
  variant="outline"
157
157
  disabled={disabled || addingText}
158
158
  onClick={handleAddText}
159
- className="text-xs"
159
+ className="text-xs w-full justify-center whitespace-nowrap"
160
160
  >
161
161
  {addingText ? 'Adding…' : '+ Text'}
162
162
  </Button>
@@ -166,7 +166,7 @@ export default function AddElementMenu({ project, selectedSlideId, adapter, onAd
166
166
  variant="outline"
167
167
  disabled={disabled}
168
168
  onClick={() => setShowOverlayPicker(true)}
169
- className="text-xs"
169
+ className="text-xs w-full justify-center whitespace-nowrap"
170
170
  >
171
171
  + Overlay
172
172
  </Button>
@@ -70,7 +70,7 @@ function SlideGrid({
70
70
  }
71
71
 
72
72
  return (
73
- <div className="w-56 flex-shrink-0 flex flex-col border-r border-[var(--editor-border)] bg-[var(--editor-bg)] overflow-y-auto">
73
+ <div className="w-56 flex-shrink-0 flex flex-col border-r border-[var(--editor-border)] bg-[var(--editor-bg)] overflow-y-auto min-h-0 h-full">
74
74
  <div className="px-3 py-2 border-b border-[var(--editor-border)]">
75
75
  <span className="text-xs font-semibold text-[var(--editor-text)]/60 uppercase tracking-wider">Slides</span>
76
76
  </div>
@@ -365,10 +365,11 @@ export default function CarouselEditor<P extends Project = Project>({ project: i
365
365
 
366
366
  return (
367
367
  <div ref={containerRef} className="flex flex-col h-full overflow-y-auto bg-[var(--editor-bg)]">
368
- {/* TOP: slide rail | canvas | editing panel (right). Given a generous
369
- viewport-relative height so the slide renders large; the project-media
370
- region flows beneath and the whole editor scrolls vertically. */}
371
- <div className="flex flex-shrink-0 min-h-[62vh] overflow-hidden">
368
+ {/* TOP: slide rail | canvas | editing panel (right). Fixed viewport-relative
369
+ height with min-h-0 so each of the three columns establishes its own
370
+ independent scroll context; the project-media region flows beneath and
371
+ the whole editor scrolls vertically. */}
372
+ <div className="flex flex-shrink-0 h-[78vh] min-h-0 overflow-hidden">
372
373
  <SlideGrid
373
374
  project={project}
374
375
  slides={slides}
@@ -382,40 +383,49 @@ export default function CarouselEditor<P extends Project = Project>({ project: i
382
383
  compileOverlay={(t) => adapter.compileOverlay(t)}
383
384
  />
384
385
 
385
- <div ref={canvasContainerRef} className="relative flex-1 flex flex-col items-center justify-center gap-4 overflow-hidden p-4">
386
- <button
387
- onClick={handleRefresh}
388
- disabled={refreshing}
389
- className={`absolute top-3 left-3 z-30 flex items-center gap-2 px-3 py-2 rounded-md border transition-colors ${
390
- refreshState === 'err'
391
- ? 'text-red-300 border-red-500/40 bg-red-950/60 hover:bg-red-900/70'
392
- : 'text-[var(--editor-text)] border-[var(--editor-border)] bg-[var(--editor-surface)]/80 hover:text-[var(--editor-text)] hover:border-[var(--editor-accent)] hover:bg-[var(--editor-surface)]'
393
- }`}
394
- title={refreshState === 'err' ? 'Refresh failed — check connection' : 'Refresh project'}
395
- >
396
- {refreshState === 'err' ? <AlertCircle size={18} /> : <RefreshCw size={18} className={refreshing ? 'animate-spin' : ''} />}
397
- <span className="text-xs font-medium">Refresh</span>
398
- </button>
399
-
400
- <div className="absolute top-3 right-3 z-30 flex items-center gap-2">
401
- {slots?.toolbarActions}
386
+ {/* CANVAS COLUMN: a pinned toolbar row on top, then the independently
387
+ scrolling slide-rendering area below it. */}
388
+ <div className="relative flex-1 flex flex-col min-h-0 overflow-hidden">
389
+ {/* TOOLBAR ROW: Refresh on the left; host toolbar actions + Render on the
390
+ right. Pinned (shrink-0) above the scrolling canvas area. */}
391
+ <div className="flex-shrink-0 flex items-center justify-between gap-2 px-4 py-3 border-b border-[var(--editor-border)]">
402
392
  <button
403
- onClick={handleRender}
404
- disabled={rendering || project.status === 'pending' || slides.length === 0}
405
- className="flex items-center gap-2 px-3 py-2 rounded-md border border-[var(--editor-accent)] bg-[var(--editor-accent)] text-[var(--editor-accent-foreground)] hover:opacity-90 transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
406
- title={
407
- project.status === 'pending'
408
- ? 'Wait for the agent to finish before rendering'
409
- : slides.length === 0
410
- ? 'Add slides before rendering'
411
- : 'Render all slides as PNGs'
412
- }
393
+ onClick={handleRefresh}
394
+ disabled={refreshing}
395
+ className={`flex items-center gap-2 px-3 py-2 rounded-md border transition-colors ${
396
+ refreshState === 'err'
397
+ ? 'text-red-300 border-red-500/40 bg-red-950/60 hover:bg-red-900/70'
398
+ : 'text-[var(--editor-text)] border-[var(--editor-border)] bg-[var(--editor-surface)]/80 hover:text-[var(--editor-text)] hover:border-[var(--editor-accent)] hover:bg-[var(--editor-surface)]'
399
+ }`}
400
+ title={refreshState === 'err' ? 'Refresh failed check connection' : 'Refresh project'}
413
401
  >
414
- <Download size={18} />
415
- <span className="text-xs font-medium">{rendering ? 'Starting…' : 'Render'}</span>
402
+ {refreshState === 'err' ? <AlertCircle size={18} /> : <RefreshCw size={18} className={refreshing ? 'animate-spin' : ''} />}
403
+ <span className="text-xs font-medium">Refresh</span>
416
404
  </button>
405
+
406
+ <div className="flex items-center gap-2">
407
+ {slots?.toolbarActions}
408
+ <button
409
+ onClick={handleRender}
410
+ disabled={rendering || project.status === 'pending' || slides.length === 0}
411
+ className="flex items-center gap-2 px-3 py-2 rounded-md border border-[var(--editor-accent)] bg-[var(--editor-accent)] text-[var(--editor-accent-foreground)] hover:opacity-90 transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
412
+ title={
413
+ project.status === 'pending'
414
+ ? 'Wait for the agent to finish before rendering'
415
+ : slides.length === 0
416
+ ? 'Add slides before rendering'
417
+ : 'Render all slides as PNGs'
418
+ }
419
+ >
420
+ <Download size={18} />
421
+ <span className="text-xs font-medium">{rendering ? 'Starting…' : 'Render'}</span>
422
+ </button>
423
+ </div>
417
424
  </div>
418
425
 
426
+ {/* SCROLL AREA: the slide viewport. ResizeObserver lives here so
427
+ canvasScale measures the slide-rendering area, not the toolbar. */}
428
+ <div ref={canvasContainerRef} className="relative flex-1 flex flex-col items-center justify-center gap-4 overflow-y-auto min-h-0 p-4">
419
429
  {project.status === 'pending' ? (
420
430
  <div className="flex flex-col items-center gap-6 text-center max-w-lg w-full">
421
431
  {slots?.pendingStatus ?? (
@@ -493,10 +503,11 @@ export default function CarouselEditor<P extends Project = Project>({ project: i
493
503
  </div>
494
504
  )}
495
505
  </div>
506
+ </div>
496
507
 
497
508
  {/* RIGHT: the slide editor (add-element toolbar + property panel),
498
- beside the canvas with its own vertical scroll. */}
499
- <div className="w-[24rem] flex-shrink-0 border-l border-[var(--editor-border)] flex flex-col overflow-y-auto bg-[var(--editor-bg)]">
509
+ beside the canvas with its own independent vertical scroll. */}
510
+ <div className="w-[24rem] flex-shrink-0 border-l border-[var(--editor-border)] flex flex-col overflow-y-auto min-h-0 h-full bg-[var(--editor-bg)]">
500
511
  {selectedSlide && project.status !== 'pending' && (
501
512
  <div className="px-4 py-2 border-b border-[var(--editor-border)]">
502
513
  <AddElementMenu
@@ -9,7 +9,7 @@ import type {
9
9
  GlobalOverlayProp,
10
10
  EditorAdapter,
11
11
  } from '../types'
12
- import { Button, cn } from '../ui'
12
+ import { Button, cn, inspectorInputClass } from '../ui'
13
13
  import { TextFormattingToolbar } from '../text/TextFormattingToolbar'
14
14
 
15
15
  function parseNumber(v: string): number | null {
@@ -17,6 +17,13 @@ function parseNumber(v: string): number | null {
17
17
  return Number.isFinite(n) ? n : null
18
18
  }
19
19
 
20
+ // Shared small muted label that sits just above an inspector control.
21
+ const fieldLabelClass = 'text-[11px] uppercase tracking-wide text-[var(--editor-text)]/55'
22
+
23
+ // Section header: SLIDE / OVERLAY / IMAGE.
24
+ const sectionHeaderClass =
25
+ 'text-xs font-semibold uppercase tracking-wider text-[var(--editor-text)]/70'
26
+
20
27
  interface Props {
21
28
  project: Project
22
29
  slide?: Slide
@@ -64,7 +71,7 @@ function HideToggle({
64
71
  title={isHidden ? 'Show in editor' : 'Hide from editor'}
65
72
  aria-label={isHidden ? 'Show in editor' : 'Hide from editor'}
66
73
  aria-pressed={isHidden}
67
- className="text-[var(--editor-text)]/60 hover:text-[var(--editor-text)] px-1"
74
+ className="flex h-7 w-7 items-center justify-center rounded text-[var(--editor-text)]/60 hover:bg-[var(--editor-surface)] hover:text-[var(--editor-text)]"
68
75
  >
69
76
  {isHidden ? <EyeOff className="h-3.5 w-3.5" /> : <Eye className="h-3.5 w-3.5" />}
70
77
  </button>
@@ -78,8 +85,8 @@ function numInput(
78
85
  opts?: { min?: number; max?: number; step?: number }
79
86
  ) {
80
87
  return (
81
- <label className="flex flex-col gap-0.5">
82
- <span className="text-xs text-[var(--editor-text)]/60">{label}</span>
88
+ <label className="flex flex-col gap-1">
89
+ <span className={fieldLabelClass}>{label}</span>
83
90
  <input
84
91
  type="number"
85
92
  value={value}
@@ -90,7 +97,7 @@ function numInput(
90
97
  const parsed = parseNumber(e.target.value)
91
98
  if (parsed !== null) onChange(parsed)
92
99
  }}
93
- className="bg-[var(--editor-surface)] border border-[var(--editor-border)] rounded px-2 py-1 text-xs text-[var(--editor-text)] focus:outline-none focus:border-[var(--editor-accent)] w-full"
100
+ className={cn(inspectorInputClass, 'text-right')}
94
101
  />
95
102
  </label>
96
103
  )
@@ -109,36 +116,35 @@ function PropEditor({
109
116
 
110
117
  if (type === 'bool') {
111
118
  return (
112
- <label className="flex items-center gap-2 cursor-pointer" title={description}>
119
+ <label className="flex cursor-pointer items-center gap-2" title={description}>
113
120
  <input
114
121
  type="checkbox"
115
122
  checked={Boolean(value)}
116
123
  onChange={e => onChange(e.target.checked)}
117
- className="accent-[var(--editor-accent)]"
124
+ className="h-4 w-4 accent-[var(--editor-accent)]"
118
125
  />
119
- <span className="text-xs text-[var(--editor-text)]">{name}</span>
126
+ <span className="text-sm text-[var(--editor-text)]">{name}</span>
120
127
  </label>
121
128
  )
122
129
  }
123
130
 
124
131
  if (type === 'color') {
125
132
  return (
126
- <label className="flex flex-col gap-0.5" title={description}>
127
- <span className="text-xs text-[var(--editor-text)]/60">{name}</span>
128
- <input
129
- type="color"
133
+ <div className="flex flex-col gap-1" title={description}>
134
+ <span className={fieldLabelClass}>{name}</span>
135
+ <SwatchInput
130
136
  value={String(value ?? '#000000')}
131
- onChange={e => onChange(e.target.value)}
132
- className="w-full h-7 bg-[var(--editor-surface)] border border-[var(--editor-border)] rounded cursor-pointer"
137
+ onChange={v => onChange(v)}
138
+ ariaLabel={name}
133
139
  />
134
- </label>
140
+ </div>
135
141
  )
136
142
  }
137
143
 
138
144
  if (type === 'int' || type === 'float') {
139
145
  return (
140
- <label className="flex flex-col gap-0.5" title={description}>
141
- <span className="text-xs text-[var(--editor-text)]/60">{name}</span>
146
+ <label className="flex flex-col gap-1" title={description}>
147
+ <span className={fieldLabelClass}>{name}</span>
142
148
  <input
143
149
  type="number"
144
150
  value={Number(value ?? 0)}
@@ -147,7 +153,7 @@ function PropEditor({
147
153
  const parsed = parseNumber(e.target.value)
148
154
  if (parsed !== null) onChange(parsed)
149
155
  }}
150
- className="bg-[var(--editor-surface)] border border-[var(--editor-border)] rounded px-2 py-1 text-xs text-[var(--editor-text)] focus:outline-none focus:border-[var(--editor-accent)] w-full"
156
+ className={cn(inspectorInputClass, 'text-right')}
151
157
  />
152
158
  </label>
153
159
  )
@@ -155,18 +161,52 @@ function PropEditor({
155
161
 
156
162
  // string fallback
157
163
  return (
158
- <label className="flex flex-col gap-0.5" title={description}>
159
- <span className="text-xs text-[var(--editor-text)]/60">{name}</span>
164
+ <label className="flex flex-col gap-1" title={description}>
165
+ <span className={fieldLabelClass}>{name}</span>
160
166
  <input
161
167
  type="text"
162
168
  value={String(value ?? '')}
163
169
  onChange={e => onChange(e.target.value)}
164
- className="bg-[var(--editor-surface)] border border-[var(--editor-border)] rounded px-2 py-1 text-xs text-[var(--editor-text)] focus:outline-none focus:border-[var(--editor-accent)] w-full"
170
+ className={inspectorInputClass}
165
171
  />
166
172
  </label>
167
173
  )
168
174
  }
169
175
 
176
+ // Color control that actually reads as one: a filled rounded swatch (the live
177
+ // value) sitting next to the hex string, the whole row clickable to open the
178
+ // native color picker. The transparent <input type="color"> overlays the
179
+ // swatch so the OS picker anchors there.
180
+ function SwatchInput({
181
+ value,
182
+ onChange,
183
+ ariaLabel,
184
+ }: {
185
+ value: string
186
+ onChange: (v: string) => void
187
+ ariaLabel: string
188
+ }) {
189
+ return (
190
+ <div className="flex items-center gap-2.5">
191
+ <label className="relative h-7 w-7 shrink-0 cursor-pointer">
192
+ <span
193
+ className="block h-full w-full rounded-md border border-[var(--editor-border)] shadow-sm"
194
+ style={{ backgroundColor: value }}
195
+ aria-hidden
196
+ />
197
+ <input
198
+ type="color"
199
+ value={value}
200
+ onChange={e => onChange(e.target.value)}
201
+ className="absolute inset-0 h-full w-full cursor-pointer opacity-0"
202
+ aria-label={ariaLabel}
203
+ />
204
+ </label>
205
+ <span className="font-mono text-sm uppercase text-[var(--editor-text)]/80">{value}</span>
206
+ </div>
207
+ )
208
+ }
209
+
170
210
  export default function SlidePropertyPanel({
171
211
  project,
172
212
  slide,
@@ -222,48 +262,45 @@ export default function SlidePropertyPanel({
222
262
 
223
263
  return (
224
264
  <div className={cn('w-80 flex-shrink-0 border-l border-[var(--editor-border)] flex flex-col overflow-y-auto bg-[var(--editor-bg)]', className)}>
225
- {/* Slide header */}
226
- <div className="px-4 py-3 border-b border-[var(--editor-border)]">
227
- <div className="text-xs font-semibold text-[var(--editor-text)]/60 uppercase tracking-wider mb-2">Slide</div>
228
- <div className="flex flex-col gap-2">
229
- <label className="flex flex-col gap-0.5">
230
- <span className="text-xs text-[var(--editor-text)]/60">Background color</span>
231
- <input
232
- type="color"
233
- value={slide.base_color || '#ffffff'}
234
- onChange={e => onSlideChange({ base_color: e.target.value })}
235
- className="w-full h-7 bg-[var(--editor-surface)] border border-[var(--editor-border)] rounded cursor-pointer"
236
- />
237
- </label>
238
- <div className="flex gap-2">
239
- <Button
240
- size="sm"
241
- variant="outline"
242
- className="flex-1 text-xs"
243
- onClick={() => onDuplicateSlide(slide.id)}
244
- >
245
- Duplicate
246
- </Button>
247
- <Button
248
- size="sm"
249
- variant="outline"
250
- className="flex-1 text-xs text-red-400 hover:text-red-300"
251
- onClick={() => onDeleteSlide(slide.id)}
252
- >
253
- Delete
254
- </Button>
255
- </div>
265
+ {/* Slide section */}
266
+ <div className="flex flex-col gap-4 p-4">
267
+ <div className={sectionHeaderClass}>Slide</div>
268
+ <div className="flex flex-col gap-1">
269
+ <span className={fieldLabelClass}>Background color</span>
270
+ <SwatchInput
271
+ value={slide.base_color || '#ffffff'}
272
+ onChange={v => onSlideChange({ base_color: v })}
273
+ ariaLabel="Background color"
274
+ />
275
+ </div>
276
+ <div className="flex gap-2">
277
+ <Button
278
+ size="sm"
279
+ variant="secondary"
280
+ className="flex-1"
281
+ onClick={() => onDuplicateSlide(slide.id)}
282
+ >
283
+ Duplicate
284
+ </Button>
285
+ <Button
286
+ size="sm"
287
+ variant="danger"
288
+ className="flex-1"
289
+ onClick={() => onDeleteSlide(slide.id)}
290
+ >
291
+ Delete
292
+ </Button>
256
293
  </div>
257
294
  </div>
258
295
 
259
296
  {/* Element section */}
260
297
  {element && (
261
- <div className="px-4 py-3 flex flex-col gap-3">
298
+ <div className="flex flex-col gap-4 border-t border-[var(--editor-border)] p-4">
262
299
  <div className="flex items-center justify-between">
263
- <span className="text-xs font-semibold text-[var(--editor-text)]/60 uppercase tracking-wider">
300
+ <span className={sectionHeaderClass}>
264
301
  {element.type === 'image' ? 'Image' : 'Overlay'}
265
302
  </span>
266
- <div className="flex items-center gap-1">
303
+ <div className="flex items-center gap-0.5">
267
304
  <HideToggle
268
305
  elementId={element.id}
269
306
  isHidden={hiddenElementIds?.includes(element.id) ?? false}
@@ -271,14 +308,14 @@ export default function SlidePropertyPanel({
271
308
  />
272
309
  <button
273
310
  onClick={() => onReorderElement(slide.id, element.id, 'forward')}
274
- className="text-xs text-[var(--editor-text)]/60 hover:text-[var(--editor-text)] px-1"
311
+ className="flex h-7 w-7 items-center justify-center rounded text-[var(--editor-text)]/60 hover:bg-[var(--editor-surface)] hover:text-[var(--editor-text)]"
275
312
  title="Bring forward"
276
313
  >
277
314
 
278
315
  </button>
279
316
  <button
280
317
  onClick={() => onReorderElement(slide.id, element.id, 'backward')}
281
- className="text-xs text-[var(--editor-text)]/60 hover:text-[var(--editor-text)] px-1"
318
+ className="flex h-7 w-7 items-center justify-center rounded text-[var(--editor-text)]/60 hover:bg-[var(--editor-surface)] hover:text-[var(--editor-text)]"
282
319
  title="Send backward"
283
320
  >
284
321
 
@@ -287,27 +324,29 @@ export default function SlidePropertyPanel({
287
324
  </div>
288
325
 
289
326
  {/* Transform */}
290
- <div className="grid grid-cols-2 gap-2">
291
- {numInput('X', element.x, v => onElementChange({ x: v }))}
292
- {numInput('Y', element.y, v => onElementChange({ y: v }))}
293
- {numInput('W', element.w, v => onElementChange({ w: v }), { min: 1 })}
294
- {numInput('H', element.h, v => onElementChange({ h: v }), { min: 1 })}
327
+ <div className="flex flex-col gap-3">
328
+ <div className="grid grid-cols-2 gap-3">
329
+ {numInput('X', element.x, v => onElementChange({ x: v }))}
330
+ {numInput('Y', element.y, v => onElementChange({ y: v }))}
331
+ {numInput('W', element.w, v => onElementChange({ w: v }), { min: 1 })}
332
+ {numInput('H', element.h, v => onElementChange({ h: v }), { min: 1 })}
333
+ </div>
295
334
  {numInput('Rotation', element.rotation ?? 0, v => onElementChange({ rotation: v }), { min: -360, max: 360 })}
296
335
  </div>
297
336
 
298
337
  {/* Image-specific */}
299
338
  {element.type === 'image' && (
300
- <div className="flex flex-col gap-1.5">
301
- <div className="flex flex-col gap-0.5">
302
- <span className="text-xs text-[var(--editor-text)]/60">Source</span>
303
- <span className="text-xs text-[var(--editor-text)] truncate" title={element.src}>
339
+ <div className="flex flex-col gap-2">
340
+ <div className="flex flex-col gap-1">
341
+ <span className={fieldLabelClass}>Source</span>
342
+ <span className="truncate text-sm text-[var(--editor-text)]" title={element.src}>
304
343
  {element.src.split('/').pop() || element.src}
305
344
  </span>
306
345
  </div>
307
346
  <Button
308
347
  size="sm"
309
- variant="outline"
310
- className="text-xs flex items-center gap-1.5"
348
+ variant="secondary"
349
+ className="flex items-center gap-1.5"
311
350
  disabled={(element.rotation ?? 0) !== 0 || !onEnterCrop}
312
351
  title={
313
352
  (element.rotation ?? 0) !== 0
@@ -324,7 +363,7 @@ export default function SlidePropertyPanel({
324
363
 
325
364
  {/* Overlay-specific */}
326
365
  {overlayEl && (
327
- <div className="flex flex-col gap-2">
366
+ <div className="flex flex-col gap-4">
328
367
  {/* Rich-text formatting (bold/italic/case/color/align + font family
329
368
  & size pickers) for overlays exposing the standard text contract. */}
330
369
  {updateOverlayProp && (
@@ -335,17 +374,17 @@ export default function SlidePropertyPanel({
335
374
  />
336
375
  )}
337
376
 
338
- <div className="grid grid-cols-2 gap-2">
377
+ <div className="grid grid-cols-2 gap-3">
339
378
  {numInput('Frame', overlayEl.frame, v => onElementChange({ frame: v }), { min: 0 })}
340
379
  </div>
341
380
 
342
381
  {schemasLoading && (
343
- <div className="text-xs text-[var(--editor-text)]/60">Loading overlay props…</div>
382
+ <div className="text-xs text-[var(--editor-text)]/55">Loading overlay props…</div>
344
383
  )}
345
384
 
346
385
  {!schemasLoading && overlaySchema && overlaySchema.props.length > 0 && (
347
- <div className="flex flex-col gap-2">
348
- <span className="text-xs text-[var(--editor-text)]/60 font-medium">Props</span>
386
+ <div className="flex flex-col gap-3">
387
+ <span className={fieldLabelClass}>Props</span>
349
388
  {overlaySchema.props.map(prop => (
350
389
  <PropEditor
351
390
  key={prop.name}
@@ -367,19 +406,19 @@ export default function SlidePropertyPanel({
367
406
  )}
368
407
 
369
408
  {/* Element actions */}
370
- <div className="flex gap-2 pt-1">
409
+ <div className="flex gap-2">
371
410
  <Button
372
411
  size="sm"
373
- variant="outline"
374
- className="flex-1 text-xs"
412
+ variant="secondary"
413
+ className="flex-1"
375
414
  onClick={() => onDuplicateElement(slide.id, element.id)}
376
415
  >
377
416
  Duplicate
378
417
  </Button>
379
418
  <Button
380
419
  size="sm"
381
- variant="outline"
382
- className="flex-1 text-xs text-red-400 hover:text-red-300"
420
+ variant="danger"
421
+ className="flex-1"
383
422
  onClick={() => onDeleteElement(slide.id, element.id)}
384
423
  >
385
424
  Delete
@@ -95,7 +95,7 @@ export function FontFamilyPicker({ value, onChange, disabled, className, buttonC
95
95
  onClick={() => setOpen((o) => !o)}
96
96
  className={
97
97
  buttonClassName ??
98
- 'flex w-full items-center gap-1 rounded-md border border-[var(--editor-border)] bg-[var(--editor-surface)] px-2 py-1 text-sm text-[var(--editor-text)] hover:opacity-90 focus:outline-none focus:ring-1 focus:ring-[var(--editor-accent)] disabled:opacity-50'
98
+ 'flex h-8 w-full items-center gap-1 rounded-md border border-[var(--editor-border)] bg-[var(--editor-surface)] px-2.5 text-sm text-[var(--editor-text)] hover:border-[var(--editor-accent)] focus:outline-none focus:ring-1 focus:ring-[var(--editor-accent)] disabled:opacity-50'
99
99
  }
100
100
  style={displayStyle}
101
101
  aria-haspopup="listbox"
@@ -210,7 +210,7 @@ export function FontSizePicker({ value, onChange, disabled, min = 8, max = 9999,
210
210
  }}
211
211
  className={
212
212
  className ??
213
- 'w-14 rounded-md border border-[var(--editor-border)] bg-[var(--editor-surface)] px-2 py-1 text-sm text-[var(--editor-text)] focus:outline-none focus:ring-1 focus:ring-[var(--editor-accent)] disabled:opacity-50'
213
+ 'h-8 w-14 rounded-md border border-[var(--editor-border)] bg-[var(--editor-surface)] px-2.5 text-sm text-[var(--editor-text)] focus:outline-none focus:border-[var(--editor-accent)] focus:ring-1 focus:ring-[var(--editor-accent)] disabled:opacity-50'
214
214
  }
215
215
  aria-label="Font size"
216
216
  />
@@ -96,15 +96,16 @@ export function TextFormattingToolbar({
96
96
  const rawColor = readPropAsString(element, 'color')
97
97
  const colorValue = HEX_PATTERN.test(rawColor) && rawColor.length === 7 ? rawColor : '#111111'
98
98
 
99
- // Themed button styles: surface base, accent-tinted hover/active, accent ring.
100
- const toolbarBtnBase = 'flex items-center justify-center rounded px-1.5 py-1 text-sm transition-colors hover:bg-[var(--editor-accent)]/20 focus:outline-none focus:ring-1 focus:ring-[var(--editor-accent)]'
101
- const toolbarBtnActive = 'bg-[var(--editor-accent)]/20 text-[var(--editor-text)]'
102
- const toolbarBtnInactive = 'text-[var(--editor-text)]/60'
103
-
104
- // Divider visibility helpers
99
+ // Themed toggle styles: consistent square buttons. Active = solid accent fill
100
+ // with accent-foreground text; inactive = themed surface + border that hovers
101
+ // toward the surface tone. One look for B / I / case / align across the bar.
102
+ const toolbarBtnBase = 'flex h-7 min-w-[1.75rem] items-center justify-center rounded-md border px-1.5 text-sm transition-colors focus:outline-none focus:ring-1 focus:ring-[var(--editor-accent)]'
103
+ const toolbarBtnActive = 'border-[var(--editor-accent)] bg-[var(--editor-accent)] text-[var(--editor-accent-foreground)]'
104
+ const toolbarBtnInactive = 'border-[var(--editor-border)] bg-[var(--editor-surface)] text-[var(--editor-text)]/70 hover:text-[var(--editor-text)] hover:border-[var(--editor-accent)]'
105
+
106
+ // Segment visibility helpers — group toggles vs size/font vs alignment.
105
107
  const hasLeftGroup = supported.has('fontWeight') || supported.has('fontStyle') || supported.has('textTransform') || supported.has('color')
106
108
  const hasMidGroup = supported.has('fontSize') || supported.has('fontFamily')
107
- const hasRightGroup = supported.has('textAlign')
108
109
 
109
110
  return (
110
111
  <div
@@ -116,88 +117,91 @@ export function TextFormattingToolbar({
116
117
  <div
117
118
  role="toolbar"
118
119
  aria-label="Text formatting"
119
- className="flex items-center gap-0.5 p-1.5"
120
+ className="flex flex-wrap items-center gap-x-3 gap-y-2 p-2"
120
121
  >
121
- {supported.has('fontWeight') && (
122
- <button
123
- type="button"
124
- aria-label="Bold"
125
- aria-pressed={boldActive}
126
- onClick={handleBoldClick}
127
- className={`${toolbarBtnBase} ${boldActive ? toolbarBtnActive : toolbarBtnInactive}`}
128
- >
129
- <Bold className="h-3.5 w-3.5" />
130
- </button>
131
- )}
132
-
133
- {supported.has('fontStyle') && (
134
- <button
135
- type="button"
136
- aria-label="Italic"
137
- aria-pressed={italicActive}
138
- onClick={handleItalicClick}
139
- className={`${toolbarBtnBase} ${italicActive ? toolbarBtnActive : toolbarBtnInactive}`}
140
- >
141
- <Italic className="h-3.5 w-3.5" />
142
- </button>
143
- )}
144
-
145
- {supported.has('textTransform') && (
146
- <button
147
- type="button"
148
- aria-label={`Text case (currently ${textTransformDisplay})`}
149
- onClick={handleCaseClick}
150
- className={`${toolbarBtnBase} min-w-[2rem] font-mono text-xs ${caseActive ? toolbarBtnActive : toolbarBtnInactive}`}
151
- >
152
- {caseLabel(props.textTransform)}
153
- </button>
154
- )}
155
-
156
- {supported.has('color') && (
157
- <label
158
- className={`${toolbarBtnBase} ${toolbarBtnInactive} relative cursor-pointer`}
159
- title="Text color"
160
- >
161
- <span
162
- className="block h-3.5 w-3.5 rounded-sm border border-[var(--editor-border)]"
163
- style={{ backgroundColor: colorValue }}
164
- aria-hidden
165
- />
166
- <input
167
- type="color"
168
- value={colorValue}
169
- onChange={(e) => handleColorChange(e.target.value)}
170
- className="absolute inset-0 h-full w-full cursor-pointer opacity-0"
171
- aria-label="Text color"
172
- />
173
- </label>
174
- )}
175
-
176
- {hasLeftGroup && hasMidGroup && (
177
- <div className="mx-1 h-4 w-px bg-[var(--editor-border)]" aria-hidden />
178
- )}
179
-
180
- {supported.has('fontSize') && (
181
- <FontSizePicker
182
- value={fontSizeValue}
183
- onChange={handleFontSizeChange}
184
- />
185
- )}
186
-
187
- {supported.has('fontFamily') && (
188
- <FontFamilyPicker
189
- value={fontFamilyValue}
190
- onChange={handleFontFamilyChange}
191
- className="w-32"
192
- />
122
+ {/* Segment 1: B / I / case / color toggles */}
123
+ {hasLeftGroup && (
124
+ <div className="flex items-center gap-1">
125
+ {supported.has('fontWeight') && (
126
+ <button
127
+ type="button"
128
+ aria-label="Bold"
129
+ aria-pressed={boldActive}
130
+ onClick={handleBoldClick}
131
+ className={`${toolbarBtnBase} ${boldActive ? toolbarBtnActive : toolbarBtnInactive}`}
132
+ >
133
+ <Bold className="h-3.5 w-3.5" />
134
+ </button>
135
+ )}
136
+
137
+ {supported.has('fontStyle') && (
138
+ <button
139
+ type="button"
140
+ aria-label="Italic"
141
+ aria-pressed={italicActive}
142
+ onClick={handleItalicClick}
143
+ className={`${toolbarBtnBase} ${italicActive ? toolbarBtnActive : toolbarBtnInactive}`}
144
+ >
145
+ <Italic className="h-3.5 w-3.5" />
146
+ </button>
147
+ )}
148
+
149
+ {supported.has('textTransform') && (
150
+ <button
151
+ type="button"
152
+ aria-label={`Text case (currently ${textTransformDisplay})`}
153
+ onClick={handleCaseClick}
154
+ className={`${toolbarBtnBase} font-mono text-xs ${caseActive ? toolbarBtnActive : toolbarBtnInactive}`}
155
+ >
156
+ {caseLabel(props.textTransform)}
157
+ </button>
158
+ )}
159
+
160
+ {supported.has('color') && (
161
+ <label
162
+ className={`${toolbarBtnBase} ${toolbarBtnInactive} relative cursor-pointer`}
163
+ title="Text color"
164
+ >
165
+ <span
166
+ className="block h-4 w-4 rounded-sm border border-[var(--editor-border)]"
167
+ style={{ backgroundColor: colorValue }}
168
+ aria-hidden
169
+ />
170
+ <input
171
+ type="color"
172
+ value={colorValue}
173
+ onChange={(e) => handleColorChange(e.target.value)}
174
+ className="absolute inset-0 h-full w-full cursor-pointer opacity-0"
175
+ aria-label="Text color"
176
+ />
177
+ </label>
178
+ )}
179
+ </div>
193
180
  )}
194
181
 
195
- {hasMidGroup && hasRightGroup && (
196
- <div className="mx-1 h-4 w-px bg-[var(--editor-border)]" aria-hidden />
182
+ {/* Segment 2: size + font */}
183
+ {hasMidGroup && (
184
+ <div className="flex items-center gap-1.5">
185
+ {supported.has('fontSize') && (
186
+ <FontSizePicker
187
+ value={fontSizeValue}
188
+ onChange={handleFontSizeChange}
189
+ />
190
+ )}
191
+
192
+ {supported.has('fontFamily') && (
193
+ <FontFamilyPicker
194
+ value={fontFamilyValue}
195
+ onChange={handleFontFamilyChange}
196
+ className="w-32"
197
+ />
198
+ )}
199
+ </div>
197
200
  )}
198
201
 
202
+ {/* Segment 3: alignment */}
199
203
  {supported.has('textAlign') && (
200
- <div role="radiogroup" aria-label="Text alignment" className="flex items-center gap-0.5">
204
+ <div role="radiogroup" aria-label="Text alignment" className="flex items-center gap-1">
201
205
  {(
202
206
  [
203
207
  { value: 'left', Icon: AlignLeft, label: 'Align left' },
@@ -224,23 +228,20 @@ export function TextFormattingToolbar({
224
228
  )}
225
229
 
226
230
  {!hasAnyControlProp && (
227
- <span className="px-2 py-1 text-xs text-[var(--editor-text)]/60">
231
+ <span className="px-1 py-1 text-xs text-[var(--editor-text)]/55">
228
232
  Edit via property panel
229
233
  </span>
230
234
  )}
231
235
 
232
236
  {onDelete && (
233
- <>
234
- {hasAnyControlProp && <div className="mx-1 h-4 w-px bg-[var(--editor-border)]" aria-hidden />}
235
- <button
236
- type="button"
237
- aria-label="Delete text overlay"
238
- onClick={onDelete}
239
- className={`${toolbarBtnBase} text-[var(--editor-text)]/60 hover:bg-red-900/30 hover:text-red-400`}
240
- >
241
- <Trash2 className="h-3.5 w-3.5" />
242
- </button>
243
- </>
237
+ <button
238
+ type="button"
239
+ aria-label="Delete text overlay"
240
+ onClick={onDelete}
241
+ className={`${toolbarBtnBase} border-[var(--editor-border)] bg-[var(--editor-surface)] text-[var(--editor-text)]/60 hover:border-red-500/50 hover:bg-red-900/30 hover:text-red-400`}
242
+ >
243
+ <Trash2 className="h-3.5 w-3.5" />
244
+ </button>
244
245
  )}
245
246
  </div>
246
247
  </div>
package/src/ui/index.ts CHANGED
@@ -4,7 +4,7 @@
4
4
  export { cn } from './utils'
5
5
  export { Button } from './button'
6
6
  export type { ButtonProps } from './button'
7
- export { Input } from './input'
7
+ export { Input, inspectorInputClass } from './input'
8
8
  export type { InputProps } from './input'
9
9
  export { Label } from './label'
10
10
  export { Select } from './select'
package/src/ui/input.tsx CHANGED
@@ -1,5 +1,11 @@
1
1
  import { cn } from './utils'
2
2
 
3
+ // Shared inspector-input style. One consistent height/border/focus treatment
4
+ // for every text/number/select control in the editor chrome so the property
5
+ // panel reads as a single coherent surface. Reuse via `cn(inspectorInputClass, …)`.
6
+ export const inspectorInputClass =
7
+ 'h-8 w-full rounded-md border border-[var(--editor-border)] bg-[var(--editor-surface)] px-2.5 py-1.5 text-sm text-[var(--editor-text)] focus:outline-none focus:border-[var(--editor-accent)] focus:ring-1 focus:ring-[var(--editor-accent)]'
8
+
3
9
  export type InputProps = React.InputHTMLAttributes<HTMLInputElement>
4
10
 
5
11
  export function Input({ className, ...props }: InputProps) {