@woodsportal/hubspot-kit 1.0.4 → 1.0.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.
Files changed (64) hide show
  1. package/dist/cli.js +24 -1
  2. package/dist/cli.js.map +1 -1
  3. package/package.json +4 -1
  4. package/src/dev/bootstrap-greenfield.tsx +51 -0
  5. package/src/dev/bootstrap.tsx +46 -0
  6. package/src/dev-module-config/__tests__/fixtures/fields.json +3014 -0
  7. package/src/dev-module-config/__tests__/fixtures/golden-module.fixture.ts +102 -0
  8. package/src/dev-module-config/__tests__/module-config-storage.test.ts +40 -0
  9. package/src/dev-module-config/__tests__/module-config-transform.test.ts +55 -0
  10. package/src/dev-module-config/__tests__/normalize-repeater-items.test.ts +46 -0
  11. package/src/dev-module-config/runtime/module-config-bridge.ts +27 -0
  12. package/src/dev-module-config/runtime/module-config-runtime.ts +185 -0
  13. package/src/dev-module-config/schema/build-default-item.ts +7 -0
  14. package/src/dev-module-config/schema/deep-merge.ts +27 -0
  15. package/src/dev-module-config/schema/extract-defaults.ts +62 -0
  16. package/src/dev-module-config/schema/normalize-choices.ts +18 -0
  17. package/src/dev-module-config/schema/normalize-repeater-items.ts +31 -0
  18. package/src/dev-module-config/schema/parse-fields-json.ts +2 -0
  19. package/src/dev-module-config/schema/types.ts +75 -0
  20. package/src/dev-module-config/schema/validate-module-config.ts +81 -0
  21. package/src/dev-module-config/schema/visibility.ts +130 -0
  22. package/src/dev-module-config/storage/config-json.ts +46 -0
  23. package/src/dev-module-config/storage/workspace-store.ts +139 -0
  24. package/src/dev-module-config/transform/build-hubspot-data.ts +409 -0
  25. package/src/dev-module-config/ui/FieldRenderer.tsx +353 -0
  26. package/src/dev-module-config/ui/ModuleConfigAnimatedPresence.tsx +72 -0
  27. package/src/dev-module-config/ui/ModuleConfigCollapsible.tsx +43 -0
  28. package/src/dev-module-config/ui/ModuleConfigDockSideIcon.tsx +27 -0
  29. package/src/dev-module-config/ui/ModuleConfigOverlay.tsx +134 -0
  30. package/src/dev-module-config/ui/ModuleConfigPersistenceStatus.tsx +89 -0
  31. package/src/dev-module-config/ui/ModuleConfigShell.tsx +354 -0
  32. package/src/dev-module-config/ui/ModuleConfigThemeToggle.tsx +32 -0
  33. package/src/dev-module-config/ui/ModuleConfigUiContext.tsx +65 -0
  34. package/src/dev-module-config/ui/editors/DeveloperOptionsEditor.tsx +61 -0
  35. package/src/dev-module-config/ui/editors/MenuOptionsEditor.tsx +103 -0
  36. package/src/dev-module-config/ui/hubspot/HsColorField.tsx +106 -0
  37. package/src/dev-module-config/ui/hubspot/HsColorPickerPopover.tsx +210 -0
  38. package/src/dev-module-config/ui/hubspot/HsFieldLabel.tsx +24 -0
  39. package/src/dev-module-config/ui/hubspot/HsFieldWidgets.tsx +248 -0
  40. package/src/dev-module-config/ui/hubspot/HsImageField.tsx +112 -0
  41. package/src/dev-module-config/ui/hubspot/HsOccurrenceGroupEditor.tsx +243 -0
  42. package/src/dev-module-config/ui/hubspot/HsRepeaterList.tsx +178 -0
  43. package/src/dev-module-config/ui/hubspot/HsSectionHeader.tsx +20 -0
  44. package/src/dev-module-config/ui/hubspot/HsToggle.tsx +39 -0
  45. package/src/dev-module-config/ui/hubspot/HsToggleRow.tsx +30 -0
  46. package/src/dev-module-config/ui/hubspot/hs-color-palette.ts +11 -0
  47. package/src/dev-module-config/ui/hubspot/module-config-typography.ts +26 -0
  48. package/src/dev-module-config/ui/module-config-motion.css +134 -0
  49. package/src/dev-module-config/ui/module-config-motion.ts +28 -0
  50. package/src/dev-module-config/ui/module-config-overlay-side.ts +29 -0
  51. package/src/dev-module-config/ui/module-config-surfaces.ts +27 -0
  52. package/src/dev-module-config/ui/module-config-theme.css +483 -0
  53. package/src/dev-module-config/ui/module-config-toolbar-theme.ts +35 -0
  54. package/src/dev-module-config/ui/navigation/ModuleConfigNavContext.tsx +188 -0
  55. package/src/dev-module-config/ui/use-dev-module-config-live-revision.ts +22 -0
  56. package/src/dev-module-config/ui/use-hubspot-live-exports.ts +27 -0
  57. package/src/dev-module-config/ui/use-module-config.ts +124 -0
  58. package/src/routing/build-api-routes.ts +37 -0
  59. package/src/routing/route-format.ts +19 -0
  60. package/templates/module-hybrid-cdn/index.html +12 -1
  61. package/templates/module-hybrid-cdn/package.json +1 -1
  62. package/templates/module-monolith/index.html +12 -0
  63. package/templates/module-monolith/package.json +1 -1
  64. package/templates/theme-starter/package.json +1 -1
@@ -0,0 +1,353 @@
1
+ import { useState, type ReactNode } from 'react'
2
+ import { normalizeFieldChoices } from '../schema/normalize-choices'
3
+ import { isFieldVisible } from '../schema/visibility'
4
+ import { getNestedValue } from '../runtime/module-config-runtime'
5
+ import { HsFieldLabel } from './hubspot/HsFieldLabel'
6
+ import { HsOccurrenceGroupEditor } from './hubspot/HsOccurrenceGroupEditor'
7
+ import { HsToggleRow } from './hubspot/HsToggleRow'
8
+ import { hsTypography } from './hubspot/module-config-typography'
9
+ import { mcMotion } from './module-config-motion'
10
+ import { ModuleConfigCollapsible } from './ModuleConfigCollapsible'
11
+ import { HsImageField } from './hubspot/HsImageField'
12
+ import { HsColorField } from './hubspot/HsColorField'
13
+ import {
14
+ HsLinkField,
15
+ HsNumberField,
16
+ HsRichTextField,
17
+ HsTextField,
18
+ } from './hubspot/HsFieldWidgets'
19
+ import type { VisibilityContext } from '../schema/visibility'
20
+ import type { HubSpotFieldDefinition, ModuleFieldValues } from '../schema/types'
21
+ import { isModuleConfigSectionDefaultCollapsed } from '../schema/types'
22
+
23
+ type FieldRendererProps = {
24
+ field: HubSpotFieldDefinition
25
+ moduleValues: ModuleFieldValues
26
+ pathPrefix?: string
27
+ /** Parent repeater scope for nested drill-down panels (e.g. menu_options.objects.0) */
28
+ editorScope?: string
29
+ onChange: (path: string, value: unknown) => void
30
+ compact?: boolean
31
+ visibilityContext?: VisibilityContext
32
+ validationErrors?: Array<{ path: string; message: string }>
33
+ }
34
+
35
+ function fieldPath(prefix: string | undefined, name: string) {
36
+ return prefix ? `${prefix}.${name}` : name
37
+ }
38
+
39
+ function readRaw(moduleValues: ModuleFieldValues, path: string): unknown {
40
+ return getNestedValue(moduleValues, path)
41
+ }
42
+
43
+ function ColorField({
44
+ field,
45
+ value,
46
+ onChange,
47
+ path,
48
+ }: {
49
+ field: HubSpotFieldDefinition
50
+ value: { color?: string; opacity?: number | string }
51
+ onChange: (path: string, value: unknown) => void
52
+ path: string
53
+ }) {
54
+ return (
55
+ <HsColorField
56
+ label={field.label}
57
+ required={field.required}
58
+ helpText={field.help_text}
59
+ value={value}
60
+ onChange={(next) => onChange(path, next)}
61
+ />
62
+ )
63
+ }
64
+
65
+ function BooleanField({
66
+ field,
67
+ value,
68
+ onChange,
69
+ path,
70
+ }: {
71
+ field: HubSpotFieldDefinition
72
+ value: boolean
73
+ onChange: (path: string, value: unknown) => void
74
+ path: string
75
+ }) {
76
+ const useToggle = field.display === 'toggle' || field.display === undefined || field.display === ''
77
+
78
+ if (useToggle) {
79
+ return (
80
+ <HsToggleRow
81
+ label={field.label}
82
+ required={field.required}
83
+ helpText={field.help_text}
84
+ checked={Boolean(value)}
85
+ onChange={(checked) => onChange(path, checked)}
86
+ />
87
+ )
88
+ }
89
+
90
+ return (
91
+ <label className={`mb-4 flex items-center justify-between gap-3 ${mcMotion.transition}`}>
92
+ <HsFieldLabel label={field.label} required={field.required} helpText={field.help_text} />
93
+ <input
94
+ type="checkbox"
95
+ checked={Boolean(value)}
96
+ onChange={(e) => onChange(path, e.target.checked)}
97
+ className={`h-4 w-4 mc-checkbox ${mcMotion.transition}`}
98
+ />
99
+ </label>
100
+ )
101
+ }
102
+
103
+ function ChoiceField({
104
+ field,
105
+ value,
106
+ onChange,
107
+ path,
108
+ }: {
109
+ field: HubSpotFieldDefinition
110
+ value: string
111
+ onChange: (path: string, value: unknown) => void
112
+ path: string
113
+ }) {
114
+ const choices = normalizeFieldChoices(field)
115
+
116
+ return (
117
+ <div className="mb-4">
118
+ <HsFieldLabel label={field.label} required={field.required} helpText={field.help_text} />
119
+ <select
120
+ className={`w-full rounded px-3 py-2 focus:outline-none ${hsTypography.input} mc-select ${mcMotion.fieldControl}`}
121
+ value={value}
122
+ onChange={(e) => onChange(path, e.target.value)}
123
+ >
124
+ {choices.map((choice) => (
125
+ <option key={choice.value} value={choice.value}>
126
+ {choice.label}
127
+ </option>
128
+ ))}
129
+ </select>
130
+ </div>
131
+ )
132
+ }
133
+
134
+ function groupInitialOpen(field: HubSpotFieldDefinition): boolean {
135
+ if (isModuleConfigSectionDefaultCollapsed(field.name)) {
136
+ return false
137
+ }
138
+ return field.expanded ?? true
139
+ }
140
+
141
+ function GroupField({
142
+ field,
143
+ moduleValues,
144
+ pathPrefix,
145
+ onChange,
146
+ compact,
147
+ visibilityContext,
148
+ validationErrors,
149
+ }: FieldRendererProps) {
150
+ const [open, setOpen] = useState(() => groupInitialOpen(field))
151
+ const path = fieldPath(pathPrefix, field.name)
152
+
153
+ return (
154
+ <ModuleConfigCollapsible
155
+ open={open}
156
+ onToggle={() => setOpen((v) => !v)}
157
+ label={field.label}
158
+ headerClassName={hsTypography.groupHeader}
159
+ >
160
+ {(field.children ?? []).map((child) => (
161
+ <FieldRenderer
162
+ key={child.id ?? child.name}
163
+ field={child}
164
+ moduleValues={moduleValues}
165
+ pathPrefix={path}
166
+ onChange={onChange}
167
+ compact={compact}
168
+ visibilityContext={visibilityContext}
169
+ validationErrors={validationErrors}
170
+ />
171
+ ))}
172
+ </ModuleConfigCollapsible>
173
+ )
174
+ }
175
+
176
+ function isOccurrenceGroup(field: HubSpotFieldDefinition): boolean {
177
+ return Boolean(field.occurrence && field.children?.length)
178
+ }
179
+
180
+ function FieldError({ path, validationErrors }: { path: string; validationErrors?: FieldRendererProps['validationErrors'] }) {
181
+ const error = validationErrors?.find((item) => item.path === path)
182
+ if (!error) return null
183
+ return <p className={`mb-2 ${hsTypography.error}`}>{error.message}</p>
184
+ }
185
+
186
+ export function FieldRenderer({
187
+ field,
188
+ moduleValues,
189
+ pathPrefix,
190
+ editorScope,
191
+ onChange,
192
+ compact,
193
+ visibilityContext,
194
+ validationErrors,
195
+ }: FieldRendererProps) {
196
+ const visibilityCtx = visibilityContext ?? { moduleValues }
197
+ if (!isFieldVisible(field, visibilityCtx)) return null
198
+
199
+ const path = fieldPath(pathPrefix, field.name)
200
+ const raw = readRaw(moduleValues, path)
201
+
202
+ if (field.type === 'group' && !isOccurrenceGroup(field)) {
203
+ return (
204
+ <GroupField
205
+ field={field}
206
+ moduleValues={moduleValues}
207
+ pathPrefix={pathPrefix}
208
+ onChange={onChange}
209
+ compact={compact}
210
+ visibilityContext={visibilityContext}
211
+ validationErrors={validationErrors}
212
+ />
213
+ )
214
+ }
215
+
216
+ if (isOccurrenceGroup(field)) {
217
+ const items = Array.isArray(raw) ? (raw as Array<ModuleFieldValues>) : []
218
+ const rootModule = visibilityContext?.moduleValues ?? moduleValues
219
+ const labelField =
220
+ field.name === 'properties'
221
+ ? 'properties_value'
222
+ : field.name === 'dashboard_cards'
223
+ ? 'card_title'
224
+ : 'list_object_name'
225
+
226
+ return (
227
+ <>
228
+ <FieldError path={path} validationErrors={validationErrors} />
229
+ <HsOccurrenceGroupEditor
230
+ field={field}
231
+ items={items}
232
+ moduleValues={rootModule}
233
+ path={path}
234
+ editorScope={editorScope}
235
+ onChange={onChange}
236
+ labelField={labelField}
237
+ sectionLabel={field.label?.trim() || field.label}
238
+ visibilityContext={visibilityContext}
239
+ />
240
+ </>
241
+ )
242
+ }
243
+
244
+ const wrap = (node: ReactNode) => (
245
+ <div className="mc-field-enter">
246
+ <FieldError path={path} validationErrors={validationErrors} />
247
+ {node}
248
+ </div>
249
+ )
250
+
251
+ if (field.type === 'boolean') {
252
+ return wrap(<BooleanField field={field} value={Boolean(raw)} onChange={onChange} path={path} />)
253
+ }
254
+
255
+ if (field.type === 'color') {
256
+ return wrap(
257
+ <ColorField
258
+ field={field}
259
+ value={(raw as { color?: string; opacity?: number }) ?? { color: '', opacity: 100 }}
260
+ onChange={onChange}
261
+ path={path}
262
+ />,
263
+ )
264
+ }
265
+
266
+ if (field.type === 'image') {
267
+ return wrap(
268
+ <HsImageField
269
+ label={field.label}
270
+ required={field.required}
271
+ helpText={field.help_text}
272
+ value={(raw as Record<string, unknown>) ?? { src: '', alt: '' }}
273
+ onChange={(value) => onChange(path, value)}
274
+ />,
275
+ )
276
+ }
277
+
278
+ if (field.type === 'link' || field.type === 'url') {
279
+ const linkValue =
280
+ raw && typeof raw === 'object' && !Array.isArray(raw)
281
+ ? (raw as {
282
+ url?: { href?: string; type?: string }
283
+ open_in_new_tab?: boolean
284
+ no_follow?: boolean
285
+ })
286
+ : {
287
+ url: { href: typeof raw === 'string' ? raw : '', type: 'EXTERNAL' },
288
+ open_in_new_tab: false,
289
+ no_follow: false,
290
+ }
291
+ return wrap(
292
+ <HsLinkField
293
+ label={field.label}
294
+ required={field.required}
295
+ helpText={field.help_text}
296
+ value={linkValue}
297
+ onChange={(value) => onChange(path, value)}
298
+ />,
299
+ )
300
+ }
301
+
302
+ if (field.type === 'choice') {
303
+ return wrap(<ChoiceField field={field} value={String(raw ?? '')} onChange={onChange} path={path} />)
304
+ }
305
+
306
+ if (field.type === 'richtext') {
307
+ return wrap(
308
+ <HsRichTextField
309
+ label={field.label}
310
+ required={field.required}
311
+ helpText={field.help_text}
312
+ value={String(raw ?? '')}
313
+ onChange={(value) => onChange(path, value)}
314
+ />,
315
+ )
316
+ }
317
+
318
+ if (field.type === 'number') {
319
+ return wrap(
320
+ <HsNumberField
321
+ label={field.label}
322
+ required={field.required}
323
+ helpText={field.help_text}
324
+ value={Number(raw ?? 0)}
325
+ onChange={(value) => onChange(path, value)}
326
+ />,
327
+ )
328
+ }
329
+
330
+ if (field.type === 'text') {
331
+ const isSvg = field.name.includes('svg') || field.label.toLowerCase().includes('svg')
332
+ return wrap(
333
+ <HsTextField
334
+ label={field.label}
335
+ required={field.required}
336
+ helpText={field.help_text}
337
+ value={String(raw ?? '')}
338
+ onChange={(value) => onChange(path, value)}
339
+ multiline={isSvg}
340
+ monospace={isSvg}
341
+ darkEditor={isSvg}
342
+ />,
343
+ )
344
+ }
345
+
346
+ if (compact) return null
347
+
348
+ return (
349
+ <div className={`mb-2 ${hsTypography.helper}`}>
350
+ Unsupported field: {field.label} ({field.type})
351
+ </div>
352
+ )
353
+ }
@@ -0,0 +1,72 @@
1
+ import { useEffect, useRef, useState, type ReactNode } from 'react'
2
+ import { getModuleConfigMotionMs, mcMotion, type ModuleConfigEnterFrom } from './module-config-motion'
3
+
4
+ type ModuleConfigAnimatedPresenceProps = {
5
+ show: boolean
6
+ children: ReactNode
7
+ className?: string
8
+ variant?: 'slide' | 'fade'
9
+ enterFrom?: ModuleConfigEnterFrom
10
+ }
11
+
12
+ export function ModuleConfigAnimatedPresence({
13
+ show,
14
+ children,
15
+ className = '',
16
+ variant = 'fade',
17
+ enterFrom = 'bottom',
18
+ }: ModuleConfigAnimatedPresenceProps) {
19
+ const [mounted, setMounted] = useState(show)
20
+ const [visible, setVisible] = useState(show)
21
+ const contentRef = useRef<ReactNode>(children)
22
+
23
+ if (show || mounted) {
24
+ contentRef.current = children
25
+ }
26
+
27
+ useEffect(() => {
28
+ const motionMs = getModuleConfigMotionMs()
29
+
30
+ if (show) {
31
+ setMounted(true)
32
+ if (motionMs === 0) {
33
+ setVisible(true)
34
+ return
35
+ }
36
+ const frame = requestAnimationFrame(() => {
37
+ requestAnimationFrame(() => setVisible(true))
38
+ })
39
+ return () => cancelAnimationFrame(frame)
40
+ }
41
+
42
+ setVisible(false)
43
+ const timer = window.setTimeout(() => setMounted(false), motionMs)
44
+ return () => clearTimeout(timer)
45
+ }, [show])
46
+
47
+ if (!mounted) return null
48
+
49
+ if (variant === 'slide') {
50
+ const slideFrom = enterFrom === 'left' ? 'left' : 'right'
51
+
52
+ return (
53
+ <div
54
+ className={`mc-slide-panel ${className}`.trim()}
55
+ data-visible={visible ? 'true' : 'false'}
56
+ data-enter-from={slideFrom}
57
+ >
58
+ {contentRef.current}
59
+ </div>
60
+ )
61
+ }
62
+
63
+ return (
64
+ <div
65
+ className={`${mcMotion.transition} ${className} ${
66
+ visible ? 'translate-y-0 opacity-100' : 'pointer-events-none translate-y-2 opacity-0'
67
+ }`.trim()}
68
+ >
69
+ {contentRef.current}
70
+ </div>
71
+ )
72
+ }
@@ -0,0 +1,43 @@
1
+ import type { ReactNode } from 'react'
2
+ import { mcMotion } from './module-config-motion'
3
+ import { mcSurface } from './module-config-surfaces'
4
+
5
+ type ModuleConfigCollapsibleProps = {
6
+ open: boolean
7
+ label: ReactNode
8
+ onToggle: () => void
9
+ children: ReactNode
10
+ headerClassName?: string
11
+ }
12
+
13
+ export function ModuleConfigCollapsible({
14
+ open,
15
+ label,
16
+ onToggle,
17
+ children,
18
+ headerClassName = '',
19
+ }: ModuleConfigCollapsibleProps) {
20
+ return (
21
+ <div className={`mb-3 ${mcSurface.panel}`}>
22
+ <button
23
+ type="button"
24
+ className={`flex w-full items-center justify-between px-3 py-2.5 text-left ${headerClassName}`}
25
+ onClick={onToggle}
26
+ aria-expanded={open}
27
+ >
28
+ {label}
29
+ <span
30
+ className={`inline-block ${mcSurface.textFaint} ${mcMotion.transform} ${open ? 'rotate-0' : '-rotate-90'}`}
31
+ aria-hidden
32
+ >
33
+ ▾
34
+ </span>
35
+ </button>
36
+ <div className={`mc-collapsible-grid ${open ? 'mc-collapsible-open' : ''}`}>
37
+ <div className="mc-collapsible-inner">
38
+ <div className="mc-collapsible-body px-3 py-2">{children}</div>
39
+ </div>
40
+ </div>
41
+ </div>
42
+ )
43
+ }
@@ -0,0 +1,27 @@
1
+ type DockSide = 'left' | 'right'
2
+
3
+ type ModuleConfigDockSideIconProps = {
4
+ /** Target side when clicked (where the panel will dock). */
5
+ side: DockSide
6
+ className?: string
7
+ }
8
+
9
+ /** Rounded frame with a filled sidebar on left or right — matches dev toolbar dock control. */
10
+ export function ModuleConfigDockSideIcon({ side, className = 'h-4 w-4' }: ModuleConfigDockSideIconProps) {
11
+ const barX = side === 'left' ? 4.5 : 11.5
12
+
13
+ return (
14
+ <svg viewBox="0 0 20 20" className={className} fill="none" aria-hidden>
15
+ <rect
16
+ x="3"
17
+ y="4"
18
+ width="14"
19
+ height="12"
20
+ rx="2"
21
+ stroke="currentColor"
22
+ strokeWidth="1.5"
23
+ />
24
+ <rect x={barX} y="6" width="4" height="8" rx="0.75" fill="currentColor" />
25
+ </svg>
26
+ )
27
+ }
@@ -0,0 +1,134 @@
1
+ import { useCallback, useEffect, useRef, useState } from 'react'
2
+ import { createPortal } from 'react-dom'
3
+ import './module-config-theme.css'
4
+ import { ModuleConfigShell } from './ModuleConfigShell'
5
+ import { ModuleConfigUiProvider } from './ModuleConfigUiContext'
6
+ import {
7
+ loadModuleConfigOverlaySide,
8
+ toggleModuleConfigOverlaySide,
9
+ type ModuleConfigOverlaySide,
10
+ } from './module-config-overlay-side'
11
+
12
+ const OVERLAY_TRANSITION_MS = 280
13
+
14
+ function getTransitionMs(): number {
15
+ if (typeof window === 'undefined') return OVERLAY_TRANSITION_MS
16
+ return window.matchMedia('(prefers-reduced-motion: reduce)').matches ? 0 : OVERLAY_TRANSITION_MS
17
+ }
18
+
19
+ export function ModuleConfigOverlay() {
20
+ const [open, setOpen] = useState(false)
21
+ const [mounted, setMounted] = useState(false)
22
+ const [entered, setEntered] = useState(false)
23
+ const [side, setSide] = useState<ModuleConfigOverlaySide>(() => loadModuleConfigOverlaySide())
24
+ const [sideTransitioning, setSideTransitioning] = useState(false)
25
+ const sideSwitchTimerRef = useRef<number | null>(null)
26
+
27
+ const clearSideSwitchTimer = useCallback(() => {
28
+ if (sideSwitchTimerRef.current != null) {
29
+ window.clearTimeout(sideSwitchTimerRef.current)
30
+ sideSwitchTimerRef.current = null
31
+ }
32
+ }, [])
33
+
34
+ const requestClose = useCallback(() => {
35
+ setOpen(false)
36
+ }, [])
37
+
38
+ useEffect(() => () => clearSideSwitchTimer(), [clearSideSwitchTimer])
39
+
40
+ useEffect(() => {
41
+ const onKeyDown = (event: KeyboardEvent) => {
42
+ if ((event.ctrlKey || event.metaKey) && event.shiftKey && event.key.toLowerCase() === 'z') {
43
+ event.preventDefault()
44
+ setOpen((value) => !value)
45
+ }
46
+ }
47
+ window.addEventListener('keydown', onKeyDown)
48
+ return () => window.removeEventListener('keydown', onKeyDown)
49
+ }, [])
50
+
51
+ useEffect(() => {
52
+ const transitionMs = getTransitionMs()
53
+
54
+ if (open) {
55
+ setMounted(true)
56
+ if (transitionMs === 0) {
57
+ setEntered(true)
58
+ return
59
+ }
60
+ const enterFrame = requestAnimationFrame(() => {
61
+ requestAnimationFrame(() => setEntered(true))
62
+ })
63
+ return () => cancelAnimationFrame(enterFrame)
64
+ }
65
+
66
+ setEntered(false)
67
+ setSideTransitioning(false)
68
+ clearSideSwitchTimer()
69
+ const timer = window.setTimeout(() => setMounted(false), transitionMs)
70
+ return () => clearTimeout(timer)
71
+ }, [clearSideSwitchTimer, open])
72
+
73
+ const onToggleSide = useCallback(() => {
74
+ if (sideTransitioning || !entered) return
75
+
76
+ const transitionMs = getTransitionMs()
77
+ if (transitionMs === 0) {
78
+ setSide((current) => toggleModuleConfigOverlaySide(current))
79
+ return
80
+ }
81
+
82
+ setSideTransitioning(true)
83
+ setEntered(false)
84
+ clearSideSwitchTimer()
85
+
86
+ sideSwitchTimerRef.current = window.setTimeout(() => {
87
+ sideSwitchTimerRef.current = null
88
+ setSide((current) => toggleModuleConfigOverlaySide(current))
89
+ requestAnimationFrame(() => {
90
+ requestAnimationFrame(() => {
91
+ setEntered(true)
92
+ setSideTransitioning(false)
93
+ })
94
+ })
95
+ }, transitionMs)
96
+ }, [clearSideSwitchTimer, entered, sideTransitioning])
97
+
98
+ if (!mounted) return null
99
+
100
+ const colorPickerPlacement = side === 'right' ? 'left' : 'right'
101
+ const enteredClass = entered ? 'mc-overlay-entered' : ''
102
+ const inertClass = entered && !sideTransitioning ? '' : 'mc-overlay-inert'
103
+
104
+ return createPortal(
105
+ <ModuleConfigUiProvider colorPickerPlacement={colorPickerPlacement} layout="overlay">
106
+ <div aria-hidden className={`mc-overlay-backdrop ${enteredClass}`} />
107
+ <aside
108
+ role="dialog"
109
+ aria-modal="true"
110
+ aria-label="Module config"
111
+ data-side={side}
112
+ className={`mc-toolbar-root mc-overlay-drawer ${enteredClass} ${inertClass}`}
113
+ >
114
+ <div className="min-h-0 flex-1">
115
+ <ModuleConfigShell
116
+ compact
117
+ overlaySide={side}
118
+ overlaySideTransitioning={sideTransitioning}
119
+ onToggleOverlaySide={onToggleSide}
120
+ onRequestClose={requestClose}
121
+ showToolbar
122
+ />
123
+ </div>
124
+ </aside>
125
+ </ModuleConfigUiProvider>,
126
+ document.body,
127
+ )
128
+ }
129
+
130
+ export function registerModuleConfigShortcutHint() {
131
+ if (import.meta.env.DEV) {
132
+ console.info('[WoodsPortal] Module config overlay: Ctrl+Shift+Z (dock left/right in panel header)')
133
+ }
134
+ }