@adea-ai/ui 0.123.0 → 0.124.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 (90) hide show
  1. package/dist/NOTICE +18 -0
  2. package/dist/components/layout/split-layout/resize-controller-core-retry.d.ts +13 -0
  3. package/dist/components/layout/split-layout/resize-controller-core-retry.d.ts.map +1 -0
  4. package/dist/components/layout/split-layout/resize-controller-core-retry.js +63 -0
  5. package/dist/components/layout/split-layout/resize-controller-core-retry.js.map +1 -0
  6. package/dist/components/layout/split-layout/resize-controller-core.d.ts +13 -0
  7. package/dist/components/layout/split-layout/resize-controller-core.d.ts.map +1 -0
  8. package/dist/components/layout/split-layout/resize-controller-core.js +63 -0
  9. package/dist/components/layout/split-layout/resize-controller-core.js.map +1 -0
  10. package/dist/components/layout/split-layout/resize-controller-retry-2.d.ts +3 -0
  11. package/dist/components/layout/split-layout/resize-controller-retry-2.d.ts.map +1 -0
  12. package/dist/components/layout/split-layout/resize-controller-retry-2.js +2 -0
  13. package/dist/components/layout/split-layout/resize-controller-retry.d.ts +2 -0
  14. package/dist/components/layout/split-layout/resize-controller-retry.d.ts.map +1 -0
  15. package/dist/components/layout/split-layout/resize-controller-retry.js +2 -0
  16. package/dist/components/layout/split-layout/resize-controller.d.ts +2 -0
  17. package/dist/components/layout/split-layout/resize-controller.d.ts.map +1 -0
  18. package/dist/components/layout/split-layout/resize-controller.js +2 -0
  19. package/dist/components/layout/split-layout/split-layout.d.ts.map +1 -1
  20. package/dist/components/layout/split-layout/split-layout.js +171 -60
  21. package/dist/components/layout/split-layout/split-layout.js.map +1 -1
  22. package/dist/components/theme/appearance-panel.js +1 -1
  23. package/dist/components/theme/index.d.ts +1 -1
  24. package/dist/components/theme/index.d.ts.map +1 -1
  25. package/dist/components/theme/index.js +5 -2
  26. package/dist/components/theme/theme-context.d.ts +13 -0
  27. package/dist/components/theme/theme-context.d.ts.map +1 -0
  28. package/dist/components/theme/theme-context.js +41 -0
  29. package/dist/components/theme/theme-context.js.map +1 -0
  30. package/dist/components/theme/theme-document.d.ts +76 -0
  31. package/dist/components/theme/theme-document.d.ts.map +1 -0
  32. package/dist/components/theme/theme-document.js +93 -0
  33. package/dist/components/theme/theme-document.js.map +1 -0
  34. package/dist/components/theme/theme-persistence.d.ts +50 -0
  35. package/dist/components/theme/theme-persistence.d.ts.map +1 -0
  36. package/dist/components/theme/theme-persistence.js +86 -0
  37. package/dist/components/theme/theme-persistence.js.map +1 -0
  38. package/dist/components/theme/theme-provider.d.ts +43 -75
  39. package/dist/components/theme/theme-provider.d.ts.map +1 -1
  40. package/dist/components/theme/theme-provider.js +50 -116
  41. package/dist/components/theme/theme-provider.js.map +1 -1
  42. package/dist/components/theme/theme-state.d.ts +142 -0
  43. package/dist/components/theme/theme-state.d.ts.map +1 -0
  44. package/dist/components/theme/theme-state.js +104 -0
  45. package/dist/components/theme/theme-state.js.map +1 -0
  46. package/dist/components/theme/theme-toggle.js +1 -1
  47. package/dist/index.d.ts +1 -1
  48. package/dist/index.d.ts.map +1 -1
  49. package/dist/index.js +6 -3
  50. package/dist/lib/appearance-font-settings.d.ts +1 -1
  51. package/dist/lib/appearance-font-settings.d.ts.map +1 -1
  52. package/dist/lib/appearance-font-settings.js.map +1 -1
  53. package/dist/lib/themes.d.ts +48 -0
  54. package/dist/lib/themes.d.ts.map +1 -1
  55. package/dist/lib/themes.js +150 -36
  56. package/dist/lib/themes.js.map +1 -1
  57. package/dist/r/registry.json +46 -0
  58. package/dist/r/split-layout.json +26 -0
  59. package/dist/r/src/components/layout/split-layout/resize-controller-core-retry.tsx +66 -0
  60. package/dist/r/src/components/layout/split-layout/resize-controller-core.tsx +66 -0
  61. package/dist/r/src/components/layout/split-layout/resize-controller-retry-2.tsx +2 -0
  62. package/dist/r/src/components/layout/split-layout/resize-controller-retry.tsx +1 -0
  63. package/dist/r/src/components/layout/split-layout/resize-controller.tsx +1 -0
  64. package/dist/r/src/components/layout/split-layout/split-layout.tsx +197 -49
  65. package/dist/r/src/components/theme/index.ts +3 -0
  66. package/dist/r/src/components/theme/theme-context.ts +43 -0
  67. package/dist/r/src/components/theme/theme-document.ts +168 -0
  68. package/dist/r/src/components/theme/theme-persistence.ts +140 -0
  69. package/dist/r/src/components/theme/theme-provider.tsx +126 -253
  70. package/dist/r/src/components/theme/theme-state.ts +225 -0
  71. package/dist/r/src/lib/appearance-font-settings.ts +1 -1
  72. package/dist/r/src/lib/themes.ts +181 -40
  73. package/dist/r/theming.json +20 -0
  74. package/package.json +1 -1
  75. package/registry.json +46 -0
  76. package/src/components/layout/split-layout/resize-controller-core-retry.tsx +66 -0
  77. package/src/components/layout/split-layout/resize-controller-core.tsx +66 -0
  78. package/src/components/layout/split-layout/resize-controller-retry-2.tsx +2 -0
  79. package/src/components/layout/split-layout/resize-controller-retry.tsx +1 -0
  80. package/src/components/layout/split-layout/resize-controller.tsx +1 -0
  81. package/src/components/layout/split-layout/split-layout.tsx +197 -49
  82. package/src/components/theme/index.ts +3 -0
  83. package/src/components/theme/theme-context.ts +43 -0
  84. package/src/components/theme/theme-document.ts +168 -0
  85. package/src/components/theme/theme-persistence.ts +140 -0
  86. package/src/components/theme/theme-provider.tsx +126 -253
  87. package/src/components/theme/theme-state.ts +225 -0
  88. package/src/index.ts +3 -0
  89. package/src/lib/appearance-font-settings.ts +1 -1
  90. package/src/lib/themes.ts +181 -40
@@ -0,0 +1,66 @@
1
+ import Resizable from '@corvu/resizable'
2
+ import { type Accessor, type JSX } from 'solid-js'
3
+ import {
4
+ listLeaves,
5
+ MIN_SPLIT_RATIO,
6
+ MAX_SPLIT_RATIO,
7
+ type SplitLayoutBranch,
8
+ type SplitLayoutLeaf,
9
+ } from './model'
10
+
11
+ export type SplitLayoutResizeControllerProps<L extends SplitLayoutLeaf> = {
12
+ branch: Accessor<SplitLayoutBranch<L>>
13
+ rect: Accessor<JSX.CSSProperties>
14
+ domId: (id: string) => string
15
+ root: () => HTMLDivElement | undefined
16
+ labelForSeparator?: (branch: SplitLayoutBranch<L>) => string
17
+ onResize: (splitId: string, ratio: number) => void
18
+ }
19
+
20
+ /** Corvu stays behind the first real split while the pane owners remain mounted. */
21
+ export default function SplitLayoutResizeController<L extends SplitLayoutLeaf>(
22
+ props: SplitLayoutResizeControllerProps<L>
23
+ ) {
24
+ const id = () => props.branch().id
25
+ return (
26
+ <Resizable
27
+ orientation={props.branch().direction === 'row' ? 'horizontal' : 'vertical'}
28
+ sizes={[props.branch().ratio, 1 - props.branch().ratio]}
29
+ keyboardDelta={0.05}
30
+ onSizesChange={(sizes) => {
31
+ // Corvu also reports panel registration/unregistration while
32
+ // mounting or disposing a controller. Those incomplete or
33
+ // hidden-host sizes are not user resize intent.
34
+ const root = props.root()
35
+ if (!root?.isConnected || root.getClientRects().length === 0) return
36
+ if (
37
+ sizes.length !== 2 ||
38
+ sizes.some((size) => !Number.isFinite(size)) ||
39
+ Math.abs(sizes[0]! + sizes[1]! - 1) > 0.000001
40
+ )
41
+ return
42
+ if (sizes[0] !== undefined && Math.abs(sizes[0] - props.branch().ratio) > 0.000001)
43
+ props.onResize(id(), sizes[0])
44
+ }}
45
+ style={props.rect()}
46
+ class="pointer-events-none absolute flex min-h-0 min-w-0 data-[orientation=vertical]:flex-col"
47
+ >
48
+ <Resizable.Panel minSize={MIN_SPLIT_RATIO} maxSize={MAX_SPLIT_RATIO} aria-hidden="true" />
49
+ <Resizable.Handle
50
+ aria-label={
51
+ props.labelForSeparator?.(props.branch()) ??
52
+ (props.branch().direction === 'row' ? 'Resize pane columns' : 'Resize pane rows')
53
+ }
54
+ aria-orientation={props.branch().direction === 'row' ? 'vertical' : 'horizontal'}
55
+ aria-controls={listLeaves(props.branch())
56
+ .map((leaf) => props.domId(leaf.id))
57
+ .join(' ')}
58
+ aria-valuemin={10}
59
+ aria-valuemax={90}
60
+ aria-valuenow={Math.round(props.branch().ratio * 100)}
61
+ class="pointer-events-auto relative w-px shrink-0 bg-border after:absolute after:inset-y-0 after:-inset-x-1 after:w-3 focus-visible:bg-primary focus-visible:outline-none hover:bg-primary data-[orientation=vertical]:h-px data-[orientation=vertical]:w-full data-[orientation=vertical]:after:inset-x-0 data-[orientation=vertical]:after:-inset-y-1 data-[orientation=vertical]:after:h-3 data-[orientation=vertical]:after:w-full"
62
+ />
63
+ <Resizable.Panel minSize={MIN_SPLIT_RATIO} maxSize={MAX_SPLIT_RATIO} aria-hidden="true" />
64
+ </Resizable>
65
+ )
66
+ }
@@ -0,0 +1,2 @@
1
+ /** Independent module entrypoint to recover from a cached failed primary core import. */
2
+ export { default } from './resize-controller-core-retry'
@@ -0,0 +1 @@
1
+ export { default } from './resize-controller-core'
@@ -0,0 +1 @@
1
+ export { default } from './resize-controller-core'
@@ -1,17 +1,21 @@
1
- import Resizable from '@corvu/resizable'
2
1
  import {
3
2
  For,
4
- createMemo,
3
+ createEffect,
5
4
  createComputed,
5
+ createMemo,
6
6
  createUniqueId,
7
7
  createSignal,
8
+ onMount,
8
9
  onCleanup,
9
10
  on,
11
+ Show,
10
12
  type Accessor,
11
13
  type JSX,
12
14
  } from 'solid-js'
15
+ import { Dynamic } from 'solid-js/web'
13
16
  import { X, GripVertical } from 'lucide-solid'
14
17
  import { ActionButton } from '../../composites/action-button'
18
+ import { Button } from '../../ui/button'
15
19
  import { cn } from '../../../lib/utils'
16
20
  import { computeLayoutFrames, type LayoutRect } from './geometry'
17
21
  import { paneDropIntent, type PaneDropIntent } from './drop'
@@ -21,12 +25,16 @@ const DRAG_PREVIEW_MAX_WIDTH = 384
21
25
  const DRAG_PREVIEW_MAX_HEIGHT = 256
22
26
  import {
23
27
  listLeaves,
24
- MIN_SPLIT_RATIO,
25
- MAX_SPLIT_RATIO,
26
28
  type SplitLayoutBranch,
27
29
  type SplitLayoutLeaf,
28
30
  type SplitLayoutState,
29
31
  } from './model'
32
+ import type { SplitLayoutResizeControllerProps } from './resize-controller-core'
33
+
34
+ type ResizeController<L extends SplitLayoutLeaf> = (
35
+ props: SplitLayoutResizeControllerProps<L>
36
+ ) => JSX.Element
37
+ type ResizeControllerState<L extends SplitLayoutLeaf> = ResizeController<L> | false | 'error'
30
38
 
31
39
  export type SplitLayoutProps<L extends SplitLayoutLeaf> = {
32
40
  state: SplitLayoutState<L>
@@ -59,6 +67,16 @@ function rectStyle(rect: LayoutRect): JSX.CSSProperties {
59
67
  height: `${rect.height * 100}%`,
60
68
  }
61
69
  }
70
+ function separatorPlaceholderStyle(branch: SplitLayoutBranch, rect: LayoutRect): JSX.CSSProperties {
71
+ const horizontal = branch.direction === 'row'
72
+ return {
73
+ left: `${(rect.x + (horizontal ? rect.width * branch.ratio : 0)) * 100}%`,
74
+ top: `${(rect.y + (horizontal ? 0 : rect.height * branch.ratio)) * 100}%`,
75
+ width: horizontal ? '1px' : `${rect.width * 100}%`,
76
+ height: horizontal ? `${rect.height * 100}%` : '1px',
77
+ transform: horizontal ? 'translateX(-50%)' : 'translateY(-50%)',
78
+ }
79
+ }
62
80
  function dragPreviewPlaceholder(source: HTMLElement, message: string) {
63
81
  const placeholder = document.createElement('div')
64
82
  placeholder.className = cn(
@@ -171,14 +189,40 @@ function cloneDragPreviewNode(source: Node): Node | undefined {
171
189
  export function SplitLayout<L extends SplitLayoutLeaf>(props: SplitLayoutProps<L>) {
172
190
  let root: HTMLDivElement | undefined
173
191
  let pendingFrame: number | undefined
192
+ let disposed = false
193
+ let resizeLoadGeneration = 0
194
+ let retryWithAlternateImport = false
195
+ let alternateImportAttempts = 0
196
+ let focusHandleAfterRetry = false
197
+ let focusRecoveryButtonAfterError = false
198
+ let focusRestoreEpoch = 0
199
+ let focusEpoch = 0
200
+ let pendingFocusFrame: number | undefined
174
201
  const refs = new Map<string, HTMLElement>()
175
202
  const domIds = new Map<string, string>()
176
203
  const prefix = createUniqueId()
204
+ const resizeReloadWarningId = `${prefix}-resize-reload-warning`
177
205
  let nextDomId = 0
178
206
  let nextDragId = 0
179
207
  let dragPreview: HTMLElement | undefined
180
208
  const [drag, setDrag] = createSignal<{ id: string; token: string }>()
181
209
  const [drop, setDrop] = createSignal<{ id: string; intent: PaneDropIntent }>()
210
+ const [resizeController, setResizeController] = createSignal<ResizeControllerState<L>>()
211
+ const cancelPendingFocusRestore = () => {
212
+ if (pendingFocusFrame !== undefined) cancelAnimationFrame(pendingFocusFrame)
213
+ pendingFocusFrame = undefined
214
+ }
215
+ const trackFocusChange = (event: FocusEvent) => {
216
+ if (event.target === document.body) return
217
+ focusEpoch += 1
218
+ focusHandleAfterRetry = false
219
+ focusRecoveryButtonAfterError = false
220
+ cancelPendingFocusRestore()
221
+ }
222
+ onMount(() => {
223
+ document.addEventListener('focusin', trackFocusChange)
224
+ onCleanup(() => document.removeEventListener('focusin', trackFocusChange))
225
+ })
182
226
  const clearDrag = () => {
183
227
  setDrag(undefined)
184
228
  setDrop(undefined)
@@ -221,6 +265,79 @@ export function SplitLayout<L extends SplitLayoutLeaf>(props: SplitLayoutProps<L
221
265
  frame.node.kind === 'split' ? [frame.node] : []
222
266
  )
223
267
  )
268
+ createEffect(() => {
269
+ if (branches().length === 0) {
270
+ resizeLoadGeneration += 1
271
+ focusHandleAfterRetry = false
272
+ focusRecoveryButtonAfterError = false
273
+ cancelPendingFocusRestore()
274
+ // Keep a loaded controller so a later split does not re-import a facade
275
+ // that may have failed before recovery. Clear only transient UI state.
276
+ if (resizeController() === false || resizeController() === 'error')
277
+ setResizeController(undefined)
278
+ return
279
+ }
280
+ if (resizeController() !== undefined) return
281
+ const retry = retryWithAlternateImport
282
+ const generation = ++resizeLoadGeneration
283
+ setResizeController(false)
284
+ const controllerImport = retry
285
+ ? alternateImportAttempts++ === 0
286
+ ? import('./resize-controller-retry')
287
+ : import('./resize-controller-retry-2')
288
+ : import('./resize-controller')
289
+ void controllerImport.then(
290
+ ({ default: Controller }) => {
291
+ if (disposed || generation !== resizeLoadGeneration || branches().length === 0) return
292
+ const shouldFocusHandle = focusHandleAfterRetry && focusEpoch === focusRestoreEpoch
293
+ focusHandleAfterRetry = false
294
+ focusRecoveryButtonAfterError = false
295
+ retryWithAlternateImport = false
296
+ alternateImportAttempts = 0
297
+ setResizeController(() => Controller)
298
+ if (shouldFocusHandle)
299
+ pendingFocusFrame = requestAnimationFrame(() => {
300
+ pendingFocusFrame = undefined
301
+ if (
302
+ disposed ||
303
+ generation !== resizeLoadGeneration ||
304
+ branches().length === 0 ||
305
+ focusEpoch !== focusRestoreEpoch
306
+ )
307
+ return
308
+ const currentFocus = root?.ownerDocument.activeElement
309
+ if (currentFocus && currentFocus !== root?.ownerDocument.body && currentFocus !== root)
310
+ return
311
+ root?.querySelector<HTMLElement>('[data-corvu-resizable-handle]')?.focus({
312
+ preventScroll: true,
313
+ })
314
+ })
315
+ },
316
+ () => {
317
+ // A rejected dynamic import can remain cached under its specifier.
318
+ // The next split must start from the alternate module entry as well.
319
+ retryWithAlternateImport = true
320
+ if (!disposed && generation === resizeLoadGeneration && branches().length > 0) {
321
+ setResizeController('error')
322
+ const shouldFocusRetry = focusRecoveryButtonAfterError && focusEpoch === focusRestoreEpoch
323
+ if (shouldFocusRetry)
324
+ pendingFocusFrame = requestAnimationFrame(() => {
325
+ pendingFocusFrame = undefined
326
+ if (
327
+ disposed ||
328
+ generation !== resizeLoadGeneration ||
329
+ branches().length === 0 ||
330
+ focusEpoch !== focusRestoreEpoch
331
+ )
332
+ return
333
+ root?.querySelector<HTMLButtonElement>('[data-resize-retry]')?.focus({
334
+ preventScroll: true,
335
+ })
336
+ })
337
+ }
338
+ }
339
+ )
340
+ })
224
341
  createComputed(() => {
225
342
  const current = drag()
226
343
  if (current && (!props.onMove || !leafMap().has(current.id))) clearDrag()
@@ -288,6 +405,10 @@ export function SplitLayout<L extends SplitLayoutLeaf>(props: SplitLayoutProps<L
288
405
  )
289
406
  )
290
407
  onCleanup(() => {
408
+ disposed = true
409
+ focusHandleAfterRetry = false
410
+ focusRecoveryButtonAfterError = false
411
+ cancelPendingFocusRestore()
291
412
  if (pendingFrame !== undefined) cancelAnimationFrame(pendingFrame)
292
413
  clearDrag()
293
414
  refs.clear()
@@ -452,62 +573,89 @@ export function SplitLayout<L extends SplitLayoutLeaf>(props: SplitLayoutProps<L
452
573
  }}
453
574
  </For>
454
575
  <span class="sr-only" role="status">
455
- {dropLabel()}
576
+ {dropLabel() ||
577
+ (resizeController() === 'error'
578
+ ? alternateImportAttempts >= 2
579
+ ? 'Resize controls did not load after repeated attempts. Reloading may discard unsaved pane content.'
580
+ : 'Resize controls did not load.'
581
+ : '')}
456
582
  </span>
457
583
  <For each={branches().map((branch) => branch.id)}>
458
584
  {(id) => {
459
585
  const initial = branchMap().get(id)!
460
586
  const branch: Accessor<SplitLayoutBranch<L>> = () => branchMap().get(id) ?? initial
587
+ const rect = () => frames().get(id)?.rect ?? { x: 0, y: 0, width: 0, height: 0 }
461
588
  return (
462
- <Resizable
463
- orientation={branch().direction === 'row' ? 'horizontal' : 'vertical'}
464
- sizes={[branch().ratio, 1 - branch().ratio]}
465
- keyboardDelta={0.05}
466
- onSizesChange={(sizes) => {
467
- // Corvu also reports panel registration/unregistration while
468
- // mounting or disposing a controller. Those incomplete or
469
- // hidden-host sizes are not user resize intent.
470
- if (!root?.isConnected || root.getClientRects().length === 0) return
471
- if (
472
- sizes.length !== 2 ||
473
- sizes.some((size) => !Number.isFinite(size)) ||
474
- Math.abs(sizes[0]! + sizes[1]! - 1) > 0.000001
475
- )
476
- return
477
- if (sizes[0] !== undefined && Math.abs(sizes[0] - branch().ratio) > 0.000001)
478
- props.onResize(id, sizes[0])
479
- }}
480
- style={rectStyle(frames().get(id)?.rect ?? { x: 0, y: 0, width: 0, height: 0 })}
481
- class="pointer-events-none absolute flex min-h-0 min-w-0 data-[orientation=vertical]:flex-col"
589
+ <Show
590
+ when={typeof resizeController() === 'function'}
591
+ fallback={
592
+ <div
593
+ role="separator"
594
+ aria-label={
595
+ props.labelForSeparator?.(branch()) ??
596
+ (branch().direction === 'row' ? 'Resize pane columns' : 'Resize pane rows')
597
+ }
598
+ aria-orientation={branch().direction === 'row' ? 'vertical' : 'horizontal'}
599
+ aria-controls={listLeaves(branch())
600
+ .map((leaf) => domId(leaf.id))
601
+ .join(' ')}
602
+ aria-valuemin={10}
603
+ aria-valuemax={90}
604
+ aria-valuenow={Math.round(branch().ratio * 100)}
605
+ aria-disabled="true"
606
+ tabIndex={-1}
607
+ data-direction={branch().direction}
608
+ style={separatorPlaceholderStyle(branch(), rect())}
609
+ class="pointer-events-none absolute bg-border"
610
+ />
611
+ }
482
612
  >
483
- <Resizable.Panel
484
- minSize={MIN_SPLIT_RATIO}
485
- maxSize={MAX_SPLIT_RATIO}
486
- aria-hidden="true"
487
- />
488
- <Resizable.Handle
489
- aria-label={
490
- props.labelForSeparator?.(branch()) ??
491
- (branch().direction === 'row' ? 'Resize pane columns' : 'Resize pane rows')
492
- }
493
- aria-orientation={branch().direction === 'row' ? 'vertical' : 'horizontal'}
494
- aria-controls={listLeaves(branch())
495
- .map((leaf) => domId(leaf.id))
496
- .join(' ')}
497
- aria-valuemin={10}
498
- aria-valuemax={90}
499
- aria-valuenow={Math.round(branch().ratio * 100)}
500
- class="pointer-events-auto relative w-px shrink-0 bg-border after:absolute after:inset-y-0 after:-inset-x-1 after:w-3 focus-visible:bg-primary focus-visible:outline-none hover:bg-primary data-[orientation=vertical]:h-px data-[orientation=vertical]:w-full data-[orientation=vertical]:after:inset-x-0 data-[orientation=vertical]:after:-inset-y-1 data-[orientation=vertical]:after:h-3 data-[orientation=vertical]:after:w-full"
501
- />
502
- <Resizable.Panel
503
- minSize={MIN_SPLIT_RATIO}
504
- maxSize={MAX_SPLIT_RATIO}
505
- aria-hidden="true"
613
+ <Dynamic
614
+ component={resizeController() as ResizeController<L>}
615
+ branch={branch}
616
+ rect={() => rectStyle(rect())}
617
+ domId={domId}
618
+ root={() => root}
619
+ labelForSeparator={props.labelForSeparator}
620
+ onResize={props.onResize}
506
621
  />
507
- </Resizable>
622
+ </Show>
508
623
  )
509
624
  }}
510
625
  </For>
626
+ {resizeController() === 'error' ? (
627
+ <div class="absolute top-2 right-2 z-(--z-menu) flex max-w-sm flex-col items-end gap-2">
628
+ {alternateImportAttempts >= 2 ? (
629
+ <p
630
+ id={resizeReloadWarningId}
631
+ role="alert"
632
+ class="rounded-md border bg-background p-2 text-sm shadow-md"
633
+ >
634
+ Reloading may discard unsaved pane content.
635
+ </p>
636
+ ) : null}
637
+ <Button
638
+ variant="outline"
639
+ size="sm"
640
+ data-resize-retry
641
+ aria-describedby={alternateImportAttempts >= 2 ? resizeReloadWarningId : undefined}
642
+ onClick={(event) => {
643
+ if (alternateImportAttempts >= 2) {
644
+ window.location.reload()
645
+ return
646
+ }
647
+ const ownsFocus = document.activeElement === event.currentTarget
648
+ focusHandleAfterRetry = ownsFocus
649
+ focusRecoveryButtonAfterError = ownsFocus
650
+ focusRestoreEpoch = focusEpoch
651
+ retryWithAlternateImport = true
652
+ setResizeController(undefined)
653
+ }}
654
+ >
655
+ {alternateImportAttempts >= 2 ? 'Reload page to retry resizing' : 'Retry pane resizing'}
656
+ </Button>
657
+ </div>
658
+ ) : null}
511
659
  </div>
512
660
  )
513
661
  }
@@ -11,11 +11,14 @@ export {
11
11
  ThemeProvider,
12
12
  defaultThemeSelection,
13
13
  inlineScriptLiteral,
14
+ localStoragePersistence,
14
15
  resolvedDensity,
15
16
  themeScript,
17
+ useOptionalTheme,
16
18
  useTheme,
17
19
  type ThemeContextValue,
18
20
  type ThemeDensity,
21
+ type ThemePersistence,
19
22
  type ThemeProviderProps,
20
23
  type ThemeSelection,
21
24
  } from './theme-provider'
@@ -0,0 +1,43 @@
1
+ import { createContext, useContext, type Context } from 'solid-js'
2
+ import type { ThemeContextValue } from './theme-state'
3
+
4
+ /**
5
+ * Where the context object lives: on the global, under a registered symbol.
6
+ *
7
+ * A context is an object identity, and a bundle can legitimately load this module
8
+ * twice — a dev graph where a relative import and a package subpath are distinct
9
+ * module records, or two copies of the package at different versions. Each copy
10
+ * would then call `createContext` and own its own object; a provider from one and a
11
+ * `useOptionalTheme()` from the other would never meet, and the second would return
12
+ * `undefined` as though no provider were mounted. `Symbol.for` is shared across
13
+ * every copy in a realm, so they all find the one context the first created.
14
+ *
15
+ * Both copies must agree on its shape. The context value only ever grows, which is
16
+ * why a newer provider under an older reader is safe and the reverse is a missing
17
+ * member, not a mismatch.
18
+ */
19
+ const CONTEXT_KEY = Symbol.for('adea.ui.theme')
20
+
21
+ type ContextHost = { [CONTEXT_KEY]?: Context<ThemeContextValue | undefined> }
22
+
23
+ export function themeContext(): Context<ThemeContextValue | undefined> {
24
+ const host = globalThis as ContextHost
25
+ return (host[CONTEXT_KEY] ??= createContext<ThemeContextValue>())
26
+ }
27
+
28
+ export function useTheme(): ThemeContextValue {
29
+ const value = useContext(themeContext())
30
+ if (!value) throw new Error('useTheme must be called inside a <ThemeProvider>.')
31
+ return value
32
+ }
33
+
34
+ /**
35
+ * The theme context, or `undefined` outside a `ThemeProvider`.
36
+ *
37
+ * For a component that can run both ways — a shared control rendered in a harness,
38
+ * a story, or a host that has not mounted a provider — and would rather degrade
39
+ * than throw.
40
+ */
41
+ export function useOptionalTheme(): ThemeContextValue | undefined {
42
+ return useContext(themeContext())
43
+ }
@@ -0,0 +1,168 @@
1
+ import {
2
+ applyAppearanceFontSettings,
3
+ normalizeAppearanceEditorFontSettings,
4
+ } from '../../lib/appearance-font-settings'
5
+ import {
6
+ accentVariables,
7
+ themeCssVariables,
8
+ type ThemeAppearance,
9
+ type ThemeVariant,
10
+ } from '../../lib/themes'
11
+ import { resolvedDensity, type ThemeSelection } from './theme-state'
12
+
13
+ /**
14
+ * The document half of the theme provider: the single writer of `<html>`.
15
+ *
16
+ * Written against the few members of an element it uses, so the rules can be tested
17
+ * on a stand-in and the provider's effect stays a one-line call.
18
+ */
19
+ export type ThemeRoot = Pick<
20
+ HTMLElement,
21
+ 'classList' | 'dataset' | 'style' | 'removeAttribute' | 'setAttribute'
22
+ >
23
+
24
+ export type ThemeDocumentState = {
25
+ /** What the document shows: the committed selection, or a preview laid over it. */
26
+ selection: ThemeSelection
27
+ appearance: ThemeAppearance
28
+ variant: ThemeVariant
29
+ }
30
+
31
+ export type ThemeDocumentOptions = {
32
+ /**
33
+ * Rewrites the custom properties just before they are written: the variant's
34
+ * roles with the accent laid over them. It sees every property of the pass and
35
+ * must return the full map; a name it drops is not written.
36
+ */
37
+ transformTokens?: (tokens: Record<string, string>) => Record<string, string>
38
+ }
39
+
40
+ /**
41
+ * The custom properties a state paints: the variant's roles, with the accent
42
+ * selection laid over them, then `transformTokens`.
43
+ *
44
+ * The accent is applied *here* rather than left to the `[data-accent]` blocks in
45
+ * `theme.css`, because the properties are written inline and an inline declaration
46
+ * outranks every stylesheet selector however specific it is. The blocks still exist
47
+ * and are still correct — they are what styles the window before this runs — but
48
+ * they cannot win from here, so leaving the accent to them meant the attribute was
49
+ * set, the rules were present, and nothing changed on screen.
50
+ */
51
+ export function themeDocumentTokens(
52
+ state: ThemeDocumentState,
53
+ options: ThemeDocumentOptions = {}
54
+ ): { tokens: Record<string, string>; accented: boolean } {
55
+ const properties = { ...themeCssVariables(state.variant) }
56
+ const accent = accentVariables(state.selection.accent, state.appearance, state.selection)
57
+ if (accent) Object.assign(properties, accent)
58
+ return {
59
+ tokens: options.transformTokens ? options.transformTokens(properties) : properties,
60
+ accented: accent !== undefined,
61
+ }
62
+ }
63
+
64
+ /**
65
+ * Write a state onto a root and report which custom properties it owns.
66
+ *
67
+ * Pass the previous call's return as `previous` and any palette property that pass
68
+ * wrote and this one does not is removed. For the catalogue the two sets are the
69
+ * same, so nothing is ever removed; it matters for a host variant that carries
70
+ * `tokens` the catalogue does not, which would otherwise stay inline, beating the
71
+ * stylesheet, after the user switched away from it.
72
+ */
73
+ export function applyThemeToRoot(
74
+ root: ThemeRoot,
75
+ state: ThemeDocumentState,
76
+ options: ThemeDocumentOptions = {},
77
+ previous?: ReadonlySet<string>
78
+ ): ReadonlySet<string> {
79
+ const { tokens, accented } = themeDocumentTokens(state, options)
80
+
81
+ for (const [name, value] of Object.entries(tokens)) {
82
+ root.style.setProperty(name, value)
83
+ }
84
+ if (previous) {
85
+ for (const name of previous) {
86
+ if (!(name in tokens)) root.style.removeProperty(name)
87
+ }
88
+ }
89
+
90
+ root.classList.toggle('dark', state.appearance === 'dark')
91
+ root.dataset['theme'] = state.variant.id
92
+ root.dataset['appearance'] = state.appearance
93
+ root.style.colorScheme = state.appearance
94
+
95
+ if (!accented) root.removeAttribute('data-accent')
96
+ else root.dataset['accent'] = state.selection.accent
97
+
98
+ applyAppearanceFontSettings(
99
+ root,
100
+ normalizeAppearanceEditorFontSettings(state.selection.fonts, state.selection.font).settings
101
+ )
102
+
103
+ // Density is a pure token shift, so unlike the colour axes there is nothing to
104
+ // compute: the attribute selects a block in `theme.css`. It is applied here for
105
+ // the same reason as the others — this is the one place that knows what the
106
+ // current selection is, and it is the only writer of these attributes.
107
+ if (resolvedDensity(state.selection) === 'comfortable') root.removeAttribute('data-density')
108
+ else root.dataset['density'] = resolvedDensity(state.selection)
109
+
110
+ return new Set(Object.keys(tokens))
111
+ }
112
+
113
+ /** What {@link applyWithoutTransitions} needs of a document. */
114
+ export type TransitionDocument = {
115
+ head: { appendChild(node: unknown): unknown }
116
+ createElement(tag: 'style'): {
117
+ textContent: string | null
118
+ offsetHeight?: number
119
+ remove(): void
120
+ }
121
+ }
122
+
123
+ /**
124
+ * Run `apply` with every CSS transition switched off for the frame that carries
125
+ * it, so a theme switch cannot animate page-wide.
126
+ *
127
+ * Without this, flipping the custom properties on `<html>` starts a transition on
128
+ * every element that declares one, and each fades from the old palette to the new
129
+ * over its own duration: a theme change that looks like a rendering fault. The
130
+ * rule is removed two frames later, after the browser has committed the new styles
131
+ * with transitions off, and `restore` runs it early (the provider calls it when it
132
+ * is disposed mid-switch).
133
+ *
134
+ * `schedule` is the frame source; it defaults to `requestAnimationFrame` and falls
135
+ * back to running at once where there is none, which leaves transitions on rather
136
+ * than off.
137
+ */
138
+ export function applyWithoutTransitions(
139
+ doc: TransitionDocument,
140
+ apply: () => void,
141
+ schedule: (callback: () => void) => void = defaultSchedule
142
+ ): () => void {
143
+ const style = doc.createElement('style')
144
+ style.textContent = '*{transition:none !important}'
145
+ doc.head.appendChild(style)
146
+ // Reading a layout property forces the browser to commit the rule before the
147
+ // properties change; without it the two can be batched into one style recalc.
148
+ void style.offsetHeight
149
+ let removed = false
150
+ const restore = () => {
151
+ if (removed) return
152
+ removed = true
153
+ style.remove()
154
+ }
155
+ try {
156
+ apply()
157
+ } catch (error) {
158
+ restore()
159
+ throw error
160
+ }
161
+ schedule(() => schedule(restore))
162
+ return restore
163
+ }
164
+
165
+ function defaultSchedule(callback: () => void): void {
166
+ if (typeof requestAnimationFrame === 'function') requestAnimationFrame(callback)
167
+ else callback()
168
+ }