@exegia/corpora-ui 0.20.0 → 0.22.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 (165) hide show
  1. package/dist-lib/components/blocks/auth/auth-flow-atom.d.ts +77 -0
  2. package/dist-lib/components/blocks/auth/auth-session-atom.d.ts +28 -0
  3. package/dist-lib/components/blocks/auth/auth-state-type.d.ts +71 -0
  4. package/dist-lib/components/blocks/auth/auth-state.d.ts +9 -0
  5. package/dist-lib/components/blocks/auth/use-auth-state.d.ts +52 -0
  6. package/dist-lib/components/blocks/layout.d.ts +4 -1
  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 -12
  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/__tests__/logo.test.d.ts +1 -0
  35. package/dist-lib/components/composed/logo.d.ts +34 -0
  36. package/dist-lib/components/composed/tree/__tests__/tree-atom.test.d.ts +1 -0
  37. package/dist-lib/components/composed/tree/constants.d.ts +4 -4
  38. package/dist-lib/components/composed/tree/index.d.ts +3 -1
  39. package/dist-lib/components/composed/tree/tree-atom.d.ts +160 -0
  40. package/dist-lib/components/composed/tree/tree-node.d.ts +6 -1
  41. package/dist-lib/components/composed/tree/type.d.ts +116 -18
  42. package/dist-lib/components/composed/tree/use-tree-dnd.d.ts +18 -7
  43. package/dist-lib/components/composed/tree/use-tree-state.d.ts +25 -0
  44. package/dist-lib/components/composed/tree/use-tree.d.ts +4 -0
  45. package/dist-lib/components/composed/user-avatar.d.ts +5 -28
  46. package/dist-lib/components/user-avatar/__tests__/user-avatar.test.d.ts +1 -0
  47. package/dist-lib/components/user-avatar/component.d.ts +1 -8
  48. package/dist-lib/components/user-avatar/index.d.ts +5 -0
  49. package/dist-lib/components/user-avatar/presence-badge.d.ts +16 -0
  50. package/dist-lib/components/user-avatar/type.d.ts +53 -0
  51. package/dist-lib/components/user-avatar/use-user-avatar-state.d.ts +26 -0
  52. package/dist-lib/components/user-avatar/use-user-avatar.d.ts +37 -0
  53. package/dist-lib/components/user-avatar/user-avatar-atom.d.ts +64 -0
  54. package/dist-lib/components/user-avatar/utils.d.ts +22 -0
  55. package/dist-lib/index.d.ts +6 -3
  56. package/dist-lib/index.js +3732 -2670
  57. package/dist-lib/index.js.map +1 -1
  58. package/dist-lib/state/exegia-provider.d.ts +51 -0
  59. package/dist-lib/state/index.d.ts +4 -0
  60. package/dist-lib/state/store.d.ts +17 -0
  61. package/package.json +15 -12
  62. package/src/components/blocks/auth/__tests__/auth-state-atom.test.tsx +247 -0
  63. package/src/components/blocks/auth/auth-flow-atom.ts +238 -0
  64. package/src/components/blocks/auth/auth-session-atom.ts +82 -0
  65. package/src/components/blocks/auth/auth-state-type.ts +97 -0
  66. package/src/components/blocks/auth/auth-state.ts +52 -0
  67. package/src/components/blocks/auth/use-auth-state.ts +127 -0
  68. package/src/components/blocks/layout.ts +30 -2
  69. package/src/components/blocks/nav/sidebar/__tests__/ai-sidebar-atom.test.tsx +325 -0
  70. package/src/components/blocks/nav/sidebar/ai-sidebar-atom.ts +698 -0
  71. package/src/components/blocks/nav/sidebar/ai-sidebar.tsx +48 -42
  72. package/src/components/blocks/nav/sidebar/index.ts +53 -1
  73. package/src/components/blocks/nav/sidebar/sidebar-context.ts +15 -0
  74. package/src/components/blocks/nav/sidebar/sidebar-row.tsx +119 -50
  75. package/src/components/blocks/nav/sidebar/type.ts +125 -30
  76. package/src/components/blocks/nav/sidebar/use-ai-sidebar-state.ts +123 -0
  77. package/src/components/blocks/nav/sidebar/use-ai-sidebar.ts +301 -248
  78. package/src/components/blocks/nav/sidebar/utils.ts +1 -1
  79. package/src/components/blocks/profile/__tests__/profile-card-block.test.tsx +194 -0
  80. package/src/components/blocks/profile/index.ts +28 -0
  81. package/src/components/blocks/profile/profile-card-atom.ts +247 -0
  82. package/src/components/blocks/profile/profile-card-block.tsx +153 -74
  83. package/src/components/blocks/profile/type.ts +95 -0
  84. package/src/components/blocks/profile/use-profile-card-state.ts +67 -0
  85. package/src/components/blocks/profile/use-profile-card.ts +126 -0
  86. package/src/components/blocks/shell/__tests__/shell-fit-atom.test.tsx +360 -0
  87. package/src/components/blocks/shell/__tests__/shell-layout.test.tsx +192 -3
  88. package/src/components/blocks/shell/__tests__/shell-metrics.test.ts +108 -0
  89. package/src/components/blocks/shell/animated-panel-inset.tsx +5 -1
  90. package/src/components/blocks/shell/animated-panel-provider.tsx +77 -11
  91. package/src/components/blocks/shell/animated-panel-trigger.tsx +4 -0
  92. package/src/components/blocks/shell/animated-panel.tsx +126 -29
  93. package/src/components/blocks/shell/index.ts +18 -13
  94. package/src/components/blocks/shell/shell-fit-atom.ts +243 -0
  95. package/src/components/blocks/shell/shell-layout.tsx +55 -53
  96. package/src/components/blocks/shell/shell-metrics.ts +79 -0
  97. package/src/components/blocks/shell/type.ts +130 -3
  98. package/src/components/blocks/shell/use-shell-fit-state.ts +49 -0
  99. package/src/components/blocks/shell/use-shell-fit.ts +135 -0
  100. package/src/components/blocks/shell/use-shell-panels.ts +57 -4
  101. package/src/components/blocks/shell/utils.ts +44 -4
  102. package/src/components/composed/__tests__/logo.test.tsx +60 -0
  103. package/src/components/composed/logo.tsx +159 -0
  104. package/src/components/composed/tree/CLAUDE.md +132 -0
  105. package/src/components/composed/tree/__tests__/tree-atom.test.tsx +217 -0
  106. package/src/components/composed/tree/__tests__/tree.test.tsx +162 -37
  107. package/src/components/composed/tree/__tests__/use-tree.test.tsx +2 -1
  108. package/src/components/composed/tree/constants.ts +4 -4
  109. package/src/components/composed/tree/index.ts +38 -0
  110. package/src/components/composed/tree/tree-atom.ts +590 -0
  111. package/src/components/composed/tree/tree-node.tsx +117 -78
  112. package/src/components/composed/tree/tree.tsx +24 -16
  113. package/src/components/composed/tree/type.ts +125 -15
  114. package/src/components/composed/tree/use-tree-dnd.ts +82 -51
  115. package/src/components/composed/tree/use-tree-state.ts +105 -0
  116. package/src/components/composed/tree/use-tree.ts +185 -184
  117. package/src/components/composed/user-avatar.tsx +11 -99
  118. package/src/components/user-avatar/__tests__/user-avatar.test.tsx +286 -0
  119. package/src/components/user-avatar/component.tsx +151 -22
  120. package/src/components/user-avatar/fallback.tsx +3 -1
  121. package/src/components/user-avatar/index.ts +19 -0
  122. package/src/components/user-avatar/presence-badge.tsx +56 -0
  123. package/src/components/user-avatar/type.ts +58 -0
  124. package/src/components/user-avatar/use-user-avatar-state.ts +60 -0
  125. package/src/components/user-avatar/use-user-avatar.ts +180 -0
  126. package/src/components/user-avatar/user-avatar-atom.ts +218 -0
  127. package/src/components/user-avatar/utils.ts +94 -0
  128. package/src/index.ts +35 -3
  129. package/src/state/exegia-provider.tsx +79 -0
  130. package/src/state/index.ts +4 -0
  131. package/src/state/store.ts +19 -0
  132. package/dist-lib/components/blocks/nav/sidebar-block.d.ts +0 -14
  133. package/dist-lib/components/blocks/nav/sidebar-nav-row.d.ts +0 -3
  134. package/dist-lib/components/blocks/nav/types.d.ts +0 -60
  135. package/dist-lib/components/blocks/nav/utils.d.ts +0 -1
  136. package/dist-lib/components/blocks/shell/animated-sidebar-content.d.ts +0 -2
  137. package/dist-lib/components/blocks/shell/animated-sidebar-footer.d.ts +0 -2
  138. package/dist-lib/components/blocks/shell/animated-sidebar-group-content.d.ts +0 -2
  139. package/dist-lib/components/blocks/shell/animated-sidebar-group-label.d.ts +0 -2
  140. package/dist-lib/components/blocks/shell/animated-sidebar-group.d.ts +0 -2
  141. package/dist-lib/components/blocks/shell/animated-sidebar-header.d.ts +0 -2
  142. package/dist-lib/components/blocks/shell/animated-sidebar-menu-button.d.ts +0 -3
  143. package/dist-lib/components/blocks/shell/animated-sidebar-menu-item.d.ts +0 -2
  144. package/dist-lib/components/blocks/shell/animated-sidebar-menu-sub-button.d.ts +0 -3
  145. package/dist-lib/components/blocks/shell/animated-sidebar-menu-sub-item.d.ts +0 -2
  146. package/dist-lib/components/blocks/shell/animated-sidebar-menu-sub.d.ts +0 -2
  147. package/dist-lib/components/blocks/shell/animated-sidebar-menu.d.ts +0 -2
  148. package/src/components/blocks/nav/__tests__/sidebar-block.test.tsx +0 -125
  149. package/src/components/blocks/nav/sidebar-block.tsx +0 -142
  150. package/src/components/blocks/nav/sidebar-nav-row.tsx +0 -78
  151. package/src/components/blocks/nav/types.ts +0 -65
  152. package/src/components/blocks/nav/utils.ts +0 -4
  153. package/src/components/blocks/shell/animated-sidebar-content.tsx +0 -19
  154. package/src/components/blocks/shell/animated-sidebar-footer.tsx +0 -19
  155. package/src/components/blocks/shell/animated-sidebar-group-content.tsx +0 -16
  156. package/src/components/blocks/shell/animated-sidebar-group-label.tsx +0 -29
  157. package/src/components/blocks/shell/animated-sidebar-group.tsx +0 -16
  158. package/src/components/blocks/shell/animated-sidebar-header.tsx +0 -16
  159. package/src/components/blocks/shell/animated-sidebar-menu-button.tsx +0 -151
  160. package/src/components/blocks/shell/animated-sidebar-menu-item.tsx +0 -20
  161. package/src/components/blocks/shell/animated-sidebar-menu-sub-button.tsx +0 -85
  162. package/src/components/blocks/shell/animated-sidebar-menu-sub-item.tsx +0 -19
  163. package/src/components/blocks/shell/animated-sidebar-menu-sub.tsx +0 -44
  164. package/src/components/blocks/shell/animated-sidebar-menu.tsx +0 -26
  165. /package/dist-lib/components/blocks/{nav/__tests__/sidebar-block.test.d.ts → auth/__tests__/auth-state-atom.test.d.ts} +0 -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,60 @@
1
+ import { describe, expect, test } from "bun:test"
2
+ import { render, screen } from "@testing-library/react"
3
+
4
+ import { Logo } from "../logo"
5
+
6
+ const root = () => document.querySelector<HTMLElement>('[data-slot="logo"]')
7
+ const wordmark = () =>
8
+ document.querySelector<HTMLElement>('[data-slot="logo-wordmark"]')
9
+ const monogram = () =>
10
+ document.querySelector<HTMLElement>('[data-slot="logo-monogram"]')
11
+
12
+ describe("Logo", () => {
13
+ test("defaults: monogram tile from the name, name as the wordmark", () => {
14
+ render(<Logo name="Corpora Codex" />)
15
+ expect(monogram()?.textContent).toBe("CC")
16
+ expect(wordmark()?.textContent).toBe("Corpora Codex")
17
+ expect(root()?.tagName).toBe("SPAN")
18
+ expect(root()?.getAttribute("data-variant")).toBe("full")
19
+ })
20
+
21
+ test("a custom mark wins over src; src renders a decorative image", () => {
22
+ const { rerender } = render(
23
+ <Logo name="Corpora" src="https://example.com/mark.png" />
24
+ )
25
+ expect(monogram()).toBeNull()
26
+ expect(document.querySelector("img")?.getAttribute("alt")).toBe("")
27
+ rerender(
28
+ <Logo
29
+ name="Corpora"
30
+ src="https://example.com/mark.png"
31
+ mark={<svg data-testid="mark" />}
32
+ />
33
+ )
34
+ expect(document.querySelector("img")).toBeNull()
35
+ expect(screen.getByTestId("mark")).toBeTruthy()
36
+ })
37
+
38
+ test("href renders a link named for AT regardless of variant", () => {
39
+ render(<Logo name="Corpora" href="/" variant="mark" />)
40
+ const link = screen.getByRole("link", { name: "Corpora" })
41
+ expect(link.getAttribute("href")).toBe("/")
42
+ expect(link.getAttribute("data-slot")).toBe("logo")
43
+ })
44
+
45
+ test("variant=mark folds the wordmark: hidden from AT, root keeps the name", () => {
46
+ const { rerender } = render(<Logo name="Corpora" variant="mark" />)
47
+ expect(wordmark()?.getAttribute("aria-hidden")).toBe("true")
48
+ expect(root()?.getAttribute("aria-label")).toBe("Corpora")
49
+ expect(root()?.getAttribute("data-variant")).toBe("mark")
50
+ // Unfolded, the visible wordmark is the name — no double label.
51
+ rerender(<Logo name="Corpora" variant="full" />)
52
+ expect(wordmark()?.getAttribute("aria-hidden")).toBeNull()
53
+ expect(root()?.getAttribute("aria-label")).toBeNull()
54
+ })
55
+
56
+ test("a custom wordmark replaces the name text", () => {
57
+ render(<Logo name="Corpora" wordmark={<span>corpora/ui</span>} />)
58
+ expect(wordmark()?.textContent).toBe("corpora/ui")
59
+ })
60
+ })
@@ -0,0 +1,159 @@
1
+ "use client"
2
+
3
+ import * as React from "react"
4
+ import { motion, useReducedMotion } from "motion/react"
5
+ import type { Variants } from "motion/react"
6
+
7
+ import { EASE_OUT } from "@/lib/ease.ts"
8
+ import { cn } from "@/lib/utils"
9
+ import { initialsFrom } from "@/components/user-avatar"
10
+
11
+ /** `full` shows mark + wordmark; `mark` folds the wordmark away — an icon
12
+ * rail, a favicon-sized corner. */
13
+ export type LogoVariant = "full" | "mark"
14
+
15
+ export interface LogoProps
16
+ extends Omit<React.HTMLAttributes<HTMLElement>, "children"> {
17
+ /**
18
+ * Brand name. Labels the logo for AT (and the link, when `href` renders
19
+ * one), drives the default wordmark, and the monogram tile when no mark
20
+ * is given.
21
+ */
22
+ name: string
23
+ /** Custom mark — an inline SVG sized to fill its box. Wins over `src`. */
24
+ mark?: React.ReactNode
25
+ /** Image URL for the mark. Decorative — `name` labels the logo. */
26
+ src?: string
27
+ /** Wordmark content. Defaults to `name`. */
28
+ wordmark?: React.ReactNode
29
+ /** `full` (default) or `mark` — the wordmark folds away, animated. */
30
+ variant?: LogoVariant
31
+ /** Renders the logo as a link — the usual "mark goes home" affordance. */
32
+ href?: string
33
+ }
34
+
35
+ /** Wordmark fold/unfold — the same envelope the profile card's identity
36
+ * lines use, so a logo and a profile card folding in the same rail move as
37
+ * one. Kept local: composed components do not reach into blocks. Expressed
38
+ * as named variants for consistency with the profile card's fold envelope —
39
+ * house style for multi-property fold states, not a motion requirement. */
40
+ const WORDMARK_VARIANTS: Variants = {
41
+ open: {
42
+ width: "auto",
43
+ opacity: 1,
44
+ x: 0,
45
+ transition: { duration: 0.2, delay: 0.08, ease: EASE_OUT },
46
+ },
47
+ folded: {
48
+ width: 0,
49
+ opacity: 0,
50
+ x: -4,
51
+ transition: { duration: 0.12, ease: EASE_OUT },
52
+ },
53
+ }
54
+
55
+ const WORDMARK_VARIANTS_REDUCED: Variants = {
56
+ open: { width: "auto", opacity: 1, x: 0, transition: { duration: 0.16, ease: EASE_OUT } },
57
+ folded: { width: 0, opacity: 0, x: 0, transition: { duration: 0.16, ease: EASE_OUT } },
58
+ }
59
+
60
+ /**
61
+ * Brand lockup: a mark beside a wordmark. The mark is whatever the brand
62
+ * has — an SVG (`mark`), an image (`src`), or, given neither, a monogram
63
+ * tile derived from `name`. `variant="mark"` folds the wordmark away with
64
+ * the same motion a collapsing rail uses, so the lockup can sit in one and
65
+ * follow its fold. With `href` the whole lockup is a link named by `name`.
66
+ *
67
+ * Sizing rides on the mark: it defaults to `size-8`; restyle via
68
+ * `[&_[data-slot=logo-mark]]:size-*` or wrap in a text-size context for the
69
+ * wordmark, which inherits.
70
+ */
71
+ export function Logo({
72
+ name,
73
+ mark,
74
+ src,
75
+ wordmark,
76
+ variant = "full",
77
+ href,
78
+ className,
79
+ ...props
80
+ }: LogoProps): React.ReactElement {
81
+ const reduce = useReducedMotion()
82
+ const folded = variant === "mark"
83
+
84
+ const markNode = mark ?? (
85
+ src !== undefined ? (
86
+ // Decorative: the root's `name` (visible wordmark or aria-label)
87
+ // already names the brand.
88
+ <img alt="" className="size-full object-contain" src={src} />
89
+ ) : (
90
+ <span
91
+ aria-hidden="true"
92
+ className="flex size-full items-center justify-center rounded-lg bg-foreground font-semibold text-background text-[45cqw]"
93
+ data-slot="logo-monogram"
94
+ >
95
+ {initialsFrom(name)}
96
+ </span>
97
+ )
98
+ )
99
+
100
+ const content = (
101
+ <>
102
+ <span
103
+ className="size-8 shrink-0 @container [&_svg]:size-full"
104
+ data-slot="logo-mark"
105
+ >
106
+ {markNode}
107
+ </span>
108
+ <motion.span
109
+ animate={folded ? "folded" : "open"}
110
+ variants={reduce ? WORDMARK_VARIANTS_REDUCED : WORDMARK_VARIANTS}
111
+ // Folded, the wordmark is gone for AT too — the root's label names
112
+ // the brand on its own.
113
+ aria-hidden={folded || undefined}
114
+ className={cn(
115
+ "min-w-0 overflow-hidden font-semibold whitespace-nowrap tracking-tight",
116
+ folded && "pointer-events-none",
117
+ // 0px wide while folded, but the flex gap before it would still
118
+ // offset the mark — pull it back by that gap.
119
+ folded && "-ml-2"
120
+ )}
121
+ data-slot="logo-wordmark"
122
+ initial={false}
123
+ >
124
+ {wordmark ?? name}
125
+ </motion.span>
126
+ </>
127
+ )
128
+
129
+ const rootClassName = cn(
130
+ "inline-flex shrink-0 items-center gap-2 text-foreground",
131
+ className
132
+ )
133
+
134
+ return href !== undefined ? (
135
+ <a
136
+ // Always labelled: the folded state hides the wordmark, and a link
137
+ // must not lose its name mid-animation.
138
+ aria-label={name}
139
+ className={rootClassName}
140
+ data-slot="logo"
141
+ data-variant={variant}
142
+ href={href}
143
+ {...props}
144
+ >
145
+ {content}
146
+ </a>
147
+ ) : (
148
+ <span
149
+ aria-label={folded ? name : undefined}
150
+ className={rootClassName}
151
+ data-slot="logo"
152
+ data-variant={variant}
153
+ role={folded ? "img" : undefined}
154
+ {...props}
155
+ >
156
+ {content}
157
+ </span>
158
+ )
159
+ }