@bycrux/editor 0.4.3 → 0.4.5

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.3",
3
+ "version": "0.4.5",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "exports": {
@@ -365,10 +365,10 @@ 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. Given a generous viewport-relative height so
369
- the slide renders large; the below-panels region flows beneath and the
370
- whole editor scrolls vertically. */}
371
- <div className="flex flex-shrink-0 min-h-[80vh] overflow-hidden">
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">
372
372
  <SlideGrid
373
373
  project={project}
374
374
  slides={slides}
@@ -493,50 +493,50 @@ export default function CarouselEditor<P extends Project = Project>({ project: i
493
493
  </div>
494
494
  )}
495
495
  </div>
496
- </div>
497
496
 
498
- {/* BELOW: the slide editor, stacked vertically full-width under the canvas.
499
- Flows beneath the tall canvas region and scrolls with the page (the root
500
- is overflow-y-auto). Order: add-element toolbar property panel →
501
- project media (assets) at the very bottom. */}
502
- <div className="flex-shrink-0 border-t border-[var(--editor-border)] bg-[var(--editor-bg)] flex flex-col">
503
- {selectedSlide && project.status !== 'pending' && (
504
- <div className="px-4 py-2 border-b border-[var(--editor-border)] bg-[var(--editor-bg)]">
505
- <AddElementMenu
506
- project={project}
507
- selectedSlideId={selectedSlideId}
508
- adapter={adapter}
509
- onAddElement={handleAddElement}
510
- />
511
- </div>
512
- )}
513
- <SlidePropertyPanel
514
- project={project}
515
- slide={selectedSlide}
516
- element={selectedElement}
517
- adapter={adapter}
518
- onSlideChange={handleSlideChange}
519
- onElementChange={handlePanelElementChange}
520
- onDeleteSlide={handleDeleteSlide}
521
- onDuplicateSlide={handleDuplicateSlide}
522
- onDeleteElement={handleDeleteElement}
523
- onDuplicateElement={handleDuplicateElement}
524
- onReorderElement={handleReorderElement}
525
- onEnterCrop={(_slideId, elementId) => { setSelectedElementId(elementId); setCropElementId(elementId) }}
526
- updateOverlayProp={state.updateOverlayProp}
527
- hiddenElementIds={hiddenElementIds}
528
- onToggleElementVisibility={onToggleElementVisibility}
529
- // Stacked full-width here (drop the default w-80 sidebar + left border).
530
- className="w-full border-l-0 border-b border-[var(--editor-border)]"
531
- />
532
- {slots?.assetsPanel && (
533
- // Project media — at the very bottom, full width.
534
- <div className="w-full flex flex-col">
535
- {slots.assetsPanel}
536
- </div>
537
- )}
497
+ {/* 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)]">
500
+ {selectedSlide && project.status !== 'pending' && (
501
+ <div className="px-4 py-2 border-b border-[var(--editor-border)]">
502
+ <AddElementMenu
503
+ project={project}
504
+ selectedSlideId={selectedSlideId}
505
+ adapter={adapter}
506
+ onAddElement={handleAddElement}
507
+ />
508
+ </div>
509
+ )}
510
+ <SlidePropertyPanel
511
+ project={project}
512
+ slide={selectedSlide}
513
+ element={selectedElement}
514
+ adapter={adapter}
515
+ onSlideChange={handleSlideChange}
516
+ onElementChange={handlePanelElementChange}
517
+ onDeleteSlide={handleDeleteSlide}
518
+ onDuplicateSlide={handleDuplicateSlide}
519
+ onDeleteElement={handleDeleteElement}
520
+ onDuplicateElement={handleDuplicateElement}
521
+ onReorderElement={handleReorderElement}
522
+ onEnterCrop={(_slideId, elementId) => { setSelectedElementId(elementId); setCropElementId(elementId) }}
523
+ updateOverlayProp={state.updateOverlayProp}
524
+ hiddenElementIds={hiddenElementIds}
525
+ onToggleElementVisibility={onToggleElementVisibility}
526
+ // Fills the right column (drop the default w-80 width + left border).
527
+ className="w-full border-l-0"
528
+ />
529
+ </div>
538
530
  </div>
539
531
 
532
+ {/* BELOW: Project media, full width at the bottom. Flows beneath the top
533
+ region and scrolls with the page (the root is overflow-y-auto). */}
534
+ {slots?.assetsPanel && (
535
+ <div className="flex-shrink-0 border-t border-[var(--editor-border)] w-full flex flex-col">
536
+ {slots.assetsPanel}
537
+ </div>
538
+ )}
539
+
540
540
  {renderOpen && (
541
541
  <CarouselRenderModal
542
542
  projectId={project.id}
@@ -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
@@ -97,9 +97,9 @@ describe('CarouselEditor — editor-core integration', () => {
97
97
  />,
98
98
  )
99
99
  await waitFor(() => getByTestId('assets'))
100
- // The assets slot lives in the below-canvas region, stacked full-width at the
101
- // very bottom (no longer capped to a 320px sidebar) so the host panel spans
102
- // the editor width beneath the property panel.
100
+ // The assets slot lives in the below-canvas region, full-width at the very
101
+ // bottom (no longer capped to a 320px sidebar) so the host panel spans the
102
+ // editor width beneath the top canvas/editing region.
103
103
  const wrapper = getByTestId('assets').parentElement
104
104
  expect(wrapper?.className).toContain('w-full')
105
105
  expect(wrapper?.className).not.toContain('w-80')
@@ -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) {