@exegia/corpora-ui 0.22.0 → 0.23.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 (40) hide show
  1. package/dist-lib/components/blocks/auth/__tests__/auth-flow-block.test.d.ts +1 -0
  2. package/dist-lib/components/blocks/auth/auth-flow-block.d.ts +123 -0
  3. package/dist-lib/components/blocks/scaffold/__tests__/scaffold-atom.test.d.ts +1 -0
  4. package/dist-lib/components/blocks/scaffold/index.d.ts +2 -0
  5. package/dist-lib/components/blocks/scaffold/scaffold-atom.d.ts +97 -0
  6. package/dist-lib/components/blocks/scaffold/scaffold-context.d.ts +3 -1
  7. package/dist-lib/components/blocks/scaffold/scaffold-root.d.ts +12 -3
  8. package/dist-lib/components/blocks/scaffold/type.d.ts +58 -18
  9. package/dist-lib/components/blocks/scaffold/use-scaffold-state.d.ts +26 -0
  10. package/dist-lib/components/blocks/scaffold/use-scaffold.d.ts +7 -1
  11. package/dist-lib/components/blocks/scaffold/utils.d.ts +11 -1
  12. package/dist-lib/components/ui/switch.d.ts +4 -0
  13. package/dist-lib/index.d.ts +1 -0
  14. package/dist-lib/index.js +1383 -1196
  15. package/dist-lib/index.js.map +1 -1
  16. package/package.json +1 -1
  17. package/src/components/beste/piece/browser-frame.tsx +1 -1
  18. package/src/components/beste/piece/utils.ts +4 -4
  19. package/src/components/blocks/auth/__tests__/auth-flow-block.test.tsx +183 -0
  20. package/src/components/blocks/auth/auth-flow-block.tsx +296 -0
  21. package/src/components/blocks/scaffold/__tests__/scaffold-atom.test.tsx +210 -0
  22. package/src/components/blocks/scaffold/index.ts +15 -0
  23. package/src/components/blocks/scaffold/scaffold-actions.tsx +5 -1
  24. package/src/components/blocks/scaffold/scaffold-atom.ts +383 -0
  25. package/src/components/blocks/scaffold/scaffold-canvas.tsx +12 -4
  26. package/src/components/blocks/scaffold/scaffold-context.ts +3 -1
  27. package/src/components/blocks/scaffold/scaffold-inspector.tsx +9 -2
  28. package/src/components/blocks/scaffold/scaffold-panel.tsx +9 -5
  29. package/src/components/blocks/scaffold/scaffold-root.tsx +64 -32
  30. package/src/components/blocks/scaffold/scaffold-tab.tsx +13 -9
  31. package/src/components/blocks/scaffold/type.ts +64 -18
  32. package/src/components/blocks/scaffold/use-scaffold-state.ts +58 -0
  33. package/src/components/blocks/scaffold/use-scaffold.ts +30 -19
  34. package/src/components/blocks/scaffold/utils.ts +45 -1
  35. package/src/components/docs/component-preview.tsx +7 -2
  36. package/src/components/docs/demo-controls.tsx +32 -23
  37. package/src/components/ui/switch.tsx +30 -0
  38. package/src/index.ts +1 -0
  39. package/dist-lib/components/blocks/scaffold/use-panel-visibility.d.ts +0 -19
  40. package/src/components/blocks/scaffold/use-panel-visibility.ts +0 -180
@@ -3,8 +3,14 @@
3
3
  import { motion, useReducedMotion } from "motion/react"
4
4
  import * as React from "react"
5
5
 
6
+ import { useAtomValue, useSetAtom } from "jotai"
7
+
6
8
  import { cn } from "@/lib/utils"
7
9
  import { SCAFFOLD_EASE, SCAFFOLD_EDGE_GUTTER, SCAFFOLD_MORPH_DURATION } from "./constants"
10
+ import {
11
+ scaffoldInspectorOpenAtom,
12
+ toggleScaffoldInspectorAtom,
13
+ } from "./scaffold-atom"
8
14
  import { useScaffoldContext } from "./scaffold-context"
9
15
  import type { ScaffoldInspectorProps } from "./type"
10
16
  import { GlassContainer } from "@/components/ui/glasscn/glass-container.tsx"
@@ -23,8 +29,9 @@ export function ScaffoldInspector({
23
29
  className,
24
30
  ...rest
25
31
  }: ScaffoldInspectorProps): React.ReactElement {
26
- const { inspectorOpen, inspectorWidth, toggleInspector } =
27
- useScaffoldContext()
32
+ const { scaffoldId, inspectorWidth } = useScaffoldContext()
33
+ const inspectorOpen = useAtomValue(scaffoldInspectorOpenAtom(scaffoldId))
34
+ const toggleInspector = useSetAtom(toggleScaffoldInspectorAtom(scaffoldId))
28
35
  const reducedMotion = useReducedMotion()
29
36
 
30
37
  // Slide fully past the edge gutter and the drop shadow's blur radius.
@@ -10,7 +10,10 @@ import {
10
10
  SCAFFOLD_MORPH_DURATION,
11
11
  SCAFFOLD_PANEL_MIN_WIDTH,
12
12
  } from "./constants"
13
+ import { useAtomValue } from "jotai"
14
+
13
15
  import { PanelMenuButton } from "./panel-menu-button.tsx"
16
+ import { scaffoldPanelDimmedAtom } from "./scaffold-atom"
14
17
  import { useScaffoldContext } from "./scaffold-context"
15
18
  import type { ScaffoldPanelProps, TSubPanelPosition } from "./type"
16
19
  import { ScaffoldSubPanel } from "@/components/blocks/scaffold/scaffold-sub-panel.tsx"
@@ -33,13 +36,14 @@ export function ScaffoldPanel({
33
36
  sound = true,
34
37
  className,
35
38
  }: ScaffoldPanelProps): React.ReactElement {
36
- const { hoveredPanelId } = useScaffoldContext()
39
+ const { scaffoldId } = useScaffoldContext()
37
40
  const reducedMotion = useReducedMotion()
38
41
 
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
42
+ // A hovered tab spotlights its own panel — every other id'd panel fades
43
+ // back so the pairing reads at a glance. The per-panel atom keeps a hover
44
+ // moving between two tabs from re-rendering the panels that stay dimmed;
45
+ // the empty-key sentinel reads false for a panel without an `id`.
46
+ const dimmed = useAtomValue(scaffoldPanelDimmedAtom(scaffoldId, id ?? ""))
43
47
 
44
48
  const transition = {
45
49
  duration: reducedMotion ? 0 : SCAFFOLD_MORPH_DURATION,
@@ -1,20 +1,41 @@
1
1
  "use client"
2
2
 
3
3
  import * as React from "react"
4
+ import { useSetAtom } from "jotai"
4
5
 
5
6
  import { cn } from "@/lib/utils"
6
7
  import { SCAFFOLD_INSPECTOR_WIDTH } from "./constants"
8
+ import {
9
+ projectScaffoldPropsAtom,
10
+ removeScaffoldInstance,
11
+ seedScaffoldInspectorAtom,
12
+ setScaffoldHandlersAtom,
13
+ } from "./scaffold-atom"
7
14
  import { ScaffoldContext } from "./scaffold-context"
8
- import { usePanelVisibility } from "./use-panel-visibility"
9
- import type { ScaffoldContextValue, ScaffoldRootProps } from "./type"
15
+ import type {
16
+ ScaffoldConfig,
17
+ ScaffoldContextValue,
18
+ ScaffoldHandlers,
19
+ ScaffoldRootProps,
20
+ } from "./type"
10
21
  import type { ClassNameValue } from "tailwind-merge"
11
22
 
12
23
  /**
13
24
  * The scaffold's viewport: desktop backdrop + horizontal row of rail and
14
- * main region, and the provider for the shared inspector state. Fills its
15
- * container — size it from the outside (e.g. `h-svh` for a full page).
25
+ * main region, and the binding between this instance's props and its slice
26
+ * of the store. Fills its container — size it from the outside (e.g.
27
+ * `h-svh` for a full page).
28
+ *
29
+ * State lives in the store under `scaffoldId`; the context carries only the
30
+ * id and the drawer width, so its value never changes while the scaffold is
31
+ * mounted and parts subscribe to exactly the atoms they render from. A
32
+ * controlled `inspectorOpen` stays the source of truth: it is projected
33
+ * one-way into the store (so remote readers see current data), and the
34
+ * inspector actions report through `onInspectorOpenChange` instead of
35
+ * writing.
16
36
  */
17
37
  export function ScaffoldRoot({
38
+ scaffoldId: scaffoldIdProp,
18
39
  inspectorOpen: inspectorOpenProp,
19
40
  defaultInspectorOpen,
20
41
  onInspectorOpenChange,
@@ -23,42 +44,53 @@ export function ScaffoldRoot({
23
44
  children,
24
45
  ...rest
25
46
  }: ScaffoldRootProps): React.ReactElement {
26
- const [uncontrolledOpen, setUncontrolledOpen] = React.useState(
27
- defaultInspectorOpen ?? false
28
- )
29
- const inspectorOpen = inspectorOpenProp ?? uncontrolledOpen
47
+ const generatedId = React.useId()
48
+ const scaffoldId = scaffoldIdProp ?? generatedId
30
49
  const background: ClassNameValue = `bg-linear-to-tr/increasing from-neutral-200 via-neutral-100 to-stone-200 dark:from-neutral-900 dark:via-neutral-950 dark:to-stone-950`
31
50
 
32
- const setInspectorOpen = React.useCallback(
33
- (open: boolean) => {
34
- if (inspectorOpenProp === undefined) setUncontrolledOpen(open)
35
- onInspectorOpenChange?.(open)
36
- },
37
- [inspectorOpenProp, onInspectorOpenChange]
51
+ const controlsInspector = inspectorOpenProp !== undefined
52
+ const config = React.useMemo<ScaffoldConfig>(
53
+ () => ({ controlsInspector }),
54
+ [controlsInspector]
55
+ )
56
+ const handlers = React.useMemo<ScaffoldHandlers>(
57
+ () => ({ onInspectorOpenChange }),
58
+ [onInspectorOpenChange]
38
59
  )
39
60
 
40
- const toggleInspector = React.useCallback(
41
- () => setInspectorOpen(!inspectorOpen),
42
- [inspectorOpen, setInspectorOpen]
61
+ const project = useSetAtom(projectScaffoldPropsAtom(scaffoldId))
62
+ const publishHandlers = useSetAtom(setScaffoldHandlersAtom(scaffoldId))
63
+ const seedInspector = useSetAtom(seedScaffoldInspectorAtom(scaffoldId))
64
+
65
+ // Seed the uncontrolled inspector once per mount, before the projection —
66
+ // `defaultInspectorOpen` describes the mount, not every render. Layout
67
+ // effects so children read settled values in the same commit; primitive
68
+ // deps, so no loop guard is needed.
69
+ const [seed] = React.useState(
70
+ () => inspectorOpenProp ?? defaultInspectorOpen ?? false
43
71
  )
72
+ React.useLayoutEffect(() => {
73
+ if (!controlsInspector) seedInspector(seed)
74
+ // Mount-time seed only.
75
+ // eslint-disable-next-line react-hooks/exhaustive-deps
76
+ }, [])
77
+ // Handlers before the projection — they must be in the store before any
78
+ // action can fire.
79
+ React.useLayoutEffect(() => {
80
+ publishHandlers(handlers)
81
+ }, [publishHandlers, handlers])
82
+ React.useLayoutEffect(() => {
83
+ project(config, inspectorOpenProp)
84
+ }, [project, config, inspectorOpenProp])
44
85
 
45
- const panelVisibility = usePanelVisibility()
86
+ React.useEffect(() => {
87
+ if (scaffoldIdProp !== undefined) return
88
+ return () => removeScaffoldInstance(scaffoldId)
89
+ }, [scaffoldIdProp, scaffoldId])
46
90
 
47
91
  const value = React.useMemo<ScaffoldContextValue>(
48
- () => ({
49
- inspectorOpen,
50
- inspectorWidth,
51
- setInspectorOpen,
52
- toggleInspector,
53
- ...panelVisibility,
54
- }),
55
- [
56
- inspectorOpen,
57
- inspectorWidth,
58
- setInspectorOpen,
59
- toggleInspector,
60
- panelVisibility,
61
- ]
92
+ () => ({ scaffoldId, inspectorWidth }),
93
+ [scaffoldId, inspectorWidth]
62
94
  )
63
95
 
64
96
  return (
@@ -2,9 +2,15 @@
2
2
 
3
3
  import { useReducedMotion } from "motion/react"
4
4
  import * as React from "react"
5
+ import { useAtomValue, useSetAtom } from "jotai"
5
6
 
6
7
  import { cn } from "@/lib/utils"
7
8
  import { SPRING_LAYOUT } from "@/lib/ease.ts"
9
+ import {
10
+ scaffoldPanelHiddenAtom,
11
+ setScaffoldPanelHoveredAtom,
12
+ toggleScaffoldPanelAtom,
13
+ } from "./scaffold-atom"
8
14
  import { useScaffoldContext } from "./scaffold-context"
9
15
  import type { ScaffoldTabProps } from "./type"
10
16
  import { segmentVariants } from "./utils"
@@ -32,13 +38,15 @@ export function ScaffoldTab({
32
38
  className,
33
39
  ...rest
34
40
  }: ScaffoldTabProps): React.ReactElement {
35
- const { isPanelHidden, togglePanelVisibility, setPanelHovered } =
36
- useScaffoldContext()
41
+ const { scaffoldId } = useScaffoldContext()
42
+ // The per-panel atom keeps this tab still while its siblings toggle; the
43
+ // empty-key sentinel reads false for a tab without a `panelId`.
44
+ const hidden = useAtomValue(scaffoldPanelHiddenAtom(scaffoldId, panelId ?? ""))
45
+ const togglePanel = useSetAtom(toggleScaffoldPanelAtom(scaffoldId))
46
+ const setPanelHovered = useSetAtom(setScaffoldPanelHoveredAtom(scaffoldId))
37
47
  const reduce = useReducedMotion()
38
48
  const transition = reduce ? { duration: 0 } : SPRING_LAYOUT
39
49
 
40
- const hidden = panelId !== undefined && isPanelHidden(panelId)
41
-
42
50
  // A tab closed mid-hover never fires pointer-leave — release the
43
51
  // spotlight it holds so the canvas doesn't stay dimmed.
44
52
  React.useEffect(() => {
@@ -70,11 +78,7 @@ export function ScaffoldTab({
70
78
  >
71
79
  <Button
72
80
  aria-pressed={panelId !== undefined ? !hidden : undefined}
73
- onClick={
74
- panelId !== undefined
75
- ? () => togglePanelVisibility(panelId)
76
- : undefined
77
- }
81
+ onClick={panelId !== undefined ? () => togglePanel(panelId) : undefined}
78
82
  size="sm"
79
83
  sound={sound}
80
84
  variant="ghost"
@@ -1,37 +1,77 @@
1
1
  import React, { type ComponentProps, type ReactElement, type ReactNode, } from "react"
2
2
 
3
- /** Inspector state shared by every scaffold part through ScaffoldContext. */
3
+ /** Key of one scaffold's slice of the store. */
4
+ export type ScaffoldInstanceId = string
5
+
6
+ /** The identity every scaffold part shares through ScaffoldContext. State
7
+ * itself lives in the store — parts subscribe to the atoms they render from,
8
+ * so this value never changes identity while the scaffold is mounted. */
4
9
  export interface ScaffoldContextValue {
5
- /** Whether the inspector drawer is currently shown. */
6
- inspectorOpen: boolean
10
+ scaffoldId: ScaffoldInstanceId
7
11
  /** Drawer width in px — Actions reads it to slide out of the drawer's way. */
8
12
  inspectorWidth: number
9
- setInspectorOpen: (open: boolean) => void
10
- toggleInspector: () => void
13
+ }
14
+
15
+ /** One scaffold's whole state, as `useScaffoldState` returns it. A component
16
+ * that watches one field should subscribe to that field's atom instead. */
17
+ export interface ScaffoldState {
18
+ /** Whether the inspector drawer is currently shown. */
19
+ inspectorOpen: boolean
11
20
  /** How many panels currently fit side by side, from the measured canvas
12
21
  * width at `SCAFFOLD_PANEL_MIN_WIDTH` per panel (1..`SCAFFOLD_PANEL_CAPACITY`). */
13
22
  panelCapacity: number
14
23
  /** Ids of id'd panels the scaffold is currently hiding — auto-hidden by
15
24
  * capacity pressure or toggled away from a tab. */
16
25
  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
26
  /** Id of the panel whose tab the pointer is resting on — the canvas
22
27
  * spotlights it by fading every other id'd panel to 0.35 opacity. */
23
28
  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
29
+ }
30
+
31
+ /** Drive a scaffold by id from anywhere, as `useScaffoldActions` returns it. */
32
+ export interface ScaffoldStateActions {
33
+ setInspectorOpen: (open: boolean) => void
34
+ toggleInspector: () => void
35
+ /** Show/hide an id'd panel. Showing past capacity auto-hides the
36
+ * least-recently-activated visible panel; the last visible one never hides. */
37
+ togglePanel: (panelId: string) => void
38
+ reset: () => void
39
+ }
40
+
41
+ /** @internal Which props currently control the scaffold — write gates for
42
+ * the action atoms, so the store never overwrites a prop. */
43
+ export interface ScaffoldConfig {
44
+ controlsInspector: boolean
45
+ }
46
+
47
+ /** @internal The mounted root's callbacks, published into the store so
48
+ * action atoms can report changes wherever they were fired from. */
49
+ export interface ScaffoldHandlers {
50
+ onInspectorOpenChange?: (open: boolean) => void
51
+ }
52
+
53
+ /** @internal Bookkeeping for responsive panel hiding.
54
+ * - `visibleOrder`: visible panel ids, least-recently-activated first — the
55
+ * front is what capacity pressure evicts next.
56
+ * - `autoHidden`: panels the scaffold hid because the canvas shrank, most
57
+ * recent last. They return on their own when room comes back.
58
+ * - `userHidden`: panels hidden by a tab press. They stay hidden until the
59
+ * tab is pressed again, however wide the canvas grows. */
60
+ export interface ScaffoldPanelVisibility {
61
+ visibleOrder: string[]
62
+ autoHidden: string[]
63
+ userHidden: string[]
30
64
  }
31
65
 
32
66
  export interface ScaffoldRootProps extends ComponentProps<"div"> {
33
- /** Controlled inspector state — pair with `onInspectorOpenChange`, or
34
- * spread `useScaffold().providerProps` instead of wiring by hand. */
67
+ /** Names this scaffold's slice of the store, so `useScaffoldState` /
68
+ * `useScaffoldActions` can drive it by id and its state outlives the
69
+ * component. Omitted, the root keys off `useId` and drops its state on
70
+ * unmount. `useScaffold().providerProps` carries one. */
71
+ scaffoldId?: ScaffoldInstanceId
72
+ /** Controlled inspector state — pair with `onInspectorOpenChange`. The
73
+ * prop stays the source of truth: store writes are gated off, and actions
74
+ * report through the callback instead. */
35
75
  inspectorOpen?: boolean
36
76
  /** Initial inspector state when uncontrolled. Closed by default. */
37
77
  defaultInspectorOpen?: boolean
@@ -182,6 +222,9 @@ export interface PanelMenuButtonProps extends PanelFloatingButtonProps {
182
222
  }
183
223
 
184
224
  export interface UseScaffoldOptions {
225
+ /** Names the scaffold's slice of the store. Omitted, the hook generates
226
+ * one and drops its state on unmount. */
227
+ scaffoldId?: ScaffoldInstanceId
185
228
  /** Initial inspector state. Closed by default. */
186
229
  defaultInspectorOpen?: boolean
187
230
  /** Fires on every inspector open/close. */
@@ -189,12 +232,15 @@ export interface UseScaffoldOptions {
189
232
  }
190
233
 
191
234
  export interface ScaffoldControls {
235
+ /** The id the hook and the root share — hand it to `useScaffoldState` /
236
+ * `useScaffoldActions` to drive the scaffold from elsewhere. */
237
+ scaffoldId: ScaffoldInstanceId
192
238
  inspectorOpen: boolean
193
239
  setInspectorOpen: (open: boolean) => void
194
240
  toggleInspector: () => void
195
241
  /** Spread onto Scaffold.Root. */
196
242
  providerProps: Pick<
197
243
  ScaffoldRootProps,
198
- "inspectorOpen" | "onInspectorOpenChange"
244
+ "scaffoldId" | "defaultInspectorOpen" | "onInspectorOpenChange"
199
245
  >
200
246
  }
@@ -0,0 +1,58 @@
1
+ "use client"
2
+
3
+ import { useMemo } from "react"
4
+ import { useAtomValue, useSetAtom } from "jotai"
5
+
6
+ import {
7
+ resetScaffoldAtom,
8
+ scaffoldStateAtom,
9
+ setScaffoldInspectorOpenAtom,
10
+ toggleScaffoldInspectorAtom,
11
+ toggleScaffoldPanelAtom,
12
+ } from "./scaffold-atom"
13
+ import type {
14
+ ScaffoldInstanceId,
15
+ ScaffoldState,
16
+ ScaffoldStateActions,
17
+ } from "./type"
18
+
19
+ /**
20
+ * Read the scaffold registered under `scaffoldId` from anywhere below
21
+ * `ExegiaProvider` — no controller, no props, no provider of its own.
22
+ *
23
+ * ```tsx
24
+ * const { inspectorOpen, hiddenPanelIds } = useScaffoldState("workspace")
25
+ * ```
26
+ *
27
+ * This returns the whole state object, so the caller re-renders on every
28
+ * change, hovers included. A component that reads one field should subscribe
29
+ * to that field's atom instead:
30
+ * `useAtomValue(scaffoldInspectorOpenAtom("workspace"))`.
31
+ */
32
+ export function useScaffoldState(scaffoldId: ScaffoldInstanceId): ScaffoldState {
33
+ return useAtomValue(scaffoldStateAtom(scaffoldId))
34
+ }
35
+
36
+ /**
37
+ * Drive the scaffold registered under `scaffoldId` from anywhere. Writes
38
+ * only — the caller never re-renders when the scaffold changes, so this is
39
+ * what a command palette or a keyboard shortcut should reach for.
40
+ *
41
+ * ```tsx
42
+ * const scaffold = useScaffoldActions("workspace")
43
+ * <Button onClick={scaffold.toggleInspector}>Inspect</Button>
44
+ * ```
45
+ */
46
+ export function useScaffoldActions(
47
+ scaffoldId: ScaffoldInstanceId
48
+ ): ScaffoldStateActions {
49
+ const setInspectorOpen = useSetAtom(setScaffoldInspectorOpenAtom(scaffoldId))
50
+ const toggleInspector = useSetAtom(toggleScaffoldInspectorAtom(scaffoldId))
51
+ const togglePanel = useSetAtom(toggleScaffoldPanelAtom(scaffoldId))
52
+ const reset = useSetAtom(resetScaffoldAtom(scaffoldId))
53
+
54
+ return useMemo(
55
+ () => ({ setInspectorOpen, toggleInspector, togglePanel, reset }),
56
+ [setInspectorOpen, toggleInspector, togglePanel, reset]
57
+ )
58
+ }
@@ -1,7 +1,14 @@
1
1
  "use client"
2
2
 
3
- import { useCallback, useMemo, useState } from "react"
3
+ import { useEffect, useId, useMemo } from "react"
4
+ import { useAtomValue, useSetAtom } from "jotai"
4
5
 
6
+ import {
7
+ removeScaffoldInstance,
8
+ scaffoldInspectorOpenAtom,
9
+ setScaffoldInspectorOpenAtom,
10
+ toggleScaffoldInspectorAtom,
11
+ } from "./scaffold-atom"
5
12
  import type { ScaffoldControls, UseScaffoldOptions } from "./type"
6
13
 
7
14
  /**
@@ -9,37 +16,41 @@ import type { ScaffoldControls, UseScaffoldOptions } from "./type"
9
16
  * (title-bar buttons, command palette, shortcuts). Spread the returned
10
17
  * `providerProps` onto `Scaffold.Root`; without this hook the root manages
11
18
  * the same state internally via `defaultInspectorOpen`.
19
+ *
20
+ * The hook and the root meet in the store: `providerProps` carries a
21
+ * `scaffoldId`, both sides read and write that instance's atoms, and
22
+ * `useScaffoldState` / `useScaffoldActions` reach the same slice from
23
+ * anywhere else by id. Unnamed scaffolds key off `useId` and are dropped on
24
+ * unmount; an explicit `scaffoldId` outlives its component.
12
25
  */
13
26
  export function useScaffold({
27
+ scaffoldId: scaffoldIdProp,
14
28
  defaultInspectorOpen,
15
29
  onInspectorChange,
16
30
  }: UseScaffoldOptions = {}): ScaffoldControls {
17
- const [inspectorOpen, setOpenState] = useState(defaultInspectorOpen ?? false)
31
+ const generatedId = useId()
32
+ const scaffoldId = scaffoldIdProp ?? generatedId
18
33
 
19
- const setInspectorOpen = useCallback(
20
- (open: boolean) => {
21
- setOpenState((prev) => {
22
- if (prev !== open) onInspectorChange?.(open)
23
- return open
24
- })
25
- },
26
- [onInspectorChange]
27
- )
34
+ const inspectorOpen = useAtomValue(scaffoldInspectorOpenAtom(scaffoldId))
35
+ const setInspectorOpen = useSetAtom(setScaffoldInspectorOpenAtom(scaffoldId))
36
+ const toggleInspector = useSetAtom(toggleScaffoldInspectorAtom(scaffoldId))
28
37
 
29
- const toggleInspector = useCallback(
30
- () => setInspectorOpen(!inspectorOpen),
31
- [inspectorOpen, setInspectorOpen]
32
- )
38
+ useEffect(() => {
39
+ if (scaffoldIdProp !== undefined) return
40
+ return () => removeScaffoldInstance(scaffoldId)
41
+ }, [scaffoldIdProp, scaffoldId])
33
42
 
34
- const providerProps = useMemo(
43
+ const providerProps = useMemo<ScaffoldControls["providerProps"]>(
35
44
  () => ({
36
- inspectorOpen,
37
- onInspectorOpenChange: setInspectorOpen,
45
+ scaffoldId,
46
+ defaultInspectorOpen,
47
+ onInspectorOpenChange: onInspectorChange,
38
48
  }),
39
- [inspectorOpen, setInspectorOpen]
49
+ [scaffoldId, defaultInspectorOpen, onInspectorChange]
40
50
  )
41
51
 
42
52
  return {
53
+ scaffoldId,
43
54
  inspectorOpen,
44
55
  setInspectorOpen,
45
56
  toggleInspector,
@@ -1,6 +1,9 @@
1
1
  import type { Variants } from "motion/react"
2
2
  import type { ClassNameValue } from "tailwind-merge"
3
- import type { TSubPanelVariant } from "@/components/blocks/scaffold/type.ts"
3
+ import type {
4
+ ScaffoldPanelVisibility,
5
+ TSubPanelVariant,
6
+ } from "@/components/blocks/scaffold/type.ts"
4
7
  import { EASE_OUT } from "@/lib/ease.ts"
5
8
  import {
6
9
  SCAFFOLD_CANVAS_GAP,
@@ -22,6 +25,47 @@ export function getPanelCapacity(width: number | null): number {
22
25
  return Math.max(1, Math.min(SCAFFOLD_PANEL_CAPACITY, fits))
23
26
  }
24
27
 
28
+ /** @internal An untracked scaffold: nothing registered, nothing hidden. */
29
+ export const EMPTY_SCAFFOLD_VISIBILITY: ScaffoldPanelVisibility = {
30
+ visibleOrder: [],
31
+ autoHidden: [],
32
+ userHidden: [],
33
+ }
34
+
35
+ /**
36
+ * @internal Fold the registered ids and current capacity into a settled
37
+ * visibility: prune departed panels, admit new ones, evict the
38
+ * least-recently-activated visible panels past capacity, and restore
39
+ * auto-hidden ones when room returns. Returns `prev` untouched when nothing
40
+ * changes, so callers can compare identities instead of contents.
41
+ */
42
+ export function reconcileVisibility(
43
+ prev: ScaffoldPanelVisibility,
44
+ ids: readonly string[],
45
+ capacity: number
46
+ ): ScaffoldPanelVisibility {
47
+ const known = new Set(ids)
48
+ const visibleOrder = prev.visibleOrder.filter((id) => known.has(id))
49
+ const autoHidden = prev.autoHidden.filter((id) => known.has(id))
50
+ const userHidden = prev.userHidden.filter((id) => known.has(id))
51
+
52
+ const tracked = new Set([...visibleOrder, ...autoHidden, ...userHidden])
53
+ for (const id of ids) if (!tracked.has(id)) visibleOrder.push(id)
54
+
55
+ while (visibleOrder.length > capacity && visibleOrder.length > 1) {
56
+ autoHidden.push(visibleOrder.shift() as string)
57
+ }
58
+ while (visibleOrder.length < capacity && autoHidden.length > 0) {
59
+ visibleOrder.push(autoHidden.pop() as string)
60
+ }
61
+
62
+ const unchanged =
63
+ visibleOrder.join(" ") === prev.visibleOrder.join(" ") &&
64
+ autoHidden.join(" ") === prev.autoHidden.join(" ") &&
65
+ userHidden.join(" ") === prev.userHidden.join(" ")
66
+ return unchanged ? prev : { visibleOrder, autoHidden, userHidden }
67
+ }
68
+
25
69
  /** Desktop backdrop the whole scaffold sits on — a soft warm-gray wash. */
26
70
  export const scaffoldBackgroundClass: ClassNameValue =
27
71
  "bg-linear-to-tr/increasing from-neutral-400 via-stone-200 to-neutral-300 dark:from-neutral-900 dark:via-neutral-950 dark:to-stone-950"
@@ -12,12 +12,17 @@ export function ComponentPreview({ entry }: { entry: RegistryEntry }) {
12
12
  const Demo = entry.preview
13
13
 
14
14
  return (
15
- <BrowserFrame title={entry.name} titleStyle="hidden" className="min-h-60">
15
+ <BrowserFrame
16
+ title={entry.name}
17
+ titleStyle={entry.titleStyle ?? "expanded"}
18
+ >
16
19
  {Demo ? (
17
20
  <React.Suspense
18
21
  fallback={<p className="text-sm text-muted-foreground">Loading…</p>}
19
22
  >
20
- <Demo />
23
+ <div className="relative h-full min-h-96 w-full">
24
+ <Demo />
25
+ </div>
21
26
  </React.Suspense>
22
27
  ) : (
23
28
  <p className="text-sm text-muted-foreground">
@@ -5,9 +5,11 @@ import {
5
5
  SelectItem,
6
6
  SelectTrigger,
7
7
  SelectValue,
8
- SelectContent} from "@/components/ui/select"
8
+ SelectContent,
9
+ } from "@/components/ui/select"
9
10
  import { Field, FieldLabel } from "@/components/ui/field"
10
- import { Card , CardContent} from "@/components/ui/card";
11
+ import { Card, CardContent } from "@/components/ui/card"
12
+ import { Switch } from "../ui/switch"
11
13
 
12
14
  /**
13
15
  * Lightweight playground controls for registry demos — docs-only, never
@@ -28,22 +30,25 @@ export function DemoSelect<T extends string>({
28
30
  const items = options.map((option) => ({ label: option, value: option }))
29
31
 
30
32
  return (
31
- <Field className="flex flex-col gap-1 text-xs text-muted-foreground">
32
- {label && <FieldLabel>{label}</FieldLabel>}
33
+ <Field className="ml-0 flex w-full flex-1 flex-row items-center justify-between text-muted-foreground">
34
+ {label && <FieldLabel className="ml-0 capitalize">{label}</FieldLabel>}
33
35
  <Select
34
36
  items={items}
35
37
  onValueChange={(next) => onChange(next as T)}
36
38
  value={value}
37
39
  >
38
- <SelectTrigger className="h-7 min-w-0 text-xs capitalize rounded-sm " size="sm">
40
+ <SelectTrigger
41
+ className="h-7 min-w-0 rounded-sm text-xs capitalize"
42
+ size="sm"
43
+ >
39
44
  <SelectValue />
40
45
  </SelectTrigger>
41
46
  <SelectContent>
42
- {items.map((item) => (
43
- <SelectItem key={item.value} value={item.value}>
44
- {item.label}
45
- </SelectItem>
46
- ))}
47
+ {items.map((item) => (
48
+ <SelectItem key={item.value} value={item.value}>
49
+ {item.label}
50
+ </SelectItem>
51
+ ))}
47
52
  </SelectContent>
48
53
  </Select>
49
54
  </Field>
@@ -95,14 +100,15 @@ export function DemoToggle({
95
100
  onChange: (checked: boolean) => void
96
101
  }) {
97
102
  return (
98
- <label className="flex items-center gap-1.5 text-xs text-muted-foreground">
99
- <input
100
- type="checkbox"
101
- checked={checked}
102
- onChange={(event) => onChange(event.target.checked)}
103
- />
104
- {label}
105
- </label>
103
+ <Field className="ml-0 w-full">
104
+ <FieldLabel className="ml-0 w-full justify-between capitalize">
105
+ {label}
106
+ <Switch
107
+ checked={checked}
108
+ onCheckedChange={(value) => onChange(value)}
109
+ />
110
+ </FieldLabel>
111
+ </Field>
106
112
  )
107
113
  }
108
114
 
@@ -118,13 +124,16 @@ export function DemoStage({
118
124
  }) {
119
125
  return (
120
126
  <div className="relative flex w-full flex-col items-center">
121
- {controls && <Card className="absolute top-3 right-3 z-20 rounded-xl shadow-lg">
122
- <CardContent className="flex flex-row items-center gap-x-2">{controls}</CardContent>
123
- </Card>}
127
+ {controls && (
128
+ <Card className="absolute top-3 right-3 z-20 min-w-48 rounded-lg shadow-lg">
129
+ <CardContent className="flex flex-col items-start gap-2">
130
+ {controls}
131
+ </CardContent>
132
+ </Card>
133
+ )}
124
134
  <div
125
135
  className={
126
- canvasClassName ??
127
- "flex min-h-24 max-h-32 w-full items-center justify-center rounded-lg"
136
+ canvasClassName ?? "flex min-h-24 w-full items-center justify-center"
128
137
  }
129
138
  >
130
139
  {children}