@exegia/corpora-ui 0.20.0 → 0.21.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 (123) hide show
  1. package/dist-lib/components/blocks/auth/__tests__/auth-state-atom.test.d.ts +1 -0
  2. package/dist-lib/components/blocks/auth/auth-flow-atom.d.ts +77 -0
  3. package/dist-lib/components/blocks/auth/auth-session-atom.d.ts +28 -0
  4. package/dist-lib/components/blocks/auth/auth-state-type.d.ts +71 -0
  5. package/dist-lib/components/blocks/auth/auth-state.d.ts +9 -0
  6. package/dist-lib/components/blocks/auth/use-auth-state.d.ts +52 -0
  7. package/dist-lib/components/blocks/nav/sidebar/__tests__/ai-sidebar-atom.test.d.ts +1 -0
  8. package/dist-lib/components/blocks/nav/sidebar/ai-sidebar-atom.d.ts +217 -0
  9. package/dist-lib/components/blocks/nav/sidebar/index.d.ts +3 -1
  10. package/dist-lib/components/blocks/nav/sidebar/sidebar-context.d.ts +3 -0
  11. package/dist-lib/components/blocks/nav/sidebar/sidebar-row.d.ts +3 -1
  12. package/dist-lib/components/blocks/nav/sidebar/type.d.ts +109 -29
  13. package/dist-lib/components/blocks/nav/sidebar/use-ai-sidebar-state.d.ts +29 -0
  14. package/dist-lib/components/blocks/nav/sidebar/use-ai-sidebar.d.ts +6 -1
  15. package/dist-lib/components/blocks/nav/sidebar/utils.d.ts +1 -1
  16. package/dist-lib/components/blocks/profile/index.d.ts +7 -0
  17. package/dist-lib/components/blocks/profile/profile-card-atom.d.ts +68 -0
  18. package/dist-lib/components/blocks/profile/profile-card-block.d.ts +28 -39
  19. package/dist-lib/components/blocks/profile/type.d.ts +80 -0
  20. package/dist-lib/components/blocks/profile/use-profile-card-state.d.ts +24 -0
  21. package/dist-lib/components/blocks/profile/use-profile-card.d.ts +34 -0
  22. package/dist-lib/components/blocks/shell/__tests__/shell-fit-atom.test.d.ts +1 -0
  23. package/dist-lib/components/blocks/shell/__tests__/shell-metrics.test.d.ts +1 -0
  24. package/dist-lib/components/blocks/shell/animated-panel-provider.d.ts +1 -1
  25. package/dist-lib/components/blocks/shell/animated-panel.d.ts +1 -1
  26. package/dist-lib/components/blocks/shell/index.d.ts +4 -0
  27. package/dist-lib/components/blocks/shell/shell-fit-atom.d.ts +68 -0
  28. package/dist-lib/components/blocks/shell/shell-metrics.d.ts +54 -0
  29. package/dist-lib/components/blocks/shell/type.d.ts +115 -3
  30. package/dist-lib/components/blocks/shell/use-shell-fit-state.d.ts +25 -0
  31. package/dist-lib/components/blocks/shell/use-shell-fit.d.ts +14 -0
  32. package/dist-lib/components/blocks/shell/use-shell-panels.d.ts +12 -1
  33. package/dist-lib/components/blocks/shell/utils.d.ts +32 -0
  34. package/dist-lib/components/composed/tree/__tests__/tree-atom.test.d.ts +1 -0
  35. package/dist-lib/components/composed/tree/constants.d.ts +4 -4
  36. package/dist-lib/components/composed/tree/index.d.ts +3 -1
  37. package/dist-lib/components/composed/tree/tree-atom.d.ts +160 -0
  38. package/dist-lib/components/composed/tree/tree-node.d.ts +6 -1
  39. package/dist-lib/components/composed/tree/type.d.ts +116 -18
  40. package/dist-lib/components/composed/tree/use-tree-dnd.d.ts +18 -7
  41. package/dist-lib/components/composed/tree/use-tree-state.d.ts +25 -0
  42. package/dist-lib/components/composed/tree/use-tree.d.ts +4 -0
  43. package/dist-lib/components/composed/user-avatar.d.ts +5 -28
  44. package/dist-lib/components/user-avatar/__tests__/user-avatar.test.d.ts +1 -0
  45. package/dist-lib/components/user-avatar/component.d.ts +1 -8
  46. package/dist-lib/components/user-avatar/index.d.ts +5 -0
  47. package/dist-lib/components/user-avatar/presence-badge.d.ts +16 -0
  48. package/dist-lib/components/user-avatar/type.d.ts +45 -0
  49. package/dist-lib/components/user-avatar/use-user-avatar-state.d.ts +26 -0
  50. package/dist-lib/components/user-avatar/use-user-avatar.d.ts +32 -0
  51. package/dist-lib/components/user-avatar/user-avatar-atom.d.ts +55 -0
  52. package/dist-lib/index.d.ts +5 -2
  53. package/dist-lib/index.js +3004 -1796
  54. package/dist-lib/index.js.map +1 -1
  55. package/dist-lib/state/exegia-provider.d.ts +51 -0
  56. package/dist-lib/state/index.d.ts +4 -0
  57. package/dist-lib/state/store.d.ts +17 -0
  58. package/package.json +15 -12
  59. package/src/components/blocks/auth/__tests__/auth-state-atom.test.tsx +247 -0
  60. package/src/components/blocks/auth/auth-flow-atom.ts +238 -0
  61. package/src/components/blocks/auth/auth-session-atom.ts +82 -0
  62. package/src/components/blocks/auth/auth-state-type.ts +97 -0
  63. package/src/components/blocks/auth/auth-state.ts +52 -0
  64. package/src/components/blocks/auth/use-auth-state.ts +127 -0
  65. package/src/components/blocks/nav/sidebar/__tests__/ai-sidebar-atom.test.tsx +325 -0
  66. package/src/components/blocks/nav/sidebar/ai-sidebar-atom.ts +698 -0
  67. package/src/components/blocks/nav/sidebar/ai-sidebar.tsx +48 -42
  68. package/src/components/blocks/nav/sidebar/index.ts +53 -1
  69. package/src/components/blocks/nav/sidebar/sidebar-context.ts +15 -0
  70. package/src/components/blocks/nav/sidebar/sidebar-row.tsx +119 -50
  71. package/src/components/blocks/nav/sidebar/type.ts +125 -30
  72. package/src/components/blocks/nav/sidebar/use-ai-sidebar-state.ts +123 -0
  73. package/src/components/blocks/nav/sidebar/use-ai-sidebar.ts +301 -248
  74. package/src/components/blocks/nav/sidebar/utils.ts +1 -1
  75. package/src/components/blocks/profile/__tests__/profile-card-block.test.tsx +194 -0
  76. package/src/components/blocks/profile/index.ts +28 -0
  77. package/src/components/blocks/profile/profile-card-atom.ts +247 -0
  78. package/src/components/blocks/profile/profile-card-block.tsx +153 -74
  79. package/src/components/blocks/profile/type.ts +95 -0
  80. package/src/components/blocks/profile/use-profile-card-state.ts +67 -0
  81. package/src/components/blocks/profile/use-profile-card.ts +126 -0
  82. package/src/components/blocks/shell/__tests__/shell-fit-atom.test.tsx +360 -0
  83. package/src/components/blocks/shell/__tests__/shell-layout.test.tsx +192 -3
  84. package/src/components/blocks/shell/__tests__/shell-metrics.test.ts +108 -0
  85. package/src/components/blocks/shell/animated-panel-inset.tsx +5 -1
  86. package/src/components/blocks/shell/animated-panel-provider.tsx +77 -11
  87. package/src/components/blocks/shell/animated-panel-trigger.tsx +4 -0
  88. package/src/components/blocks/shell/animated-panel.tsx +126 -29
  89. package/src/components/blocks/shell/index.ts +18 -0
  90. package/src/components/blocks/shell/shell-fit-atom.ts +243 -0
  91. package/src/components/blocks/shell/shell-layout.tsx +55 -53
  92. package/src/components/blocks/shell/shell-metrics.ts +79 -0
  93. package/src/components/blocks/shell/type.ts +130 -3
  94. package/src/components/blocks/shell/use-shell-fit-state.ts +49 -0
  95. package/src/components/blocks/shell/use-shell-fit.ts +135 -0
  96. package/src/components/blocks/shell/use-shell-panels.ts +57 -4
  97. package/src/components/blocks/shell/utils.ts +44 -4
  98. package/src/components/composed/tree/CLAUDE.md +132 -0
  99. package/src/components/composed/tree/__tests__/tree-atom.test.tsx +217 -0
  100. package/src/components/composed/tree/__tests__/tree.test.tsx +162 -37
  101. package/src/components/composed/tree/__tests__/use-tree.test.tsx +2 -1
  102. package/src/components/composed/tree/constants.ts +4 -4
  103. package/src/components/composed/tree/index.ts +38 -0
  104. package/src/components/composed/tree/tree-atom.ts +590 -0
  105. package/src/components/composed/tree/tree-node.tsx +117 -78
  106. package/src/components/composed/tree/tree.tsx +24 -16
  107. package/src/components/composed/tree/type.ts +125 -15
  108. package/src/components/composed/tree/use-tree-dnd.ts +82 -51
  109. package/src/components/composed/tree/use-tree-state.ts +105 -0
  110. package/src/components/composed/tree/use-tree.ts +185 -184
  111. package/src/components/composed/user-avatar.tsx +11 -99
  112. package/src/components/user-avatar/__tests__/user-avatar.test.tsx +245 -0
  113. package/src/components/user-avatar/component.tsx +121 -22
  114. package/src/components/user-avatar/index.ts +19 -0
  115. package/src/components/user-avatar/presence-badge.tsx +56 -0
  116. package/src/components/user-avatar/type.ts +50 -0
  117. package/src/components/user-avatar/use-user-avatar-state.ts +60 -0
  118. package/src/components/user-avatar/use-user-avatar.ts +144 -0
  119. package/src/components/user-avatar/user-avatar-atom.ts +198 -0
  120. package/src/index.ts +34 -2
  121. package/src/state/exegia-provider.tsx +79 -0
  122. package/src/state/index.ts +4 -0
  123. package/src/state/store.ts +19 -0
@@ -0,0 +1,49 @@
1
+ "use client"
2
+
3
+ import { useMemo } from "react"
4
+ import { useAtomValue, useSetAtom } from "jotai"
5
+
6
+ import {
7
+ resetShellPanelWidthAtom,
8
+ resizeShellPanelAtom,
9
+ shellFitStateAtom,
10
+ } from "./shell-fit-atom"
11
+ import type { ShellFitActions, ShellFitInstanceId, ShellFitState } from "./type"
12
+
13
+ /**
14
+ * Read the fit of the shell registered under `shellId` from anywhere below
15
+ * `ExegiaProvider` — no controller, no props, no provider of its own.
16
+ *
17
+ * ```tsx
18
+ * const { fits, panelWidth } = useShellFitState("app-shell")
19
+ * ```
20
+ *
21
+ * This returns the whole state object, so the caller re-renders on every
22
+ * measurement. A component that reads one field should subscribe to that
23
+ * field's atom instead: `useAtomValue(shellFitFitsAtom("app-shell"))`.
24
+ */
25
+ export function useShellFitState(shellId: ShellFitInstanceId): ShellFitState {
26
+ return useAtomValue(shellFitStateAtom(shellId))
27
+ }
28
+
29
+ /**
30
+ * Drive the secondary panel of the shell registered under `shellId` from
31
+ * anywhere. Writes only — the caller never re-renders when the shell moves,
32
+ * so this is what a command palette or a keyboard shortcut should reach for.
33
+ *
34
+ * ```tsx
35
+ * const shell = useShellFitActions("app-shell")
36
+ * <Button onClick={() => shell.resizePanel(480)}>Wide inspector</Button>
37
+ * ```
38
+ */
39
+ export function useShellFitActions(
40
+ shellId: ShellFitInstanceId
41
+ ): ShellFitActions {
42
+ const resizePanel = useSetAtom(resizeShellPanelAtom(shellId))
43
+ const resetPanelWidth = useSetAtom(resetShellPanelWidthAtom(shellId))
44
+
45
+ return useMemo(
46
+ () => ({ resizePanel, resetPanelWidth }),
47
+ [resizePanel, resetPanelWidth]
48
+ )
49
+ }
@@ -0,0 +1,135 @@
1
+ "use client"
2
+
3
+ import { useEffect, useId, useMemo, useRef, useState } from "react"
4
+ import { useAtomValue, useSetAtom } from "jotai"
5
+ import { useIsomorphicLayoutEffect } from "motion/react"
6
+
7
+ import {
8
+ measureShellFitAtom,
9
+ mountShellFitAtom,
10
+ removeShellFitInstance,
11
+ resetShellPanelWidthAtom,
12
+ resizeShellPanelAtom,
13
+ shellFitStateAtom,
14
+ } from "./shell-fit-atom"
15
+ import { fitsPanel, type ShellMetrics } from "./shell-metrics"
16
+ import type {
17
+ ShellFitController,
18
+ ShellFitSeed,
19
+ UseShellFitOptions,
20
+ } from "./type"
21
+ import { resolveLength } from "./utils"
22
+
23
+ /** Read the shell's columns out of the DOM. The widths come from CSS
24
+ * variables; the rail's share comes from its own state, not its box, so a
25
+ * fold counts the moment React commits it rather than a frame later when the
26
+ * width animation has moved. */
27
+ function readMetrics(host: HTMLElement, railOpen: boolean): ShellMetrics {
28
+ // Whether a shell has a rail at all is the caller's composition, so it is
29
+ // read off the DOM rather than tracked in state.
30
+ const rail = host.querySelector<HTMLElement>(
31
+ '[data-slot="sidebar"][data-side="left"]'
32
+ )
33
+ const collapsible = rail?.dataset.collapsible
34
+
35
+ const frame = getComputedStyle(host)
36
+ const gap = Number.parseFloat(frame.columnGap) || 0
37
+
38
+ return {
39
+ rail: !rail
40
+ ? 0
41
+ : railOpen || collapsible === "none"
42
+ ? resolveLength(host, "var(--sidebar-width)")
43
+ : collapsible === "offcanvas"
44
+ ? 0
45
+ : resolveLength(host, "var(--sidebar-width-icon)"),
46
+ insetMin: resolveLength(host, "var(--inset-min-width)"),
47
+ panelMin: resolveLength(host, "var(--panel-width)"),
48
+ viewport: window.innerWidth,
49
+ chrome:
50
+ (Number.parseFloat(frame.paddingLeft) || 0) +
51
+ (Number.parseFloat(frame.paddingRight) || 0) +
52
+ gap * Math.max(0, host.children.length - 1),
53
+ }
54
+ }
55
+
56
+ /**
57
+ * Measures the shell and decides what the secondary panel may do: whether it
58
+ * exists at all, and how wide it may be dragged.
59
+ *
60
+ * The three things that move the answer are all observed here — the viewport
61
+ * (`resize`), the rail's fold (`railOpen`, a render input) and the user's own
62
+ * drag (`resizePanel`) — so no caller has to re-derive it. The measurement is
63
+ * the only thing this hook keeps to itself: the numbers land in the shell-fit
64
+ * atoms keyed by `shellId`, so anything under `ExegiaProvider` can read the
65
+ * verdict (`useShellFitState`) or move the panel (`useShellFitActions`)
66
+ * without holding this controller.
67
+ */
68
+ export function useShellFit({
69
+ shellId: explicitId,
70
+ hostRef,
71
+ railOpen,
72
+ defaultPanelWidth,
73
+ onUnfit,
74
+ }: UseShellFitOptions): ShellFitController {
75
+ // A generated key isolates unnamed shells from each other; an explicit
76
+ // `shellId` is the app's handle on this one.
77
+ const generatedId = useId()
78
+ const shellId = explicitId ?? generatedId
79
+
80
+ // Read once: `defaultPanelWidth` describes the mount, not every render.
81
+ const [seed] = useState<ShellFitSeed>(() => ({
82
+ panelWidth: defaultPanelWidth ?? null,
83
+ }))
84
+
85
+ const mount = useSetAtom(mountShellFitAtom(shellId))
86
+ const measure = useSetAtom(measureShellFitAtom(shellId))
87
+ const resizePanel = useSetAtom(resizeShellPanelAtom(shellId))
88
+ const resetPanelWidth = useSetAtom(resetShellPanelWidthAtom(shellId))
89
+
90
+ // Before paint, and before the first measurement below, so a seeded width
91
+ // is in the store by the time the panel first reads its width.
92
+ useIsomorphicLayoutEffect(() => {
93
+ mount(seed)
94
+ }, [mount, seed])
95
+
96
+ // The listener below is bound once per fold, so the callback rides a ref
97
+ // rather than rebinding it whenever a consumer passes a fresh arrow.
98
+ const onUnfitRef = useRef(onUnfit)
99
+ useIsomorphicLayoutEffect(() => {
100
+ onUnfitRef.current = onUnfit
101
+ })
102
+
103
+ useIsomorphicLayoutEffect(() => {
104
+ const host = hostRef.current
105
+ if (!host) return
106
+
107
+ const takeMeasurement = () => {
108
+ const next = readMetrics(host, railOpen)
109
+ measure(next)
110
+ if (!fitsPanel(next)) onUnfitRef.current?.()
111
+ }
112
+
113
+ // Measured before paint so a shell that cannot hold the panel never
114
+ // flashes one, and re-measured on every fold because the rail's column is
115
+ // part of the room the panel needs.
116
+ takeMeasurement()
117
+ window.addEventListener("resize", takeMeasurement)
118
+ return () => window.removeEventListener("resize", takeMeasurement)
119
+ }, [hostRef, railOpen, measure])
120
+
121
+ // A shell the hook keyed is scrap once its component goes. An explicit
122
+ // `shellId` is the app's key and outlives the mount — that is what lets a
123
+ // dragged width survive a route change.
124
+ useEffect(() => {
125
+ if (explicitId !== undefined) return
126
+ return () => removeShellFitInstance(shellId)
127
+ }, [shellId, explicitId])
128
+
129
+ const state = useAtomValue(shellFitStateAtom(shellId))
130
+
131
+ return useMemo<ShellFitController>(
132
+ () => ({ shellId, ...state, resizePanel, resetPanelWidth }),
133
+ [shellId, state, resizePanel, resetPanelWidth]
134
+ )
135
+ }
@@ -1,7 +1,14 @@
1
1
  "use client"
2
2
 
3
- import { useCallback, useMemo, useState } from "react"
3
+ import { useCallback, useEffect, useId, useMemo, useState } from "react"
4
+ import { useAtomValue, useSetAtom } from "jotai"
4
5
 
6
+ import {
7
+ removeShellFitInstance,
8
+ resizeShellPanelAtom,
9
+ shellFitFitsAtom,
10
+ shellFitPanelWidthAtom,
11
+ } from "./shell-fit-atom"
5
12
  import type {
6
13
  ShellPanelControlProps,
7
14
  ShellPanelControls,
@@ -15,12 +22,36 @@ import type {
15
22
  * callback. Spread the returned `providerProps` onto ShellLayout; the
16
23
  * setters and `toggle` are for UI that lives outside the shell (title-bar
17
24
  * buttons, command palette, shortcuts).
25
+ *
26
+ * The way back down is the store: `providerProps` carries a `shellId`, the
27
+ * shell files its measurement under it, and this hook reads `isNarrow` and
28
+ * `panelWidth` straight out of those atoms — so outside UI stands down with
29
+ * the panel instead of measuring `--sidebar-width` a second time. Name the
30
+ * `shellId` and any component below `ExegiaProvider` can do the same with
31
+ * `useShellFitState(shellId)`.
32
+ *
33
+ * Call it under the same `ExegiaProvider` as the shell it drives (or under
34
+ * none at all, on both sides): mounted above the provider it would read
35
+ * Jotai's default store while the shell writes to the provider's.
18
36
  */
19
37
  export function useShellPanels({
38
+ shellId: explicitId,
20
39
  defaultOpen,
21
40
  defaultOpenMobile,
41
+ defaultPanelWidth,
22
42
  onPanelChange,
23
43
  }: UseShellPanelsOptions = {}): ShellPanelControls {
44
+ // The hook, not the provider, keys the shell: it renders above the provider
45
+ // and has to read the same atoms the provider writes. Whoever generates the
46
+ // key drops it — the provider treats a passed-in id as the app's and leaves
47
+ // it alone on unmount.
48
+ const generatedId = useId()
49
+ const shellId = explicitId ?? generatedId
50
+ useEffect(() => {
51
+ if (explicitId !== undefined) return
52
+ return () => removeShellFitInstance(shellId)
53
+ }, [shellId, explicitId])
54
+
24
55
  const [open, setOpenState] = useState<Record<SidebarSide, boolean>>(() => ({
25
56
  left: defaultOpen?.left ?? true,
26
57
  right: defaultOpen?.right ?? false,
@@ -32,24 +63,40 @@ export function useShellPanels({
32
63
  right: defaultOpenMobile?.right ?? false,
33
64
  }))
34
65
 
66
+ // Reads false until the shell has measured itself — an unmeasured shell
67
+ // fails open, so nothing out here stands down over a reading that has not
68
+ // happened yet.
69
+ const fits = useAtomValue(shellFitFitsAtom(shellId))
70
+ const isNarrow = !fits
71
+ const panelWidth = useAtomValue(shellFitPanelWidthAtom(shellId))
72
+ const resizePanel = useSetAtom(resizeShellPanelAtom(shellId))
73
+
74
+ // The shell drops the right panel when the viewport cannot hold it, so
75
+ // opening it from out here would only surface later as a panel nobody
76
+ // asked for. Closing always goes through — that is how state left over
77
+ // from a wider viewport clears.
35
78
  const setOpen = useCallback(
36
79
  (nextOpen: boolean, side: SidebarSide) => {
80
+ if (nextOpen && side === "right" && isNarrow) return
81
+
37
82
  setOpenState((prev) =>
38
83
  prev[side] === nextOpen ? prev : { ...prev, [side]: nextOpen }
39
84
  )
40
85
  onPanelChange?.(nextOpen, side)
41
86
  },
42
- [onPanelChange]
87
+ [isNarrow, onPanelChange]
43
88
  )
44
89
 
45
90
  const setOpenMobile = useCallback(
46
91
  (nextOpen: boolean, side: SidebarSide) => {
92
+ if (nextOpen && side === "right" && isNarrow) return
93
+
47
94
  setOpenMobileState((prev) =>
48
95
  prev[side] === nextOpen ? prev : { ...prev, [side]: nextOpen }
49
96
  )
50
97
  onPanelChange?.(nextOpen, side)
51
98
  },
52
- [onPanelChange]
99
+ [isNarrow, onPanelChange]
53
100
  )
54
101
 
55
102
  const toggle = useCallback(
@@ -59,15 +106,21 @@ export function useShellPanels({
59
106
 
60
107
  const providerProps = useMemo<ShellPanelControlProps>(
61
108
  () => ({
109
+ shellId,
110
+ defaultPanelWidth,
62
111
  open,
63
112
  onOpenChange: setOpen,
64
113
  openMobile,
65
114
  onOpenMobileChange: setOpenMobile,
66
115
  }),
67
- [open, openMobile, setOpen, setOpenMobile]
116
+ [shellId, defaultPanelWidth, open, openMobile, setOpen, setOpenMobile]
68
117
  )
69
118
 
70
119
  return {
120
+ shellId,
121
+ isNarrow,
122
+ panelWidth,
123
+ resizePanel,
71
124
  open,
72
125
  openMobile,
73
126
  setOpen,
@@ -92,6 +92,49 @@ export const FOCUSABLE_SELECTOR = [
92
92
  export const MOBILE_QUERY = "(max-width: 767px)"
93
93
  export const SIDEBAR_KEYBOARD_SHORTCUT = "b"
94
94
 
95
+ /**
96
+ * The shell's layout contract, in px. Every entry lands on the provider's
97
+ * wrapper as a CSS variable, so a consumer overrides a column by restyling it
98
+ * rather than by passing a prop:
99
+ *
100
+ * | variable | column |
101
+ * | ---------------------- | ----------------------------------------- |
102
+ * | `--sidebar-width` | left rail, expanded |
103
+ * | `--sidebar-width-icon` | left rail, folded to icons |
104
+ * | `--panel-width` | secondary panel: its floor AND its default |
105
+ * | `--inset-min-width` | the body's floor |
106
+ *
107
+ * These are only the defaults. Every measurement resolves the live variable,
108
+ * so an override wins over the value written here.
109
+ */
110
+ export const SHELL_WIDTHS = {
111
+ "--sidebar-width": "256px",
112
+ "--sidebar-width-icon": "56px",
113
+ "--sidebar-width-mobile": "18rem",
114
+ "--panel-width": "320px",
115
+ "--inset-min-width": "360px",
116
+ } as const
117
+
118
+ /** The expanded width of a panel docked to `side`: the secondary panel opens
119
+ * at `--panel-width`, the primary rail at `--sidebar-width`. */
120
+ export function expandedWidthVar(side: "left" | "right") {
121
+ return side === "right" ? "var(--panel-width)" : "var(--sidebar-width)"
122
+ }
123
+
124
+ /** Resolve a CSS length — `var()` included — to px inside `host`'s cascade.
125
+ * Custom properties inherit, so a throwaway probe mounted in `host` reads the
126
+ * very `--sidebar-width` the shell lays out with, including a value a
127
+ * consumer overrode on the provider. Returns 0 when `host` has no layout
128
+ * (server render, `display: none`), so callers must fail open on 0 rather
129
+ * than treat it as a real measurement. */
130
+ export function resolveLength(host: HTMLElement, value: string) {
131
+ const probe = document.createElement("div")
132
+ probe.style.cssText = `position:absolute;visibility:hidden;pointer-events:none;width:${value}`
133
+ host.appendChild(probe)
134
+ const px = probe.getBoundingClientRect().width
135
+ probe.remove()
136
+ return px
137
+ }
95
138
 
96
139
  export const AnimatedSidebarContext =
97
140
  createContext<AnimatedSidebarContextValue | null>(null)
@@ -131,13 +174,10 @@ export function useIsMobile() {
131
174
  )
132
175
  }
133
176
 
134
-
135
177
  export function useAnimatedSidebarPanel() {
136
178
  const context = useContext(AnimatedSidebarPanelContext)
137
179
  if (!context) {
138
- throw new Error(
139
- "Animated Sidebar parts must be used inside AnimatedPanel."
140
- )
180
+ throw new Error("Animated Sidebar parts must be used inside AnimatedPanel.")
141
181
  }
142
182
  return context
143
183
  }
@@ -0,0 +1,132 @@
1
+ # composed/tree
2
+
3
+ Nested item tree in four shapes — `navigation`, `toc`, `sidebar` (icon rail),
4
+ `files` (explorer). It is the **reference implementation** of the Jotai
5
+ state pattern described in `react/CLAUDE.md` ("State" section) — read that
6
+ first; this file only holds what is specific to the tree.
7
+
8
+ ## File map
9
+
10
+ | File | Owns |
11
+ | ------------------- | ------------------------------------------------------------------------------------------------------- |
12
+ | `type.ts` | `TreeNode`, `TreeProps` (data form × controller form), `TreeState`/`TreeActions`, `@internal` config/handlers/seed. No imports besides React types. |
13
+ | `tree-atom.ts` | Every atom, keyed by `treeId` (`keyed()` families) and per node (`nodeFamily`). All mutations are write-only action atoms. `debugLabel` = `tree/<id>/<name>`. |
14
+ | `use-tree.ts` | `useTree(options)` — mounts an instance, projects controlled props into the store, returns the `TreeController`. |
15
+ | `use-tree-state.ts` | `useTreeState(id)` (reads, re-renders on any change) and `useTreeActions(id)` (writes only, never re-renders). |
16
+ | `use-tree-dnd.ts` | Stable drag handlers for `files`; they read `draggedId`/`dropTarget` out of the store at call time. |
17
+ | `tree-context.ts` | Context value = `{ treeId, renderTrailing, dnd }` only. Never the controller. |
18
+ | `tree.tsx` | `Tree` (props form → `UncontrolledTree`, controller form → `TreeView`), rail width measurement, roving keyboard nav. |
19
+ | `tree-node.tsx` | `TreeRow` (memoized). Per-node atom subscriptions, row kind resolution, rename input, tooltip, toc overlay toggle, trailing slot, branch animation. |
20
+ | `constants.ts` | Durations, easings, `RAIL_COLLAPSED_WIDTH`, motion variants. |
21
+ | `utils.ts` | Pure tree helpers (`findNode`, `moveNode`, `renameNode`, ancestor walks). |
22
+ | `index.ts` | Barrel. Public atoms are listed explicitly — never `export *`. |
23
+ | `__tests__/` | `tree.test.tsx` (rendering per variant), `use-tree.test.tsx` (controller/hook), `tree-atom.test.tsx` (store + memo guard). |
24
+
25
+ ## Row anatomy (as of 2026-08-18)
26
+
27
+ - **Every non-section row is the shared `ui/button` `Button`** (`variant="ghost"`,
28
+ or `"secondary"` for the active sidebar row). Section headings in a 3-level
29
+ `navigation` tree stay a plain `<button>`. **Nothing renders an `<a>` any
30
+ more.**
31
+ - `TreeNode.href` / `target` are **metadata** — they ride along on the node
32
+ handed to `onNavigate`. Routing has exactly one path:
33
+ `selectTreeNodeAtom` → `node.onSelect?.()` → `handlers.onNavigate?.(node)`.
34
+ One native behaviour survives the anchor removal: `toc` rows below the
35
+ route level (depth > 0, or any toc row with a `#hash` href) set
36
+ `window.location.hash` **after** `select` in `handlePress` — same order the
37
+ old `<a>` default gave, same `:target`/history semantics. A non-hash href
38
+ on a toc row disables the jump (it is a route). Controller `select()` from
39
+ outside does NOT jump — the hash write lives in the row, not the atom,
40
+ because it is a DOM concern.
41
+ - Row-level `data-*` (`data-slot="tree-row"`, `data-id`, `data-branch`,
42
+ `data-expanded`, `data-active`, `data-cuelume-press`) come from
43
+ `rowInteractionProps`, spread onto the Button; Base UI's `mergeProps` lets
44
+ them override Button's own `data-slot="button"`. `sound` is forwarded to the
45
+ Button so its press/release cues follow the tree's `sound` prop.
46
+ - Collapsed sidebar rows are `h-10! rounded-xl! justify-center px-0` tiles;
47
+ the rail is `w-10` (40px). `RAIL_COLLAPSED_WIDTH` **must equal** the class
48
+ (a test pins it to 40) — the motion target and the resting class must agree
49
+ or the rail jumps on its first fold.
50
+ - Row kinds (`rowKindOf`): `link` (selectable, `aria-expanded` absent),
51
+ `toggle` (parent in navigation/files, `aria-expanded` on the row),
52
+ `section` (top level of a 3-level navigation tree). `toc` parents are
53
+ `link` rows with a separate overlay `<button aria-label="Expand X">`.
54
+ - The trailing slot (`renderTrailing`, files only) is a **sibling** of the row
55
+ inside `.group/row-actions`, never a child — a button inside a button is
56
+ invalid HTML and React warns.
57
+
58
+ ## Lessons learned (chronological)
59
+
60
+ 1. **Rows read per-node atoms, not the controller.** Reading the whole
61
+ controller off context re-rendered every row on every toggle.
62
+ `tree-atom.test.tsx` counts `renderTrailing` calls per node to guard the
63
+ memo + per-node subscriptions. (0021dee)
64
+ 2. **Controlled props are projected, not mirrored.** `useTree` writes
65
+ controlled `items`/`activeId`/`collapsed` into the store in a layout effect
66
+ with write gates. `treeOwnedItemsAtom` reads empty while a prop owns the
67
+ data — without it an inline `items={[…]}` array loops forever.
68
+ 3. **Keep the rail label mounted.** Unmounting the label on collapse killed
69
+ the fold animation; it animates `width/opacity/x/display` in place and the
70
+ test asserts `display: none`, not absence. (12b6a1f)
71
+ 4. **Both rail width endpoints must be px.** Motion cannot tween a number
72
+ against `"100%"`; doing so pinned the inline width at the collapsed value
73
+ and the rail never reopened. The expanded width is measured off the
74
+ container, only while expanded, with the list's inline width cleared for
75
+ the sample. (a69072d, 2f7539f)
76
+ 5. **Tooltip is disabled, not unmounted, when the rail expands.** Swapping the
77
+ wrapper remounts the row and cuts the label fold short. Test asserts row
78
+ identity across collapse.
79
+ 6. **Trailing actions live beside the row.** See "Row anatomy". (2c59e53)
80
+ 7. **Rows became `Button`; anchors were removed** (2026-08-18, this branch).
81
+ Consequences that had to follow: tests query `role: "button"` everywhere,
82
+ `href` documentation flipped from "renders an anchor" to "metadata for
83
+ `onNavigate`", `RAIL_COLLAPSED_WIDTH` 44 → 40 to match `w-10`, `sound`
84
+ forwarded to Button, and the toc `#{id}` anchor test was rewritten as
85
+ "row selects, overlay toggle expands". The native `#{id}` jump for toc
86
+ rows was then restored via `location.hash` in `handlePress` (see "Row
87
+ anatomy") — dropping it silently was a regression, not a simplification.
88
+ 8. **`settleExit()` must run inside `act`.** AnimatePresence exits finishing
89
+ during a bare `setTimeout` wait produce "not wrapped in act" warnings;
90
+ `act(() => new Promise(r => setTimeout(r, 400)))` keeps them tracked. Query
91
+ **once** after settling — happy-dom serves stale `waitFor` results while an
92
+ exit is in flight (see memory `corpora-ui-happy-dom-exit-animations`).
93
+
94
+ ## Testing notes
95
+
96
+ - Run from `react/`: `bun test src/components/composed/tree`.
97
+ - happy-dom cannot drive Base UI hover/focus-visible, so tooltip tests assert
98
+ the wiring (`data-base-ui-tooltip-trigger`) not the popup.
99
+ - happy-dom rects are zero-height, so DnD tests hit the 0.5-midpoint fallback
100
+ ("inside" for folders, "after" for leaves).
101
+ - Motion sets no inline styles under happy-dom — the rail width tests lock
102
+ the **class**; the px tween is only verifiable in a real browser.
103
+ - Browser check via the docs demo (`/components/tree`, dev server config
104
+ `corpora-ui-docs`): the demo's Base UI `Select` only takes clicks on the
105
+ option's real viewport rect (screenshot frame is scaled — convert), and if
106
+ the Browser pane tab is hidden (`document.hidden`) both WAAPI and motion's
107
+ frameloop pause, so animated end-states can't be observed there — front the
108
+ tab or use a real browser for the fold.
109
+
110
+ ## Change log
111
+
112
+ - **2026-08-18 — fix/tree-collapsed** (on `release/v0.21.0`)
113
+ - `tree-node.tsx`: link/toggle rows render the shared `Button` (ghost /
114
+ secondary when active on sidebar); anchor branch removed; collapsed rail
115
+ rows `h-10! rounded-xl!`; rename input borderless; `cursor-pointer` on
116
+ rows/toggles; `sound` forwarded to Button.
117
+ - `tree.tsx`: rail resting class `w-11` → `w-10`, `gap-y-2!` when collapsed.
118
+ - `constants.ts`: `RAIL_COLLAPSED_WIDTH` 44 → 40.
119
+ - `type.ts` / `registry/components.ts`: `href`, `onNavigate`, `navigation`
120
+ and `toc` docs describe button rows + `onNavigate`-only navigation.
121
+ - `__tests__/tree.test.tsx`: link → button queries; new tests for `href`
122
+ passthrough, `sound={false}`, `RAIL_COLLAPSED_WIDTH` ↔ `w-10`, collapsed
123
+ tile classes, active-row `secondary` variant, toc row-vs-toggle split,
124
+ toc `#{id}` / `#hash` jump after `onNavigate` (route rows leave the hash
125
+ alone).
126
+ - `__tests__/*.test.tsx`: `settleExit` wrapped in `act`.
127
+ - **2026-08-17** — Jotai migration (`ExegiaProvider`, atom families,
128
+ `useTree`/`useTreeState`/`useTreeActions`), rail reopen + measurement fixes,
129
+ label kept mounted, trailing actions moved out of the row, headless
130
+ controller API.
131
+ - **2026-08-16** — Initial four-variant tree, tooltip on collapsed rail rows,
132
+ motion + a11y pass.