@exegia/corpora-ui 0.19.0 → 0.20.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 (73) hide show
  1. package/dist-lib/components/blocks/nav/sidebar/__tests__/use-ai-sidebar.test.d.ts +1 -0
  2. package/dist-lib/components/blocks/nav/sidebar/ai-sidebar.d.ts +11 -2
  3. package/dist-lib/components/blocks/nav/sidebar/type.d.ts +83 -1
  4. package/dist-lib/components/blocks/nav/sidebar/use-ai-sidebar.d.ts +22 -31
  5. package/dist-lib/components/blocks/nav/sidebar/utils.d.ts +5 -0
  6. package/dist-lib/components/blocks/scaffold/constants.d.ts +5 -0
  7. package/dist-lib/components/blocks/scaffold/scaffold-canvas.d.ts +2 -0
  8. package/dist-lib/components/blocks/scaffold/scaffold-panel.d.ts +1 -1
  9. package/dist-lib/components/blocks/scaffold/scaffold-tab.d.ts +6 -1
  10. package/dist-lib/components/blocks/scaffold/type.d.ts +28 -0
  11. package/dist-lib/components/blocks/scaffold/use-panel-visibility.d.ts +19 -0
  12. package/dist-lib/components/blocks/scaffold/utils.d.ts +6 -0
  13. package/dist-lib/components/composed/tree/__tests__/tree.test.d.ts +1 -0
  14. package/dist-lib/components/composed/tree/__tests__/use-tree.test.d.ts +1 -0
  15. package/dist-lib/components/composed/tree/constants.d.ts +35 -0
  16. package/dist-lib/components/composed/tree/index.d.ts +4 -0
  17. package/dist-lib/components/composed/tree/tree-context.d.ts +4 -0
  18. package/dist-lib/components/composed/tree/tree-node.d.ts +7 -0
  19. package/dist-lib/components/composed/tree/tree.d.ts +19 -0
  20. package/dist-lib/components/composed/tree/type.d.ts +192 -0
  21. package/dist-lib/components/composed/tree/use-tree-dnd.d.ts +7 -0
  22. package/dist-lib/components/composed/tree/use-tree.d.ts +20 -0
  23. package/dist-lib/components/composed/tree/utils.d.ts +28 -0
  24. package/dist-lib/components/user-avatar/audio-wave.d.ts +2 -0
  25. package/dist-lib/components/user-avatar/component.d.ts +10 -0
  26. package/dist-lib/components/user-avatar/fallback.d.ts +5 -0
  27. package/dist-lib/components/user-avatar/index.d.ts +13 -0
  28. package/dist-lib/components/user-avatar/type.d.ts +26 -0
  29. package/dist-lib/components/user-avatar/utils.d.ts +2 -0
  30. package/dist-lib/index.d.ts +1 -0
  31. package/dist-lib/index.js +2141 -1139
  32. package/dist-lib/index.js.map +1 -1
  33. package/package.json +2 -1
  34. package/src/components/beste/piece/browser-frame.tsx +9 -6
  35. package/src/components/blocks/nav/sidebar/__tests__/use-ai-sidebar.test.tsx +310 -0
  36. package/src/components/blocks/nav/sidebar/ai-sidebar.tsx +90 -60
  37. package/src/components/blocks/nav/sidebar/type.ts +105 -1
  38. package/src/components/blocks/nav/sidebar/use-ai-sidebar.ts +293 -116
  39. package/src/components/blocks/nav/sidebar/utils.ts +28 -0
  40. package/src/components/blocks/scaffold/__tests__/scaffold.test.tsx +160 -1
  41. package/src/components/blocks/scaffold/constants.ts +7 -0
  42. package/src/components/blocks/scaffold/panel-menu-button.tsx +3 -0
  43. package/src/components/blocks/scaffold/scaffold-canvas.tsx +48 -5
  44. package/src/components/blocks/scaffold/scaffold-inspector.tsx +3 -2
  45. package/src/components/blocks/scaffold/scaffold-main.tsx +1 -1
  46. package/src/components/blocks/scaffold/scaffold-panel.tsx +41 -5
  47. package/src/components/blocks/scaffold/scaffold-root.tsx +18 -3
  48. package/src/components/blocks/scaffold/scaffold-sidebar.tsx +1 -1
  49. package/src/components/blocks/scaffold/scaffold-tab.tsx +45 -7
  50. package/src/components/blocks/scaffold/type.ts +28 -0
  51. package/src/components/blocks/scaffold/use-panel-visibility.ts +180 -0
  52. package/src/components/blocks/scaffold/utils.ts +19 -0
  53. package/src/components/composed/tree/__tests__/tree.test.tsx +400 -0
  54. package/src/components/composed/tree/__tests__/use-tree.test.tsx +332 -0
  55. package/src/components/composed/tree/constants.ts +60 -0
  56. package/src/components/composed/tree/index.ts +13 -0
  57. package/src/components/composed/tree/tree-context.ts +13 -0
  58. package/src/components/composed/tree/tree-node.tsx +451 -0
  59. package/src/components/composed/tree/tree.tsx +278 -0
  60. package/src/components/composed/tree/type.ts +212 -0
  61. package/src/components/composed/tree/use-tree-dnd.ts +148 -0
  62. package/src/components/composed/tree/use-tree.ts +306 -0
  63. package/src/components/composed/tree/utils.ts +172 -0
  64. package/src/components/composed/user-avatar.tsx +4 -1
  65. package/src/components/docs/component-preview.tsx +11 -11
  66. package/src/components/user-avatar/audio-wave.tsx +22 -0
  67. package/src/components/user-avatar/component.tsx +53 -0
  68. package/src/components/user-avatar/fallback.tsx +26 -0
  69. package/src/components/user-avatar/index.ts +15 -0
  70. package/src/components/user-avatar/type.ts +35 -0
  71. package/src/components/user-avatar/utils.ts +8 -0
  72. package/src/index.css +211 -153
  73. package/src/index.ts +1 -0
@@ -1,10 +1,31 @@
1
1
  import { describe, expect, mock, test } from "bun:test"
2
- import { render, screen, waitFor } from "@testing-library/react"
2
+ import { act, render, screen, waitFor } from "@testing-library/react"
3
3
  import userEvent from "@testing-library/user-event"
4
4
  import type { ReactNode } from "react"
5
5
 
6
6
  import { Scaffold, useScaffold } from "../index"
7
7
  import type { TScaffoldPanelChild } from "../type"
8
+ import { getPanelCapacity } from "../utils"
9
+
10
+ // The canvas measures itself through ResizeObserver; this stub hands the
11
+ // callback to the test so it can drive the width deterministically.
12
+ let observeCanvas: ResizeObserverCallback | undefined
13
+ globalThis.ResizeObserver = class {
14
+ constructor(callback: ResizeObserverCallback) {
15
+ observeCanvas = callback
16
+ }
17
+ observe() {}
18
+ unobserve() {}
19
+ disconnect() {}
20
+ } as unknown as typeof ResizeObserver
21
+
22
+ const resizeCanvas = (width: number) =>
23
+ act(() => {
24
+ observeCanvas?.(
25
+ [{ contentRect: { width } }] as unknown as ResizeObserverEntry[],
26
+ undefined as unknown as ResizeObserver
27
+ )
28
+ })
8
29
 
9
30
  function Strip() {
10
31
  return <span>Strip</span>
@@ -268,6 +289,16 @@ describe("Scaffold", () => {
268
289
  expect(inspector().getAttribute("aria-hidden")).toBe("true")
269
290
  })
270
291
 
292
+ test("capacity grants each panel 320px plus the gaps between them", () => {
293
+ expect(getPanelCapacity(null)).toBe(3)
294
+ expect(getPanelCapacity(976)).toBe(3) // 3×320 + 2×8
295
+ expect(getPanelCapacity(975)).toBe(2)
296
+ expect(getPanelCapacity(648)).toBe(2) // 2×320 + 8
297
+ expect(getPanelCapacity(647)).toBe(1)
298
+ expect(getPanelCapacity(100)).toBe(1) // never below one panel
299
+ expect(getPanelCapacity(5000)).toBe(3) // never above the cap
300
+ })
301
+
271
302
  test("root manages inspector state itself when uncontrolled", () => {
272
303
  render(
273
304
  <Scaffold.Root defaultInspectorOpen>
@@ -284,3 +315,131 @@ describe("Scaffold", () => {
284
315
  ).toBeDefined()
285
316
  })
286
317
  })
318
+
319
+ const PANEL_IDS = ["one", "two", "three"] as const
320
+
321
+ function ResponsiveWorkspace() {
322
+ return (
323
+ <Scaffold.Root>
324
+ <Scaffold.Main>
325
+ <Scaffold.Actions>
326
+ {PANEL_IDS.map((id) => (
327
+ <Scaffold.Tab key={id} panelId={id}>
328
+ {`Tab ${id}`}
329
+ </Scaffold.Tab>
330
+ ))}
331
+ </Scaffold.Actions>
332
+ <Scaffold.Canvas>
333
+ {PANEL_IDS.map((id) => (
334
+ <Scaffold.Panel key={id} id={id} name={`Panel ${id}`}>
335
+ <span>{`Body ${id}`}</span>
336
+ </Scaffold.Panel>
337
+ ))}
338
+ </Scaffold.Canvas>
339
+ </Scaffold.Main>
340
+ </Scaffold.Root>
341
+ )
342
+ }
343
+
344
+ const panel = (id: string) =>
345
+ screen.queryByRole("region", { name: `Panel ${id}` })
346
+
347
+ const tab = (id: string) => screen.getByRole("button", { name: `Tab ${id}` })
348
+
349
+ describe("Scaffold responsive panels", () => {
350
+ test("narrowing hides the oldest panel; widening restores it", async () => {
351
+ render(<ResponsiveWorkspace />)
352
+
353
+ resizeCanvas(976)
354
+ for (const id of PANEL_IDS) expect(panel(id)).not.toBeNull()
355
+
356
+ // Two panels' worth of room — the least-recently-activated hides.
357
+ resizeCanvas(700)
358
+ await settleExit()
359
+ expect(panel("one")).toBeNull()
360
+ expect(panel("two")).not.toBeNull()
361
+ expect(panel("three")).not.toBeNull()
362
+ expect(
363
+ tab("one")
364
+ .closest('[data-slot="scaffold-tab"]')
365
+ ?.getAttribute("data-panel-hidden")
366
+ ).toBe("")
367
+ expect(tab("one").getAttribute("aria-pressed")).toBe("false")
368
+
369
+ // Room returns — the auto-hidden panel comes back by itself.
370
+ resizeCanvas(1200)
371
+ expect(await screen.findByRole("region", { name: "Panel one" })).toBeDefined()
372
+ expect(tab("one").getAttribute("aria-pressed")).toBe("true")
373
+ })
374
+
375
+ test("pressing a hidden tab shows its panel, hiding the oldest visible one", async () => {
376
+ const user = userEvent.setup()
377
+ render(<ResponsiveWorkspace />)
378
+
379
+ resizeCanvas(700)
380
+ await settleExit()
381
+ expect(panel("one")).toBeNull()
382
+
383
+ await user.click(tab("one"))
384
+ await settleExit()
385
+ expect(panel("one")).not.toBeNull()
386
+ expect(panel("two")).toBeNull()
387
+ expect(panel("three")).not.toBeNull()
388
+ })
389
+
390
+ test("pressing a visible tab hides its panel; the last one never hides", async () => {
391
+ const user = userEvent.setup()
392
+ render(<ResponsiveWorkspace />)
393
+
394
+ resizeCanvas(400)
395
+ await settleExit()
396
+ // One panel's worth of room — only the newest activation stays.
397
+ expect(panel("one")).toBeNull()
398
+ expect(panel("two")).toBeNull()
399
+ expect(panel("three")).not.toBeNull()
400
+
401
+ // The sole visible panel refuses to hide.
402
+ await user.click(tab("three"))
403
+ await settleExit()
404
+ expect(panel("three")).not.toBeNull()
405
+
406
+ // Activating another trades places with it.
407
+ await user.click(tab("one"))
408
+ await settleExit()
409
+ expect(panel("one")).not.toBeNull()
410
+ expect(panel("three")).toBeNull()
411
+ })
412
+
413
+ test("hovering a tab spotlights its panel and dims the others", async () => {
414
+ const user = userEvent.setup()
415
+ render(<ResponsiveWorkspace />)
416
+
417
+ resizeCanvas(1200)
418
+ await user.hover(tab("two"))
419
+ expect(panel("two")?.getAttribute("data-dimmed")).toBeNull()
420
+ expect(panel("one")?.getAttribute("data-dimmed")).toBe("")
421
+ expect(panel("three")?.getAttribute("data-dimmed")).toBe("")
422
+
423
+ await user.unhover(tab("two"))
424
+ expect(panel("one")?.getAttribute("data-dimmed")).toBeNull()
425
+ expect(panel("three")?.getAttribute("data-dimmed")).toBeNull()
426
+ })
427
+
428
+ test("user-hidden panels stay hidden when the canvas widens", async () => {
429
+ const user = userEvent.setup()
430
+ render(<ResponsiveWorkspace />)
431
+
432
+ resizeCanvas(1200)
433
+ await user.click(tab("two"))
434
+ await settleExit()
435
+ expect(panel("two")).toBeNull()
436
+
437
+ resizeCanvas(1400)
438
+ await settleExit()
439
+ expect(panel("two")).toBeNull()
440
+
441
+ // Its tab presses back to visible.
442
+ await user.click(tab("two"))
443
+ expect(await screen.findByRole("region", { name: "Panel two" })).toBeDefined()
444
+ })
445
+ })
@@ -8,6 +8,13 @@ export const SCAFFOLD_INSPECTOR_WIDTH = 272
8
8
  * count reaches it so the Add segment hides. */
9
9
  export const SCAFFOLD_PANEL_CAPACITY = 3
10
10
 
11
+ /** Narrowest a panel may render, in px. When the canvas can't grant every
12
+ * panel this width, the scaffold hides panels until the rest fit. */
13
+ export const SCAFFOLD_PANEL_MIN_WIDTH = 320
14
+
15
+ /** Gap in px between side-by-side panels on the canvas (`gap-2`). */
16
+ export const SCAFFOLD_CANVAS_GAP = 8
17
+
11
18
  /** Gap in px between the drawer/actions and the viewport edges. */
12
19
  export const SCAFFOLD_EDGE_GUTTER = 10
13
20
 
@@ -145,6 +145,9 @@ export function PanelMenuButton({
145
145
  size="icon-sm"
146
146
  variant="ghost"
147
147
  sound={sound}
148
+ // The hidden Expand|Swap|Close side stretches the pill to the sm
149
+ // button height — match it so the collapsed toggle stays square.
150
+ className="size-8 sm:size-7"
148
151
  data-slot="scaffold-panel-menu-toggle"
149
152
  onClick={handleOnExpand}
150
153
  onPointerEnter={handleToggleHover}
@@ -4,26 +4,69 @@ import { AnimatePresence } from "motion/react"
4
4
  import * as React from "react"
5
5
 
6
6
  import { cn } from "@/lib/utils"
7
- import type { ScaffoldCanvasProps } from "./type"
7
+ import { useScaffoldContext } from "./scaffold-context"
8
+ import type { ScaffoldCanvasProps, ScaffoldPanelProps } from "./type"
9
+
10
+ /** A canvas child's panel id when it opted into responsive hiding. */
11
+ function panelIdOf(child: React.ReactNode): string | undefined {
12
+ if (!React.isValidElement<Pick<ScaffoldPanelProps, "id">>(child))
13
+ return undefined
14
+ const id = child.props.id
15
+ return typeof id === "string" ? id : undefined
16
+ }
8
17
 
9
18
  /**
10
19
  * The panel row below the action cluster: 8px gutters, 8px gaps, panels
11
20
  * side by side. Key each `Scaffold.Panel` child so closes animate out.
21
+ * Measures its own width and hides id'd panels (oldest activation first)
22
+ * whenever it can't grant each one `SCAFFOLD_PANEL_MIN_WIDTH`.
12
23
  */
13
24
  export function ScaffoldCanvas({
14
25
  className,
15
26
  children,
16
27
  ...rest
17
28
  }: ScaffoldCanvasProps): React.ReactElement {
29
+ const { hiddenPanelIds, registerPanelIds, setCanvasWidth } =
30
+ useScaffoldContext()
31
+ const ref = React.useRef<HTMLDivElement>(null)
32
+
33
+ React.useLayoutEffect(() => {
34
+ const element = ref.current
35
+ if (!element || typeof ResizeObserver === "undefined") return
36
+ const observer = new ResizeObserver(([entry]) =>
37
+ setCanvasWidth(entry.contentRect.width)
38
+ )
39
+ observer.observe(element)
40
+ return () => observer.disconnect()
41
+ }, [setCanvasWidth])
42
+
43
+ const childArray = React.Children.toArray(children)
44
+ const panelIds = childArray
45
+ .map(panelIdOf)
46
+ .filter((id): id is string => id !== undefined)
47
+ const idsKey = panelIds.join(" ")
48
+
49
+ React.useLayoutEffect(() => {
50
+ registerPanelIds(panelIds)
51
+ // panelIds is rebuilt each render; idsKey carries its identity.
52
+ // eslint-disable-next-line react-hooks/exhaustive-deps
53
+ }, [idsKey, registerPanelIds])
54
+
55
+ const visibleChildren = childArray.filter((child) => {
56
+ const id = panelIdOf(child)
57
+ return id === undefined || !hiddenPanelIds.includes(id)
58
+ })
59
+
18
60
  return (
19
61
  <div
20
- className={cn("relative flex w-full flex-1 gap-2", className)}
62
+ className={cn("relative flex min-h-0 w-full flex-1 gap-2", className)}
21
63
  data-slot="scaffold-canvas"
64
+ ref={ref}
22
65
  {...rest}
23
66
  >
24
- <AnimatePresence initial={false} mode="popLayout">
25
- {children}
26
- </AnimatePresence>
67
+ {/* Sync mode (no `mode="wait"`): an exiting panel shrinks in place
68
+ while its siblings grow into the freed room in the same beat. */}
69
+ <AnimatePresence initial={false}>{visibleChildren}</AnimatePresence>
27
70
  </div>
28
71
  )
29
72
  }
@@ -53,7 +53,7 @@ export function ScaffoldInspector({
53
53
  {...rest}
54
54
  >
55
55
  <GlassContainer
56
- className="flex flex-1 flex-col rounded-md border-t border-white bg-neutral-50/10 shadow-inner shadow-md dark:border-neutral-800 dark:bg-black/5"
56
+ className="flex flex-1 flex-col rounded-md border-t border-white bg-neutral-50/20 shadow-inner shadow-lg dark:border-neutral-800 dark:bg-black/5"
57
57
  glassVariant="subtle"
58
58
  >
59
59
  <div
@@ -65,7 +65,8 @@ export function ScaffoldInspector({
65
65
  </span>
66
66
  <Button
67
67
  size="icon-xs"
68
- variant="secondary"
68
+ variant="glass"
69
+ glassVariant="subtle"
69
70
  className="scale-85"
70
71
  aria-label="Close inspector"
71
72
  onClick={toggleInspector}
@@ -18,7 +18,7 @@ export function ScaffoldMain({
18
18
  return (
19
19
  <div
20
20
  id="scaffold-main"
21
- className={cn("relative flex h-full min-w-0 flex-1 flex-col", className)}
21
+ className={cn("relative flex min-w-0 flex-1 flex-col", className)}
22
22
  data-slot="scaffold-main"
23
23
  {...rest}
24
24
  >
@@ -5,8 +5,13 @@ import * as React from "react"
5
5
  import { useState } from "react"
6
6
 
7
7
  import { cn } from "@/lib/utils"
8
- import { SCAFFOLD_EASE, SCAFFOLD_MORPH_DURATION } from "./constants"
8
+ import {
9
+ SCAFFOLD_EASE,
10
+ SCAFFOLD_MORPH_DURATION,
11
+ SCAFFOLD_PANEL_MIN_WIDTH,
12
+ } from "./constants"
9
13
  import { PanelMenuButton } from "./panel-menu-button.tsx"
14
+ import { useScaffoldContext } from "./scaffold-context"
10
15
  import type { ScaffoldPanelProps, TSubPanelPosition } from "./type"
11
16
  import { ScaffoldSubPanel } from "@/components/blocks/scaffold/scaffold-sub-panel.tsx"
12
17
 
@@ -18,6 +23,7 @@ import { ScaffoldSubPanel } from "@/components/blocks/scaffold/scaffold-sub-pane
18
23
  * primary slot while the primary card shrinks down into the strip.
19
24
  */
20
25
  export function ScaffoldPanel({
26
+ id,
21
27
  children,
22
28
  SecondaryPanel,
23
29
  onSwap,
@@ -27,8 +33,14 @@ export function ScaffoldPanel({
27
33
  sound = true,
28
34
  className,
29
35
  }: ScaffoldPanelProps): React.ReactElement {
36
+ const { hoveredPanelId } = useScaffoldContext()
30
37
  const reducedMotion = useReducedMotion()
31
38
 
39
+ // A hovered tab spotlights its own panel — every other id'd panel
40
+ // fades back so the pairing reads at a glance.
41
+ const dimmed =
42
+ id !== undefined && hoveredPanelId !== null && hoveredPanelId !== id
43
+
32
44
  const transition = {
33
45
  duration: reducedMotion ? 0 : SCAFFOLD_MORPH_DURATION,
34
46
  ease: SCAFFOLD_EASE,
@@ -48,17 +60,41 @@ export function ScaffoldPanel({
48
60
  setSubPanelPosition((prev) => (prev === "top" ? "bottom" : "top"))
49
61
  }
50
62
 
63
+ // Enter/exit grow and shrink the panel's slot in the flex row: flexGrow
64
+ // carries the width (the panel is basis-0, so `width` itself is inert on
65
+ // the main axis) and minWidth releases the 320px floor so the slot can
66
+ // reach zero. scaleX pins the visual anchor to the panel's center —
67
+ // flexbox collapses a slot toward whichever edge its neighbors grow
68
+ // from, so without it the panel would wipe from one side.
69
+ const hidden = { flexGrow: 0, minWidth: 0, scaleX: 0, opacity: 0 }
70
+
51
71
  return (
52
72
  <motion.section
53
- id="scaffold-panel"
54
- animate={{ flexDirection: isSwapped ? "column-reverse" : "column" }}
73
+ id={id ?? "scaffold-panel"}
74
+ animate={{
75
+ flexGrow: 1,
76
+ // id panels opt into responsive hiding, so each visible one can
77
+ // hold the 320px floor — the canvas hides siblings before this
78
+ // floor would overflow it.
79
+ minWidth: id !== undefined ? SCAFFOLD_PANEL_MIN_WIDTH : 0,
80
+ scaleX: 1,
81
+ flexDirection: isSwapped ? "column-reverse" : "column",
82
+ // The hovered tab's panel keeps full opacity; the rest fade back
83
+ // so the tab↔panel pairing reads at a glance. Inline (not a
84
+ // class) because motion owns this element's opacity.
85
+ opacity: dimmed ? 0.35 : 1,
86
+ }}
87
+ initial={hidden}
88
+ exit={hidden}
89
+ style={{ originX: 0.5 }}
55
90
  aria-label={name}
56
91
  className={cn(
57
- "group/panel relative flex min-w-80 flex-1 flex-col",
92
+ "group/panel relative flex min-w-0 flex-1 flex-col w-full",
58
93
  className
59
94
  )}
60
- layout
95
+ layout={"size"}
61
96
  data-slot="scaffold-panel"
97
+ data-dimmed={dimmed ? "" : undefined}
62
98
  data-swapped={isSwapped ? "" : undefined}
63
99
  transition={transition}
64
100
  >
@@ -5,6 +5,7 @@ import * as React from "react"
5
5
  import { cn } from "@/lib/utils"
6
6
  import { SCAFFOLD_INSPECTOR_WIDTH } from "./constants"
7
7
  import { ScaffoldContext } from "./scaffold-context"
8
+ import { usePanelVisibility } from "./use-panel-visibility"
8
9
  import type { ScaffoldContextValue, ScaffoldRootProps } from "./type"
9
10
  import type { ClassNameValue } from "tailwind-merge"
10
11
 
@@ -41,9 +42,23 @@ export function ScaffoldRoot({
41
42
  [inspectorOpen, setInspectorOpen]
42
43
  )
43
44
 
45
+ const panelVisibility = usePanelVisibility()
46
+
44
47
  const value = React.useMemo<ScaffoldContextValue>(
45
- () => ({ inspectorOpen, inspectorWidth, setInspectorOpen, toggleInspector }),
46
- [inspectorOpen, inspectorWidth, setInspectorOpen, toggleInspector]
48
+ () => ({
49
+ inspectorOpen,
50
+ inspectorWidth,
51
+ setInspectorOpen,
52
+ toggleInspector,
53
+ ...panelVisibility,
54
+ }),
55
+ [
56
+ inspectorOpen,
57
+ inspectorWidth,
58
+ setInspectorOpen,
59
+ toggleInspector,
60
+ panelVisibility,
61
+ ]
47
62
  )
48
63
 
49
64
  return (
@@ -54,7 +69,7 @@ export function ScaffoldRoot({
54
69
  // `clip`, not `hidden`: the off canvas inspector extends the
55
70
  // scrollable overflow, and focus/scrollIntoView would scroll a
56
71
  // hidden-overflow root sideways to reveal it.
57
- "relative isolate flex h-full w-full flex-1 overflow-clip pr-2 pb-2",
72
+ "relative isolate flex size-full flex-1 overflow-clip pr-2 pb-2",
58
73
  background,
59
74
  className
60
75
  )}
@@ -19,7 +19,7 @@ export function ScaffoldSidebar({
19
19
  id="scaffold-sidebar"
20
20
  aria-label="Primary"
21
21
  className={cn(
22
- "flex min-w-12 shrink-0 flex-col items-center gap-1 pt-10 pb-2",
22
+ "flex h-full min-w-12 shrink-0 flex-col items-center gap-1 overflow-y-auto pt-10 pb-2",
23
23
  className
24
24
  )}
25
25
  data-slot="scaffold-sidebar"
@@ -5,10 +5,11 @@ import * as React from "react"
5
5
 
6
6
  import { cn } from "@/lib/utils"
7
7
  import { SPRING_LAYOUT } from "@/lib/ease.ts"
8
+ import { useScaffoldContext } from "./scaffold-context"
8
9
  import type { ScaffoldTabProps } from "./type"
9
10
  import { segmentVariants } from "./utils"
10
11
  import { Button } from "@/components/ui/button"
11
- import { LucideAppWindow, LucideX } from "lucide-react"
12
+ import { LucideAppWindow, LucideEyeOff, LucideX } from "lucide-react"
12
13
  import { GlassButtonGroup } from "@/components/ui/glasscn/glass-button-group.tsx"
13
14
 
14
15
  /**
@@ -16,25 +17,51 @@ import { GlassButtonGroup } from "@/components/ui/glasscn/glass-button-group.tsx
16
17
  * affordance. Render one per open panel as Actions children, keyed —
17
18
  * closes animate out of the pill. Omit `onClose` on the last remaining
18
19
  * panel's tab; the canvas keeps at least one panel open.
20
+ *
21
+ * With `panelId` (matching a `Scaffold.Panel`'s `id`), the tab also fronts
22
+ * that panel's visibility: a hidden panel dims its tab behind an eye-off
23
+ * icon, and pressing the label toggles the panel — showing one past
24
+ * capacity hides the least-recently-activated panel in its place.
19
25
  */
20
26
  export function ScaffoldTab({
21
27
  children,
28
+ panelId,
22
29
  onClose,
23
30
  closeLabel = "Close panel",
24
31
  sound = true,
25
32
  className,
26
33
  ...rest
27
34
  }: ScaffoldTabProps): React.ReactElement {
35
+ const { isPanelHidden, togglePanelVisibility, setPanelHovered } =
36
+ useScaffoldContext()
28
37
  const reduce = useReducedMotion()
29
38
  const transition = reduce ? { duration: 0 } : SPRING_LAYOUT
30
39
 
40
+ const hidden = panelId !== undefined && isPanelHidden(panelId)
41
+
42
+ // A tab closed mid-hover never fires pointer-leave — release the
43
+ // spotlight it holds so the canvas doesn't stay dimmed.
44
+ React.useEffect(() => {
45
+ if (panelId === undefined) return
46
+ return () => setPanelHovered(panelId, false)
47
+ }, [panelId, setPanelHovered])
48
+
31
49
  return (
32
50
  <GlassButtonGroup
33
51
  layout
34
- animate={reduce ? { opacity: 1 } : "visible"}
52
+ animate={reduce ? { opacity: 1, width: "auto" } : "visible"}
35
53
  data-slot="scaffold-tab"
36
- exit={reduce ? { opacity: 0 } : "exit"}
37
- initial={reduce ? { opacity: 0 } : "hidden"}
54
+ data-panel-hidden={hidden ? "" : undefined}
55
+ onPointerEnter={
56
+ panelId !== undefined ? () => setPanelHovered(panelId, true) : undefined
57
+ }
58
+ onPointerLeave={
59
+ panelId !== undefined
60
+ ? () => setPanelHovered(panelId, false)
61
+ : undefined
62
+ }
63
+ exit={reduce ? { opacity: 0, width: 0 } : "exit"}
64
+ initial={reduce ? { opacity: 0, width: 0 } : "hidden"}
38
65
  transition={transition}
39
66
  variants={segmentVariants}
40
67
  glassVariant="subtle"
@@ -42,11 +69,22 @@ export function ScaffoldTab({
42
69
  {...rest}
43
70
  >
44
71
  <Button
72
+ aria-pressed={panelId !== undefined ? !hidden : undefined}
73
+ onClick={
74
+ panelId !== undefined
75
+ ? () => togglePanelVisibility(panelId)
76
+ : undefined
77
+ }
45
78
  size="sm"
79
+ sound={sound}
46
80
  variant="ghost"
47
- className={cn("gap-2.5 rounded-sm pl-2", onClose && "pr-2")}
81
+ className={cn(
82
+ "gap-2.5 rounded-sm pl-2",
83
+ onClose && "pr-2",
84
+ hidden && "text-muted-foreground/70"
85
+ )}
48
86
  >
49
- <LucideAppWindow size={16} />
87
+ {hidden ? <LucideEyeOff size={16} /> : <LucideAppWindow size={16} />}
50
88
  {children}
51
89
  </Button>
52
90
  {onClose && (
@@ -56,7 +94,7 @@ export function ScaffoldTab({
56
94
  size="icon-sm"
57
95
  sound={sound}
58
96
  variant="ghost"
59
- className="rounded-none!"
97
+ className="self-center rounded-none!"
60
98
  >
61
99
  <LucideX size={16} />
62
100
  </Button>
@@ -8,6 +8,25 @@ export interface ScaffoldContextValue {
8
8
  inspectorWidth: number
9
9
  setInspectorOpen: (open: boolean) => void
10
10
  toggleInspector: () => void
11
+ /** How many panels currently fit side by side, from the measured canvas
12
+ * width at `SCAFFOLD_PANEL_MIN_WIDTH` per panel (1..`SCAFFOLD_PANEL_CAPACITY`). */
13
+ panelCapacity: number
14
+ /** Ids of id'd panels the scaffold is currently hiding — auto-hidden by
15
+ * capacity pressure or toggled away from a tab. */
16
+ hiddenPanelIds: readonly string[]
17
+ isPanelHidden: (id: string) => boolean
18
+ /** Show/hide an id'd panel. Showing past capacity auto-hides the
19
+ * least-recently-activated visible panel; the last visible one never hides. */
20
+ togglePanelVisibility: (id: string) => void
21
+ /** Id of the panel whose tab the pointer is resting on — the canvas
22
+ * spotlights it by fading every other id'd panel to 0.35 opacity. */
23
+ hoveredPanelId: string | null
24
+ /** @internal Tabs report pointer enter/leave on their label. */
25
+ setPanelHovered: (id: string, hovered: boolean) => void
26
+ /** @internal Canvas reports its ordered panel ids. */
27
+ registerPanelIds: (ids: readonly string[]) => void
28
+ /** @internal Canvas reports its measured width. */
29
+ setCanvasWidth: (width: number) => void
11
30
  }
12
31
 
13
32
  export interface ScaffoldRootProps extends ComponentProps<"div"> {
@@ -62,6 +81,11 @@ export type TScaffoldPanelChild<
62
81
  > = ReactElement<T, React.JSXElementConstructor<T>>
63
82
 
64
83
  export interface ScaffoldPanelProps {
84
+ /** Stable id opting the panel into responsive hiding: the canvas hides
85
+ * id'd panels when it can't grant each `SCAFFOLD_PANEL_MIN_WIDTH`, and a
86
+ * `Scaffold.Tab` with the matching `panelId` reflects and toggles it.
87
+ * Omitted, the panel always renders. */
88
+ id?: string
65
89
  /** The panel's content, rendered in a card with a drop shadow. */
66
90
  children: ReactNode
67
91
  /** The panel's content, rendered in a card with a drop shadow. */
@@ -88,6 +112,10 @@ export interface ScaffoldTabProps extends MotionSafe<
88
112
  > {
89
113
  /** The tab's label. */
90
114
  children?: ReactNode
115
+ /** Id of the panel this tab fronts (`Scaffold.Panel`'s `id`). The tab
116
+ * then shows whether the panel is hidden and pressing its label toggles
117
+ * visibility — showing past capacity hides another panel in its place. */
118
+ panelId?: string
91
119
  /** Renders the tab's close button. The button renders only while this
92
120
  * is present — omit it to pin the tab, e.g. on the last remaining
93
121
  * panel (the canvas keeps at least one open). */