@actuate-media/cms-admin 0.72.2 → 0.73.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/dist/__tests__/components/resizable-aside.test.d.ts +2 -0
  3. package/dist/__tests__/components/resizable-aside.test.d.ts.map +1 -0
  4. package/dist/__tests__/components/resizable-aside.test.js +77 -0
  5. package/dist/__tests__/components/resizable-aside.test.js.map +1 -0
  6. package/dist/__tests__/components/toggle.test.d.ts +2 -0
  7. package/dist/__tests__/components/toggle.test.d.ts.map +1 -0
  8. package/dist/__tests__/components/toggle.test.js +17 -0
  9. package/dist/__tests__/components/toggle.test.js.map +1 -0
  10. package/dist/__tests__/views/post-fields-modal.render.test.js +38 -0
  11. package/dist/__tests__/views/post-fields-modal.render.test.js.map +1 -1
  12. package/dist/actuate-admin.css +1 -1
  13. package/dist/components/MediaPickerModal.d.ts.map +1 -1
  14. package/dist/components/MediaPickerModal.js +1 -1
  15. package/dist/components/MediaPickerModal.js.map +1 -1
  16. package/dist/components/TagInput.js +1 -1
  17. package/dist/components/TagInput.js.map +1 -1
  18. package/dist/components/ui/Modal.d.ts +17 -1
  19. package/dist/components/ui/Modal.d.ts.map +1 -1
  20. package/dist/components/ui/Modal.js +29 -3
  21. package/dist/components/ui/Modal.js.map +1 -1
  22. package/dist/components/ui/ResizableAside.d.ts +38 -0
  23. package/dist/components/ui/ResizableAside.d.ts.map +1 -0
  24. package/dist/components/ui/ResizableAside.js +209 -0
  25. package/dist/components/ui/ResizableAside.js.map +1 -0
  26. package/dist/components/ui/Toggle.d.ts.map +1 -1
  27. package/dist/components/ui/Toggle.js +4 -3
  28. package/dist/components/ui/Toggle.js.map +1 -1
  29. package/dist/fields/RichTextField.d.ts +3 -1
  30. package/dist/fields/RichTextField.d.ts.map +1 -1
  31. package/dist/fields/RichTextField.js +2 -2
  32. package/dist/fields/RichTextField.js.map +1 -1
  33. package/dist/views/Dashboard.d.ts.map +1 -1
  34. package/dist/views/Dashboard.js +1 -1
  35. package/dist/views/Dashboard.js.map +1 -1
  36. package/dist/views/page-editor/PageSectionEditor.js +1 -1
  37. package/dist/views/page-editor/PageSectionEditor.js.map +1 -1
  38. package/dist/views/page-editor/SectionInspector.d.ts +4 -1
  39. package/dist/views/page-editor/SectionInspector.d.ts.map +1 -1
  40. package/dist/views/page-editor/SectionInspector.js +14 -3
  41. package/dist/views/page-editor/SectionInspector.js.map +1 -1
  42. package/dist/views/post-editor/PostFieldsModal.d.ts +1 -1
  43. package/dist/views/post-editor/PostFieldsModal.d.ts.map +1 -1
  44. package/dist/views/post-editor/PostFieldsModal.js +152 -13
  45. package/dist/views/post-editor/PostFieldsModal.js.map +1 -1
  46. package/dist/views/post-editor/PostHeaderPanel.d.ts.map +1 -1
  47. package/dist/views/post-editor/PostHeaderPanel.js +2 -1
  48. package/dist/views/post-editor/PostHeaderPanel.js.map +1 -1
  49. package/dist/views/post-editor/PostSectionEditor.d.ts.map +1 -1
  50. package/dist/views/post-editor/PostSectionEditor.js +6 -1
  51. package/dist/views/post-editor/PostSectionEditor.js.map +1 -1
  52. package/dist/views/post-editor/PostTemplateEditor.js +1 -1
  53. package/dist/views/post-editor/PostTemplateEditor.js.map +1 -1
  54. package/package.json +1 -1
  55. package/src/__tests__/components/resizable-aside.test.tsx +130 -0
  56. package/src/__tests__/components/toggle.test.tsx +17 -0
  57. package/src/__tests__/views/post-fields-modal.render.test.tsx +54 -0
  58. package/src/components/MediaPickerModal.tsx +6 -2
  59. package/src/components/TagInput.tsx +1 -1
  60. package/src/components/ui/Modal.tsx +46 -3
  61. package/src/components/ui/ResizableAside.tsx +317 -0
  62. package/src/components/ui/Toggle.tsx +4 -3
  63. package/src/fields/RichTextField.tsx +4 -1
  64. package/src/styles/actuate-tokens.css +7 -2
  65. package/src/views/Dashboard.tsx +2 -1
  66. package/src/views/page-editor/PageSectionEditor.tsx +1 -1
  67. package/src/views/page-editor/SectionInspector.tsx +54 -26
  68. package/src/views/post-editor/PostFieldsModal.tsx +412 -174
  69. package/src/views/post-editor/PostHeaderPanel.tsx +15 -11
  70. package/src/views/post-editor/PostSectionEditor.tsx +9 -1
  71. package/src/views/post-editor/PostTemplateEditor.tsx +1 -1
@@ -0,0 +1,130 @@
1
+ // @vitest-environment happy-dom
2
+ import { afterEach, describe, expect, it, vi } from 'vitest'
3
+ import { fireEvent, render, screen } from '@testing-library/react'
4
+ import {
5
+ InspectorExpandButton,
6
+ ResizableAside,
7
+ SECTION_INSPECTOR_DEFAULT_WIDTH,
8
+ SECTION_INSPECTOR_MIN_WIDTH,
9
+ SECTION_INSPECTOR_WIDTH_KEY,
10
+ computeInspectorMaxWidth,
11
+ computeInspectorOverlayWidth,
12
+ readSectionInspectorWidth,
13
+ } from '../../components/ui/ResizableAside.js'
14
+
15
+ const storage = new Map<string, string>()
16
+ vi.stubGlobal('localStorage', {
17
+ getItem: (key: string) => storage.get(key) ?? null,
18
+ setItem: (key: string, value: string) => void storage.set(key, String(value)),
19
+ removeItem: (key: string) => void storage.delete(key),
20
+ clear: () => storage.clear(),
21
+ })
22
+
23
+ vi.stubGlobal(
24
+ 'ResizeObserver',
25
+ class {
26
+ constructor(private callback: ResizeObserverCallback) {}
27
+ observe(target: Element) {
28
+ const width =
29
+ target instanceof HTMLElement
30
+ ? Number.parseFloat(target.style.width) || target.clientWidth || 900
31
+ : 900
32
+ this.callback([{ contentRect: { width, height: 600 } } as ResizeObserverEntry], this)
33
+ }
34
+ unobserve() {}
35
+ disconnect() {}
36
+ },
37
+ )
38
+
39
+ afterEach(() => {
40
+ storage.clear()
41
+ })
42
+
43
+ describe('inspector width helpers', () => {
44
+ it('caps inline max width to the container minus a canvas peek', () => {
45
+ expect(computeInspectorMaxWidth(1200)).toBe(640)
46
+ expect(computeInspectorMaxWidth(500)).toBe(404)
47
+ expect(computeInspectorMaxWidth(200)).toBe(SECTION_INSPECTOR_MIN_WIDTH)
48
+ })
49
+
50
+ it('computes overlay width for narrow editor rows', () => {
51
+ expect(computeInspectorOverlayWidth(375)).toBe(343)
52
+ expect(computeInspectorOverlayWidth(768)).toBe(480)
53
+ })
54
+ })
55
+
56
+ describe('ResizableAside', () => {
57
+ it('renders a vertical resize separator with keyboard support', () => {
58
+ render(
59
+ <div style={{ width: 900 }}>
60
+ <ResizableAside aria-label="Section editor">
61
+ <p>Inspector content</p>
62
+ </ResizableAside>
63
+ </div>,
64
+ )
65
+
66
+ expect(screen.getByText('Inspector content')).toBeTruthy()
67
+ const separator = screen.getByRole('separator', { name: 'Resize inspector panel' })
68
+ expect(separator.getAttribute('aria-orientation')).toBe('vertical')
69
+ expect(separator.getAttribute('aria-valuemin')).toBe(String(SECTION_INSPECTOR_MIN_WIDTH))
70
+ })
71
+
72
+ it('widens when dragging the separator left', () => {
73
+ render(
74
+ <div style={{ width: 900 }}>
75
+ <ResizableAside aria-label="Section editor">
76
+ <p>Inspector content</p>
77
+ </ResizableAside>
78
+ </div>,
79
+ )
80
+
81
+ const aside = screen.getByLabelText('Section editor')
82
+ const separator = screen.getByRole('separator', { name: 'Resize inspector panel' })
83
+
84
+ expect(aside.style.width).toBe(`${SECTION_INSPECTOR_DEFAULT_WIDTH}px`)
85
+
86
+ fireEvent.pointerDown(separator, { clientX: 900, button: 0, pointerId: 1 })
87
+ fireEvent.pointerMove(separator, { clientX: 860, pointerId: 1 })
88
+ expect(aside.style.width).toBe(`${SECTION_INSPECTOR_DEFAULT_WIDTH + 40}px`)
89
+
90
+ fireEvent.pointerUp(separator, { pointerId: 1 })
91
+ expect(localStorage.getItem(SECTION_INSPECTOR_WIDTH_KEY)).toBe(
92
+ String(SECTION_INSPECTOR_DEFAULT_WIDTH + 40),
93
+ )
94
+ })
95
+
96
+ it('nudges width with arrow keys and persists', () => {
97
+ render(
98
+ <div style={{ width: 900 }}>
99
+ <ResizableAside aria-label="Section editor">
100
+ <p>Inspector content</p>
101
+ </ResizableAside>
102
+ </div>,
103
+ )
104
+
105
+ const aside = screen.getByLabelText('Section editor')
106
+ const separator = screen.getByRole('separator', { name: 'Resize inspector panel' })
107
+
108
+ separator.focus()
109
+ fireEvent.keyDown(separator, { key: 'ArrowLeft' })
110
+ expect(aside.style.width).toBe(`${SECTION_INSPECTOR_DEFAULT_WIDTH + 16}px`)
111
+ expect(readSectionInspectorWidth()).toBe(SECTION_INSPECTOR_DEFAULT_WIDTH + 16)
112
+ })
113
+
114
+ it('expands to overlay width on narrow containers', () => {
115
+ render(
116
+ <div style={{ width: 375 }}>
117
+ <ResizableAside aria-label="Section editor">
118
+ <InspectorExpandButton />
119
+ <p>Inspector content</p>
120
+ </ResizableAside>
121
+ </div>,
122
+ )
123
+
124
+ const aside = screen.getByLabelText('Section editor')
125
+ fireEvent.click(screen.getByRole('button', { name: 'Expand inspector panel' }))
126
+
127
+ expect(aside.getAttribute('data-overlay')).toBe('')
128
+ expect(aside.style.width).toBe(`${computeInspectorOverlayWidth(375)}px`)
129
+ })
130
+ })
@@ -0,0 +1,17 @@
1
+ // @vitest-environment happy-dom
2
+ import { afterEach, describe, expect, it } from 'vitest'
3
+ import { cleanup, render, screen } from '@testing-library/react'
4
+ import { Toggle } from '../../components/ui/Toggle.js'
5
+
6
+ afterEach(cleanup)
7
+
8
+ describe('Toggle', () => {
9
+ it('positions the thumb with design-system absolute offsets', () => {
10
+ render(<Toggle checked={false} onCheckedChange={() => {}} aria-label="Test toggle" />)
11
+ const thumb = screen.getByRole('switch').querySelector('span')
12
+ expect(thumb?.className).toContain('absolute')
13
+ expect(thumb?.className).toContain('top-[2.5px]')
14
+ expect(thumb?.className).toContain('left-[2.5px]')
15
+ expect(thumb?.className).toContain('data-[state=checked]:left-[19.5px]')
16
+ })
17
+ })
@@ -21,6 +21,17 @@ vi.mock('../../fields/RichTextField.js', () => ({
21
21
  RichTextField: () => <div data-testid="rich-text" />,
22
22
  }))
23
23
 
24
+ vi.mock('../../lib/coauthor-client.js', () => ({
25
+ coauthorText: vi.fn(async () => ({
26
+ ok: true,
27
+ result: { action: 'compress', text: 'AI excerpt' },
28
+ })),
29
+ }))
30
+
31
+ vi.mock('../../lib/seo-service.js', () => ({
32
+ generateSeoField: vi.fn(async () => ({ text: 'AI SEO title' })),
33
+ }))
34
+
24
35
  const { PostFieldsModal } = await import('../../views/post-editor/PostFieldsModal.js')
25
36
  type ModalProps = Parameters<typeof PostFieldsModal>[0]
26
37
 
@@ -123,4 +134,47 @@ describe('PostFieldsModal', () => {
123
134
  expect.objectContaining({ category: 'tutorials', tags: ['AI', 'CMS'] }),
124
135
  )
125
136
  })
137
+
138
+ it('derives slug from title until the slug field is edited', () => {
139
+ render(
140
+ <PostFieldsModal
141
+ open
142
+ post={basePost({ title: '', slug: '' })}
143
+ canEdit
144
+ onClose={() => {}}
145
+ onSave={() => {}}
146
+ />,
147
+ )
148
+ fireEvent.change(screen.getByLabelText(/^Title/), { target: { value: 'Hello World' } })
149
+ expect((screen.getByLabelText(/^Slug/) as HTMLInputElement).value).toBe('hello-world')
150
+ fireEvent.change(screen.getByLabelText(/^Slug/), { target: { value: 'custom-slug' } })
151
+ fireEvent.change(screen.getByLabelText(/^Title/), { target: { value: 'Changed Title' } })
152
+ expect((screen.getByLabelText(/^Slug/) as HTMLInputElement).value).toBe('custom-slug')
153
+ })
154
+
155
+ it('exposes AI generate controls for excerpt and SEO fields', () => {
156
+ render(<PostFieldsModal open post={basePost()} canEdit onClose={() => {}} onSave={() => {}} />)
157
+ expect(screen.getByRole('button', { name: 'Generate excerpt from content' })).toBeTruthy()
158
+ expect(screen.getByRole('button', { name: 'Generate SEO title from content' })).toBeTruthy()
159
+ expect(
160
+ screen.getByRole('button', { name: 'Generate SEO description from content' }),
161
+ ).toBeTruthy()
162
+ })
163
+
164
+ it('prompts before discarding when Cancel is clicked with unsaved edits', () => {
165
+ const onClose = vi.fn()
166
+ render(<PostFieldsModal open post={basePost()} canEdit onClose={onClose} onSave={() => {}} />)
167
+ fireEvent.change(screen.getByLabelText(/^Title/), { target: { value: 'Changed title' } })
168
+ fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
169
+ expect(onClose).not.toHaveBeenCalled()
170
+ expect(screen.getByRole('dialog', { name: 'Discard unsaved changes?' })).toBeTruthy()
171
+ })
172
+
173
+ it('closes without prompting when nothing changed', () => {
174
+ const onClose = vi.fn()
175
+ render(<PostFieldsModal open post={basePost()} canEdit onClose={onClose} onSave={() => {}} />)
176
+ fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
177
+ expect(onClose).toHaveBeenCalledTimes(1)
178
+ expect(screen.queryByRole('dialog', { name: 'Discard unsaved changes?' })).toBeNull()
179
+ })
126
180
  })
@@ -129,10 +129,14 @@ export function MediaPickerModal({
129
129
  return (
130
130
  <Dialog.Root open={open} onOpenChange={(next) => !next && onClose()}>
131
131
  <Dialog.Portal container={adminPortalContainer()}>
132
- <Dialog.Overlay className="fixed inset-0 z-50 bg-black/40" />
132
+ {/*
133
+ z-70 so this picker stacks above the z-60 form modals that embed it
134
+ (PostFieldsModal featured image, TipTap insert image, etc.).
135
+ */}
136
+ <Dialog.Overlay className="fixed inset-0 z-70 bg-black/40" />
133
137
  <Dialog.Content
134
138
  aria-describedby={undefined}
135
- className="bg-card text-card-foreground border-border fixed top-1/2 left-1/2 z-50 mx-4 flex max-h-[80vh] w-[calc(100%-2rem)] max-w-2xl -translate-x-1/2 -translate-y-1/2 flex-col rounded-xl border shadow-2xl focus:outline-none"
139
+ className="bg-card text-card-foreground border-border fixed top-1/2 left-1/2 z-70 mx-4 flex max-h-[80vh] w-[calc(100%-2rem)] max-w-2xl -translate-x-1/2 -translate-y-1/2 flex-col rounded-xl border shadow-2xl focus:outline-none"
136
140
  >
137
141
  <div className="border-border flex items-center justify-between border-b px-4 py-3">
138
142
  <Dialog.Title className="text-foreground text-lg font-medium">
@@ -35,7 +35,7 @@ interface FieldValuesResponse {
35
35
  }
36
36
 
37
37
  const INPUT =
38
- 'border-input bg-input-background text-foreground focus-visible:ring-ring w-full rounded-md border px-2.5 py-1.5 text-sm focus-visible:ring-2 focus-visible:outline-none'
38
+ 'border-border bg-input-background text-foreground focus-visible:ring-ring w-full rounded-md border px-2.5 py-1.5 text-sm focus-visible:ring-2 focus-visible:outline-none'
39
39
 
40
40
  export function TagInput({
41
41
  id,
@@ -5,12 +5,23 @@ import { X } from 'lucide-react'
5
5
  import { type ReactNode, type RefObject } from 'react'
6
6
  import { adminPortalContainer } from '../../lib/portal-container.js'
7
7
 
8
+ const MODAL_SIZE_CLASS = {
9
+ md: 'max-w-lg',
10
+ lg: 'max-w-3xl',
11
+ /** Wide form dialogs (post/page field editors) — body scrolls inside the shell. */
12
+ xl: 'flex max-h-[min(92vh,880px)] max-w-5xl flex-col',
13
+ } as const
14
+
15
+ export type ModalSize = keyof typeof MODAL_SIZE_CLASS
16
+
8
17
  export interface ModalProps {
9
18
  open: boolean
10
19
  onClose: () => void
11
20
  title: string
12
21
  children: ReactNode
13
22
  actions?: ReactNode
23
+ /** Dialog width preset. Defaults to `md` (512px). */
24
+ size?: ModalSize
14
25
  /**
15
26
  * Accessible description for the dialog, announced after the title. Optional —
16
27
  * omit when the body content is self-explanatory.
@@ -22,6 +33,12 @@ export interface ModalProps {
22
33
  * form dialogs where the user should land in the first input.
23
34
  */
24
35
  initialFocusRef?: RefObject<HTMLElement | null>
36
+ /**
37
+ * When set, overlay click, Escape, and the header X invoke this instead of
38
+ * closing immediately — use to confirm before discarding unsaved edits.
39
+ * The dialog stays open until the parent calls `onClose()`.
40
+ */
41
+ onDismiss?: () => void
25
42
  }
26
43
 
27
44
  /**
@@ -41,15 +58,39 @@ export function Modal({
41
58
  title,
42
59
  children,
43
60
  actions,
61
+ size = 'md',
44
62
  description,
45
63
  initialFocusRef,
64
+ onDismiss,
46
65
  }: ModalProps) {
66
+ const scrollBody = size === 'xl'
67
+
68
+ function handleDismissAttempt() {
69
+ if (onDismiss) onDismiss()
70
+ else onClose()
71
+ }
72
+
47
73
  return (
48
- <Dialog.Root open={open} onOpenChange={(next) => !next && onClose()}>
74
+ <Dialog.Root
75
+ open={open}
76
+ onOpenChange={(next) => {
77
+ if (!next) handleDismissAttempt()
78
+ }}
79
+ >
49
80
  <Dialog.Portal container={adminPortalContainer()}>
50
81
  <Dialog.Overlay className="fixed inset-0 z-60 bg-black/50" />
51
82
  <Dialog.Content
52
- className="bg-card text-card-foreground border-border fixed top-1/2 left-1/2 z-60 w-full max-w-lg -translate-x-1/2 -translate-y-1/2 rounded-lg border shadow-xl focus:outline-none"
83
+ className={`bg-card text-card-foreground border-border fixed top-1/2 left-1/2 z-60 w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 rounded-lg border shadow-xl focus:outline-none ${MODAL_SIZE_CLASS[size]}`}
84
+ onInteractOutside={(event) => {
85
+ if (!onDismiss) return
86
+ event.preventDefault()
87
+ handleDismissAttempt()
88
+ }}
89
+ onEscapeKeyDown={(event) => {
90
+ if (!onDismiss) return
91
+ event.preventDefault()
92
+ handleDismissAttempt()
93
+ }}
53
94
  onOpenAutoFocus={
54
95
  initialFocusRef
55
96
  ? (event) => {
@@ -78,7 +119,9 @@ export function Modal({
78
119
  {description}
79
120
  </Dialog.Description>
80
121
  )}
81
- <div className="px-6 py-4">{children}</div>
122
+ <div className={scrollBody ? 'min-h-0 flex-1 overflow-y-auto px-6 py-4' : 'px-6 py-4'}>
123
+ {children}
124
+ </div>
82
125
  {actions && (
83
126
  <div className="border-border flex justify-end gap-2 border-t px-6 py-4">{actions}</div>
84
127
  )}
@@ -0,0 +1,317 @@
1
+ 'use client'
2
+
3
+ import {
4
+ createContext,
5
+ useCallback,
6
+ useContext,
7
+ useEffect,
8
+ useRef,
9
+ useState,
10
+ type ComponentPropsWithoutRef,
11
+ type KeyboardEvent,
12
+ type PointerEvent,
13
+ type ReactNode,
14
+ } from 'react'
15
+ import { Maximize2, Minimize2 } from 'lucide-react'
16
+
17
+ export const SECTION_INSPECTOR_WIDTH_KEY = 'actuate:section-inspector-width'
18
+ export const SECTION_INSPECTOR_DEFAULT_WIDTH = 320
19
+ export const SECTION_INSPECTOR_MIN_WIDTH = 280
20
+ export const SECTION_INSPECTOR_MAX_WIDTH = 640
21
+ export const SECTION_INSPECTOR_MIN_CANVAS_WIDTH = 96
22
+ /** Below this editor-row width the expand action uses an overlay panel. */
23
+ export const SECTION_INSPECTOR_OVERLAY_BREAKPOINT = 768
24
+ export const SECTION_INSPECTOR_OVERLAY_MAX_WIDTH = 480
25
+
26
+ function clampWidth(width: number, max = SECTION_INSPECTOR_MAX_WIDTH): number {
27
+ return Math.min(max, Math.max(SECTION_INSPECTOR_MIN_WIDTH, width))
28
+ }
29
+
30
+ /** Max inline width given the surrounding editor row. */
31
+ export function computeInspectorMaxWidth(containerWidth: number): number {
32
+ if (!Number.isFinite(containerWidth) || containerWidth <= 0) {
33
+ return SECTION_INSPECTOR_MAX_WIDTH
34
+ }
35
+ const containerCap = Math.max(
36
+ SECTION_INSPECTOR_MIN_WIDTH,
37
+ containerWidth - SECTION_INSPECTOR_MIN_CANVAS_WIDTH,
38
+ )
39
+ return Math.min(SECTION_INSPECTOR_MAX_WIDTH, containerCap)
40
+ }
41
+
42
+ /** Width when expanded as an overlay on tablet/mobile. */
43
+ export function computeInspectorOverlayWidth(containerWidth: number): number {
44
+ if (!Number.isFinite(containerWidth) || containerWidth <= 0) {
45
+ return SECTION_INSPECTOR_OVERLAY_MAX_WIDTH
46
+ }
47
+ return Math.min(
48
+ SECTION_INSPECTOR_OVERLAY_MAX_WIDTH,
49
+ Math.max(SECTION_INSPECTOR_MIN_WIDTH, containerWidth - 32),
50
+ )
51
+ }
52
+
53
+ export function readSectionInspectorWidth(): number {
54
+ if (typeof window === 'undefined') return SECTION_INSPECTOR_DEFAULT_WIDTH
55
+ try {
56
+ const raw = localStorage.getItem(SECTION_INSPECTOR_WIDTH_KEY)
57
+ if (!raw) return SECTION_INSPECTOR_DEFAULT_WIDTH
58
+ const parsed = Number.parseInt(raw, 10)
59
+ if (!Number.isFinite(parsed)) return SECTION_INSPECTOR_DEFAULT_WIDTH
60
+ return clampWidth(parsed)
61
+ } catch {
62
+ return SECTION_INSPECTOR_DEFAULT_WIDTH
63
+ }
64
+ }
65
+
66
+ function persistSectionInspectorWidth(width: number, max = SECTION_INSPECTOR_MAX_WIDTH): void {
67
+ try {
68
+ localStorage.setItem(SECTION_INSPECTOR_WIDTH_KEY, String(clampWidth(width, max)))
69
+ } catch {
70
+ // Storage may be unavailable in private mode.
71
+ }
72
+ }
73
+
74
+ interface InspectorResizeContextValue {
75
+ width: number
76
+ maxWidth: number
77
+ isExpanded: boolean
78
+ canExpand: boolean
79
+ toggleExpanded: () => void
80
+ }
81
+
82
+ const InspectorResizeContext = createContext<InspectorResizeContextValue | null>(null)
83
+
84
+ export function useInspectorResize(): InspectorResizeContextValue | null {
85
+ return useContext(InspectorResizeContext)
86
+ }
87
+
88
+ interface ResizableAsideProps extends ComponentPropsWithoutRef<'aside'> {
89
+ children: ReactNode
90
+ }
91
+
92
+ /**
93
+ * Right-hand editor shell with a draggable left edge. Used by the page/post
94
+ * section inspector and post header panel (default 320px, persisted in
95
+ * localStorage). On tablet/mobile the handle uses a 44px touch target and
96
+ * the header expand button can grow the panel to an overlay width.
97
+ */
98
+ export function ResizableAside({ children, className, style, ...props }: ResizableAsideProps) {
99
+ const asideRef = useRef<HTMLElement>(null)
100
+ const [width, setWidth] = useState(SECTION_INSPECTOR_DEFAULT_WIDTH)
101
+ const [containerWidth, setContainerWidth] = useState(0)
102
+ const [isExpanded, setIsExpanded] = useState(false)
103
+ const [dragging, setDragging] = useState(false)
104
+ const widthRef = useRef(width)
105
+ const compactWidthRef = useRef(SECTION_INSPECTOR_DEFAULT_WIDTH)
106
+ const dragRef = useRef<{ startX: number; startWidth: number } | null>(null)
107
+
108
+ const inlineMax = computeInspectorMaxWidth(containerWidth)
109
+ const overlayMax = computeInspectorOverlayWidth(containerWidth)
110
+ const useOverlay =
111
+ containerWidth > 0 && containerWidth < SECTION_INSPECTOR_OVERLAY_BREAKPOINT && isExpanded
112
+ const effectiveMax = useOverlay ? overlayMax : inlineMax
113
+
114
+ useEffect(() => {
115
+ setWidth(readSectionInspectorWidth())
116
+ }, [])
117
+
118
+ useEffect(() => {
119
+ widthRef.current = width
120
+ }, [width])
121
+
122
+ useEffect(() => {
123
+ const parent = asideRef.current?.parentElement
124
+ if (!parent) return
125
+
126
+ const update = (nextWidth: number) => {
127
+ setContainerWidth(nextWidth)
128
+ }
129
+
130
+ update(parent.getBoundingClientRect().width)
131
+ const observer = new ResizeObserver((entries) => {
132
+ const entry = entries[0]
133
+ if (!entry) return
134
+ update(entry.contentRect.width)
135
+ })
136
+ observer.observe(parent)
137
+ return () => observer.disconnect()
138
+ }, [])
139
+
140
+ useEffect(() => {
141
+ setWidth((current) => clampWidth(current, effectiveMax))
142
+ }, [effectiveMax])
143
+
144
+ const endDrag = useCallback(() => {
145
+ dragRef.current = null
146
+ setDragging(false)
147
+ document.body.style.cursor = ''
148
+ document.body.style.userSelect = ''
149
+ persistSectionInspectorWidth(widthRef.current, inlineMax)
150
+ if (widthRef.current < effectiveMax - 24) {
151
+ setIsExpanded(false)
152
+ }
153
+ }, [effectiveMax, inlineMax])
154
+
155
+ const onPointerDown = useCallback((event: PointerEvent<HTMLDivElement>) => {
156
+ if (event.button !== 0) return
157
+ dragRef.current = { startX: event.clientX, startWidth: widthRef.current }
158
+ setDragging(true)
159
+ event.currentTarget.setPointerCapture(event.pointerId)
160
+ document.body.style.cursor = 'col-resize'
161
+ document.body.style.userSelect = 'none'
162
+ }, [])
163
+
164
+ const onPointerMove = useCallback(
165
+ (event: PointerEvent<HTMLDivElement>) => {
166
+ if (!dragRef.current) return
167
+ const delta = dragRef.current.startX - event.clientX
168
+ setWidth(clampWidth(dragRef.current.startWidth + delta, effectiveMax))
169
+ },
170
+ [effectiveMax],
171
+ )
172
+
173
+ const onPointerUp = useCallback(
174
+ (event: PointerEvent<HTMLDivElement>) => {
175
+ if (!dragRef.current) return
176
+ if (event.currentTarget.hasPointerCapture(event.pointerId)) {
177
+ event.currentTarget.releasePointerCapture(event.pointerId)
178
+ }
179
+ endDrag()
180
+ },
181
+ [endDrag],
182
+ )
183
+
184
+ const nudgeWidth = useCallback(
185
+ (delta: number) => {
186
+ setWidth((current) => {
187
+ const next = clampWidth(current + delta, effectiveMax)
188
+ persistSectionInspectorWidth(next, inlineMax)
189
+ if (next < effectiveMax - 24) setIsExpanded(false)
190
+ return next
191
+ })
192
+ },
193
+ [effectiveMax, inlineMax],
194
+ )
195
+
196
+ const onKeyDown = useCallback(
197
+ (event: KeyboardEvent<HTMLDivElement>) => {
198
+ if (event.key === 'ArrowLeft') {
199
+ event.preventDefault()
200
+ nudgeWidth(16)
201
+ } else if (event.key === 'ArrowRight') {
202
+ event.preventDefault()
203
+ nudgeWidth(-16)
204
+ } else if (event.key === 'Home') {
205
+ event.preventDefault()
206
+ setIsExpanded(false)
207
+ setWidth(SECTION_INSPECTOR_MIN_WIDTH)
208
+ persistSectionInspectorWidth(SECTION_INSPECTOR_MIN_WIDTH, inlineMax)
209
+ } else if (event.key === 'End') {
210
+ event.preventDefault()
211
+ setWidth(effectiveMax)
212
+ persistSectionInspectorWidth(effectiveMax, inlineMax)
213
+ setIsExpanded(effectiveMax >= inlineMax - 8)
214
+ }
215
+ },
216
+ [effectiveMax, inlineMax, nudgeWidth],
217
+ )
218
+
219
+ const toggleExpanded = useCallback(() => {
220
+ if (isExpanded) {
221
+ const restored = clampWidth(compactWidthRef.current, inlineMax)
222
+ setIsExpanded(false)
223
+ setWidth(restored)
224
+ persistSectionInspectorWidth(restored, inlineMax)
225
+ return
226
+ }
227
+
228
+ compactWidthRef.current = widthRef.current
229
+ const target =
230
+ containerWidth > 0 && containerWidth < SECTION_INSPECTOR_OVERLAY_BREAKPOINT
231
+ ? overlayMax
232
+ : inlineMax
233
+ setIsExpanded(true)
234
+ setWidth(target)
235
+ persistSectionInspectorWidth(target, inlineMax)
236
+ }, [containerWidth, inlineMax, isExpanded, overlayMax])
237
+
238
+ useEffect(() => {
239
+ return () => {
240
+ if (dragRef.current) endDrag()
241
+ }
242
+ }, [endDrag])
243
+
244
+ const canExpand =
245
+ !isExpanded &&
246
+ (containerWidth > 0 && containerWidth < SECTION_INSPECTOR_OVERLAY_BREAKPOINT
247
+ ? overlayMax > width + 8
248
+ : inlineMax > width + 8)
249
+
250
+ return (
251
+ <InspectorResizeContext.Provider
252
+ value={{ width, maxWidth: effectiveMax, isExpanded, canExpand, toggleExpanded }}
253
+ >
254
+ <aside
255
+ {...props}
256
+ ref={asideRef}
257
+ data-overlay={useOverlay ? '' : undefined}
258
+ className={`relative shrink-0 ${useOverlay ? 'absolute top-0 right-0 bottom-0 z-20 shadow-lg' : ''} ${className ?? ''}`}
259
+ style={{ width, ...style }}
260
+ >
261
+ <div
262
+ role="separator"
263
+ aria-orientation="vertical"
264
+ aria-label="Resize inspector panel"
265
+ aria-valuemin={SECTION_INSPECTOR_MIN_WIDTH}
266
+ aria-valuemax={effectiveMax}
267
+ aria-valuenow={width}
268
+ tabIndex={0}
269
+ data-dragging={dragging ? '' : undefined}
270
+ onPointerDown={onPointerDown}
271
+ onPointerMove={onPointerMove}
272
+ onPointerUp={onPointerUp}
273
+ onPointerCancel={onPointerUp}
274
+ onKeyDown={onKeyDown}
275
+ className="group absolute inset-y-0 -left-5 z-10 flex w-11 -translate-x-1/2 cursor-col-resize touch-none focus-visible:outline-none md:-left-1 md:w-3 md:translate-x-0"
276
+ >
277
+ <div
278
+ className={`mx-auto h-full w-px motion-safe:transition-colors md:mx-0 ${
279
+ dragging
280
+ ? 'bg-primary'
281
+ : 'bg-border group-hover:bg-primary/50 group-focus-visible:bg-primary/50'
282
+ }`}
283
+ />
284
+ </div>
285
+ {children}
286
+ </aside>
287
+ </InspectorResizeContext.Provider>
288
+ )
289
+ }
290
+
291
+ const EXPAND_BUTTON_CLASS =
292
+ 'text-muted-foreground hover:text-foreground hover:bg-accent flex h-9 w-9 items-center justify-center rounded-md transition-colors focus-visible:ring-ring focus-visible:ring-2 focus-visible:outline-none md:h-7 md:w-7'
293
+
294
+ /** One-tap expand/collapse for tablet and mobile inspector panels. */
295
+ export function InspectorExpandButton({ className }: { className?: string }) {
296
+ const ctx = useInspectorResize()
297
+ if (!ctx) return null
298
+
299
+ const { isExpanded, canExpand, toggleExpanded } = ctx
300
+ if (!canExpand && !isExpanded) return null
301
+
302
+ return (
303
+ <button
304
+ type="button"
305
+ onClick={toggleExpanded}
306
+ aria-label={isExpanded ? 'Collapse inspector panel' : 'Expand inspector panel'}
307
+ aria-pressed={isExpanded}
308
+ className={className ?? EXPAND_BUTTON_CLASS}
309
+ >
310
+ {isExpanded ? (
311
+ <Minimize2 className="h-4 w-4" aria-hidden />
312
+ ) : (
313
+ <Maximize2 className="h-4 w-4" aria-hidden />
314
+ )}
315
+ </button>
316
+ )
317
+ }
@@ -4,7 +4,7 @@ import * as Switch from '@radix-ui/react-switch'
4
4
  import { cv, type VariantProps } from '../../lib/cv.js'
5
5
 
6
6
  const toggleTrack = cv(
7
- 'relative inline-flex shrink-0 cursor-pointer rounded-[var(--r-pill)] transition-colors duration-[var(--motion-base)] focus-visible:ring-2 focus-visible:ring-[var(--ring)] focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50',
7
+ 'relative inline-block shrink-0 cursor-pointer rounded-[var(--r-pill)] border-0 p-0 transition-colors duration-[var(--motion-base)] focus-visible:ring-2 focus-visible:ring-[var(--ring)] focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50',
8
8
  {
9
9
  variants: {
10
10
  variant: {
@@ -26,11 +26,12 @@ const toggleTrack = cv(
26
26
  )
27
27
 
28
28
  const toggleThumb = cv(
29
- 'pointer-events-none block rounded-full bg-[var(--toggle-thumb)] shadow-[0_1px_3px_rgba(0,0,0,0.2)] transition-transform duration-[var(--motion-base)]',
29
+ 'pointer-events-none absolute top-[2.5px] left-[2.5px] block rounded-full bg-[var(--toggle-thumb)] shadow-[0_1px_3px_rgba(0,0,0,0.2)] transition-[left] duration-[var(--motion-base)] data-[state=checked]:left-[19.5px]',
30
30
  {
31
31
  variants: {
32
32
  size: {
33
- md: 'h-4 w-4 translate-x-0.5 data-[state=checked]:translate-x-[17.5px]',
33
+ /** Matches `.tog-thumb` in the design system handoff (16×16). */
34
+ md: 'h-4 w-4',
34
35
  },
35
36
  },
36
37
  defaultVariants: {