From e9a46961f56a5caba53f49d2d6ef0eb8403ce5d9 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sun, 20 Sep 2026 23:24:11 -0700 Subject: [PATCH 1/2] refactor!: prune modules unreachable from any known consumer A usage audit across workbench, portal, scout, and solutions-builder found 84 public modules (plus their tests, stories, and internal helpers) that no consumer imports. Delete them rather than carry them as semver promises. Removes the entire DataPort seam (data-port, tanstack-data-port, use-collection-state) and its dependents, the now-inbox, scheduling, workflow-registry, step-graph, canvas-host, and chat-dock surfaces, plus unused primitives and their orphaned keyframes. @tanstack/react-query drops as a peer; the dep-guard rule and barrel exclusion that existed only for it are simplified accordingly. BREAKING CHANGE: 84 public subpaths removed; @tanstack/react-query peer dependency removed --- ARCHITECTURE.md | 144 ++----- CHANGELOG.md | 37 +- CONTRIBUTING.md | 20 +- IMPLEMENTATION.md | 23 +- PRODUCT.md | 7 +- README.md | 35 +- bun.lock | 9 - package.json | 339 --------------- scripts/dep-guard.mjs | 28 +- scripts/generate-exports.mjs | 9 +- src/blocks/access-notice/access-notice.tsx | 46 --- src/blocks/canvas-host/canvas-host-chrome.tsx | 90 ---- src/blocks/canvas-host/canvas-host.test.tsx | 390 ------------------ src/blocks/canvas-host/canvas-host.tsx | 203 --------- src/hooks/use-anchored-scroll.test.tsx | 193 --------- src/hooks/use-anchored-scroll.ts | 78 ---- src/hooks/use-chat-dock.test.tsx | 141 ------- src/hooks/use-chat-dock.ts | 104 ----- src/hooks/use-collection-state.ts | 38 -- src/hooks/use-flip-transition.test.tsx | 204 --------- src/hooks/use-flip-transition.ts | 58 --- src/hooks/use-focus-trap.test.tsx | 236 ----------- src/hooks/use-focus-trap.ts | 76 ---- src/hooks/use-render-rail.test.tsx | 74 ---- src/hooks/use-render-rail.ts | 71 ---- src/hooks/use-resizable-rail.test.ts | 115 ------ src/hooks/use-resizable-rail.ts | 180 -------- src/hooks/use-sidebar-panel.ts | 77 ---- src/hooks/use-view-mode.test.ts | 84 ---- src/hooks/use-view-mode.ts | 51 --- src/index.ts | 83 ---- src/lib/activity.ts | 75 ---- src/lib/canvas-host-state.ts | 61 --- src/lib/command-registry.tsx | 96 ----- src/lib/data-port.ts | 71 ---- src/lib/generative-block.ts | 70 ---- src/lib/schedule.ts | 64 --- src/lib/step-graph-layout.ts | 56 --- src/lib/tanstack-data-port.ts | 37 -- src/lib/time-range.ts | 64 --- src/theme.css | 66 +-- src/ui/activity-timeline.tsx | 120 ------ src/ui/actor-summary.tsx | 104 ----- src/ui/add-artifact-dialog.tsx | 203 --------- src/ui/artifact-detail.tsx | 74 ---- src/ui/artifact-gallery.tsx | 205 --------- src/ui/catalog-glyph.tsx | 78 ---- src/ui/chat-dock-fab.test.tsx | 61 --- src/ui/chat-dock-modality.test.tsx | 137 ------ src/ui/chat-dock.tsx | 194 --------- src/ui/command-queue.tsx | 147 ------- src/ui/context-strip.tsx | 161 -------- src/ui/count-table.tsx | 77 ---- src/ui/dashboard-section.tsx | 58 --- src/ui/dashboard.tsx | 88 ---- src/ui/data-table.tsx | 86 ---- src/ui/delta-badge.tsx | 58 --- src/ui/dial.tsx | 83 ---- src/ui/dither-background.test.tsx | 210 ---------- src/ui/dither-background.tsx | 281 ------------- src/ui/failed-run-notice.tsx | 61 --- src/ui/generative-block-view.tsx | 126 ------ src/ui/heatmap.tsx | 51 --- src/ui/kind-picker.tsx | 119 ------ src/ui/library-search-input.tsx | 77 ---- src/ui/live-run-banner.tsx | 4 +- src/ui/live-run-header.tsx | 44 -- src/ui/live-run-inspector.tsx | 97 ----- src/ui/mail-detail.tsx | 70 ---- src/ui/managed-list.tsx | 82 ---- src/ui/message-list.test.tsx | 40 -- src/ui/message-list.tsx | 34 -- src/ui/moment-walker.tsx | 155 ------- src/ui/onboarding-tour.tsx | 195 --------- src/ui/pagination.tsx | 61 --- src/ui/pulsing-ring.test.tsx | 21 - src/ui/pulsing-ring.tsx | 31 -- src/ui/radio-group.test.tsx | 153 ------- src/ui/radio-group.tsx | 126 ------ src/ui/read-block.tsx | 25 -- src/ui/reconnecting-overlay.tsx | 39 -- src/ui/recurrence-input.tsx | 125 ------ src/ui/render-rail.tsx | 102 ----- src/ui/run-once-flow.tsx | 77 ---- src/ui/schedule-flow.tsx | 114 ----- src/ui/schedule-form-layout.tsx | 56 --- src/ui/schedule-header.tsx | 35 -- src/ui/schedule-inspector.tsx | 116 ------ src/ui/schedule-list.tsx | 114 ----- src/ui/section-nav.tsx | 107 ----- src/ui/sidebar-item-row.tsx | 5 +- src/ui/sidebar-rail.test.tsx | 61 --- src/ui/sidebar-rail.tsx | 141 ------- src/ui/sidebar-section.tsx | 91 ---- src/ui/stat-tile.tsx | 87 ---- src/ui/step-graph-node.tsx | 82 ---- src/ui/step-graph.tsx | 118 ------ src/ui/step-primitives.tsx | 58 --- src/ui/step-sidebar.test.tsx | 192 --------- src/ui/step-sidebar.tsx | 90 ---- src/ui/subagent-dock.tsx | 120 ------ src/ui/tenant-selector.tsx | 165 -------- src/ui/thread-list.tsx | 97 ----- src/ui/thread-switcher.tsx | 110 ----- src/ui/time-range-control.tsx | 81 ---- src/ui/toggle-list.tsx | 75 ---- src/ui/triage-pane.tsx | 72 ---- src/ui/whats-new.tsx | 145 ------- src/ui/workflow-catalog.tsx | 138 ------- src/ui/workflow-dock.tsx | 104 ----- src/ui/workflow-registry-list.tsx | 77 ---- src/ui/workflow-registry-row.tsx | 47 --- src/ui/workflow-registry-section.tsx | 47 --- src/ui/workflow-status-badge.tsx | 28 -- stories/blocks/auth-layout.stories.tsx | 9 - stories/blocks/canvas-host.stories.tsx | 161 -------- stories/chat/chat-dock.stories.tsx | 126 ------ stories/chat/generative-ui.stories.tsx | 120 ------ stories/chat/message-list.stories.tsx | 59 --- .../primitives/dashboard-section.stories.tsx | 26 -- stories/primitives/dial.stories.tsx | 25 -- .../primitives/dither-background.stories.tsx | 21 - .../library-page-header.stories.tsx | 18 +- stories/primitives/pagination.stories.tsx | 17 - stories/primitives/pulsing-ring.stories.tsx | 28 -- stories/primitives/radio-group.stories.tsx | 72 ---- .../sidebar-rail-and-panel.stories.tsx | 172 ++------ stories/primitives/toggle-list.stories.tsx | 26 -- stories/primitives/viz.stories.tsx | 23 +- stories/workflow/kind-picker.stories.tsx | 57 --- .../workflow/live-run-inspector.stories.tsx | 67 --- .../workflow/schedule-inspector.stories.tsx | 42 -- stories/workflow/step-graph.stories.tsx | 16 - stories/workflow/step-sidebar.stories.tsx | 94 ----- stories/workflow/workflows-list.stories.tsx | 65 --- 135 files changed, 157 insertions(+), 12103 deletions(-) delete mode 100644 src/blocks/access-notice/access-notice.tsx delete mode 100644 src/blocks/canvas-host/canvas-host-chrome.tsx delete mode 100644 src/blocks/canvas-host/canvas-host.test.tsx delete mode 100644 src/blocks/canvas-host/canvas-host.tsx delete mode 100644 src/hooks/use-anchored-scroll.test.tsx delete mode 100644 src/hooks/use-anchored-scroll.ts delete mode 100644 src/hooks/use-chat-dock.test.tsx delete mode 100644 src/hooks/use-chat-dock.ts delete mode 100644 src/hooks/use-collection-state.ts delete mode 100644 src/hooks/use-flip-transition.test.tsx delete mode 100644 src/hooks/use-flip-transition.ts delete mode 100644 src/hooks/use-focus-trap.test.tsx delete mode 100644 src/hooks/use-focus-trap.ts delete mode 100644 src/hooks/use-render-rail.test.tsx delete mode 100644 src/hooks/use-render-rail.ts delete mode 100644 src/hooks/use-resizable-rail.test.ts delete mode 100644 src/hooks/use-resizable-rail.ts delete mode 100644 src/hooks/use-sidebar-panel.ts delete mode 100644 src/hooks/use-view-mode.test.ts delete mode 100644 src/hooks/use-view-mode.ts delete mode 100644 src/lib/activity.ts delete mode 100644 src/lib/canvas-host-state.ts delete mode 100644 src/lib/command-registry.tsx delete mode 100644 src/lib/data-port.ts delete mode 100644 src/lib/generative-block.ts delete mode 100644 src/lib/schedule.ts delete mode 100644 src/lib/step-graph-layout.ts delete mode 100644 src/lib/tanstack-data-port.ts delete mode 100644 src/lib/time-range.ts delete mode 100644 src/ui/activity-timeline.tsx delete mode 100644 src/ui/actor-summary.tsx delete mode 100644 src/ui/add-artifact-dialog.tsx delete mode 100644 src/ui/artifact-detail.tsx delete mode 100644 src/ui/artifact-gallery.tsx delete mode 100644 src/ui/catalog-glyph.tsx delete mode 100644 src/ui/chat-dock-fab.test.tsx delete mode 100644 src/ui/chat-dock-modality.test.tsx delete mode 100644 src/ui/chat-dock.tsx delete mode 100644 src/ui/command-queue.tsx delete mode 100644 src/ui/context-strip.tsx delete mode 100644 src/ui/count-table.tsx delete mode 100644 src/ui/dashboard-section.tsx delete mode 100644 src/ui/dashboard.tsx delete mode 100644 src/ui/data-table.tsx delete mode 100644 src/ui/delta-badge.tsx delete mode 100644 src/ui/dial.tsx delete mode 100644 src/ui/dither-background.test.tsx delete mode 100644 src/ui/dither-background.tsx delete mode 100644 src/ui/failed-run-notice.tsx delete mode 100644 src/ui/generative-block-view.tsx delete mode 100644 src/ui/heatmap.tsx delete mode 100644 src/ui/kind-picker.tsx delete mode 100644 src/ui/library-search-input.tsx delete mode 100644 src/ui/live-run-header.tsx delete mode 100644 src/ui/live-run-inspector.tsx delete mode 100644 src/ui/mail-detail.tsx delete mode 100644 src/ui/managed-list.tsx delete mode 100644 src/ui/message-list.test.tsx delete mode 100644 src/ui/message-list.tsx delete mode 100644 src/ui/moment-walker.tsx delete mode 100644 src/ui/onboarding-tour.tsx delete mode 100644 src/ui/pagination.tsx delete mode 100644 src/ui/pulsing-ring.test.tsx delete mode 100644 src/ui/pulsing-ring.tsx delete mode 100644 src/ui/radio-group.test.tsx delete mode 100644 src/ui/radio-group.tsx delete mode 100644 src/ui/read-block.tsx delete mode 100644 src/ui/reconnecting-overlay.tsx delete mode 100644 src/ui/recurrence-input.tsx delete mode 100644 src/ui/render-rail.tsx delete mode 100644 src/ui/run-once-flow.tsx delete mode 100644 src/ui/schedule-flow.tsx delete mode 100644 src/ui/schedule-form-layout.tsx delete mode 100644 src/ui/schedule-header.tsx delete mode 100644 src/ui/schedule-inspector.tsx delete mode 100644 src/ui/schedule-list.tsx delete mode 100644 src/ui/section-nav.tsx delete mode 100644 src/ui/sidebar-rail.test.tsx delete mode 100644 src/ui/sidebar-rail.tsx delete mode 100644 src/ui/sidebar-section.tsx delete mode 100644 src/ui/stat-tile.tsx delete mode 100644 src/ui/step-graph-node.tsx delete mode 100644 src/ui/step-graph.tsx delete mode 100644 src/ui/step-primitives.tsx delete mode 100644 src/ui/step-sidebar.test.tsx delete mode 100644 src/ui/step-sidebar.tsx delete mode 100644 src/ui/subagent-dock.tsx delete mode 100644 src/ui/tenant-selector.tsx delete mode 100644 src/ui/thread-list.tsx delete mode 100644 src/ui/thread-switcher.tsx delete mode 100644 src/ui/time-range-control.tsx delete mode 100644 src/ui/toggle-list.tsx delete mode 100644 src/ui/triage-pane.tsx delete mode 100644 src/ui/whats-new.tsx delete mode 100644 src/ui/workflow-catalog.tsx delete mode 100644 src/ui/workflow-dock.tsx delete mode 100644 src/ui/workflow-registry-list.tsx delete mode 100644 src/ui/workflow-registry-row.tsx delete mode 100644 src/ui/workflow-registry-section.tsx delete mode 100644 src/ui/workflow-status-badge.tsx delete mode 100644 stories/blocks/canvas-host.stories.tsx delete mode 100644 stories/chat/chat-dock.stories.tsx delete mode 100644 stories/chat/generative-ui.stories.tsx delete mode 100644 stories/chat/message-list.stories.tsx delete mode 100644 stories/primitives/dashboard-section.stories.tsx delete mode 100644 stories/primitives/dial.stories.tsx delete mode 100644 stories/primitives/dither-background.stories.tsx delete mode 100644 stories/primitives/pagination.stories.tsx delete mode 100644 stories/primitives/pulsing-ring.stories.tsx delete mode 100644 stories/primitives/radio-group.stories.tsx delete mode 100644 stories/primitives/toggle-list.stories.tsx delete mode 100644 stories/workflow/kind-picker.stories.tsx delete mode 100644 stories/workflow/live-run-inspector.stories.tsx delete mode 100644 stories/workflow/schedule-inspector.stories.tsx delete mode 100644 stories/workflow/step-graph.stories.tsx delete mode 100644 stories/workflow/step-sidebar.stories.tsx delete mode 100644 stories/workflow/workflows-list.stories.tsx diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 2e2e5faf..81f58bec 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -17,7 +17,7 @@ per source file, no bundling — and `sideEffects` is declared CSS-only. The roo re-exports only, so importing from the root and importing by subpath both bundle just what you used. They are not guaranteed byte-for-byte identical — a bundler may order or name things differently depending on the entry — but neither drags in a component you did -not reference. One module is deliberately excluded from the root entry; see below. +not reference. **Abstraction has to earn its place.** No config indirection, no speculative extension points. There is no CLI, no component generator and no docs site, and that is deliberate. @@ -47,73 +47,12 @@ its own boundary (see README). Baking one framework's convention into a hundred published files would make correctness depend on a build step no plain-React consumer runs, and hide the boundary where the consumer cannot see it. -## The `DataPort` seam +## No fetch, ever Components in this package **never fetch**, and never import a server-side package. A component that fetches has chosen the consumer's data layer for them; a component that -imports a server package drags a database tree into a browser bundle. - -Data arrives one of two ways: as props, or through a `DataPort`. That is the -README contract — components take data through a `DataPort` (or none). "None" -is a props-only surface; `DataPortProvider` is required only for components -that call `useDataPort`. - -```ts -type DataPort = { - readonly useCollection: (request: CollectionRequest) => CollectionResult; -}; -``` - -A component declares *what* it needs — a stable cache key plus a `fetch` thunk that takes -`{ signal, offset, pageSize }` and returns `{ items, nextOffset }` — and learns nothing -about how the result is cached, deduped or revalidated. The port is resolved from React -context (`DataPortProvider` / `useDataPort`), and `useDataPort` throws with a wiring -message rather than returning `null`. - -Three details in `CollectionResult` are load-bearing: - -- **`isLoading` and `isFetching` are separate.** `isLoading` means there is nothing to - show yet; `isFetching` means a request is in flight, including a background refetch - over rows already on screen. Only `isFetching` is an honest input to `aria-busy`. -- **Pagination costs one field, not a second code path.** A source that returns - everything at once reports `nextOffset: null`. There is no paginated variant of any - component. -- **Collections only.** There is deliberately no `useRecord` and no `useMutation` — a - mutation shape invented before its first consumer is a shape that will be wrong. - -`createTanstackDataPort()` (`lib/tanstack-data-port`) implements `DataPort` over TanStack -Query's infinite-query API. It is a separate module on purpose: the seam is the type, the -adapter is one implementation, and that separation is what lets `@tanstack/react-query` -stay an **optional** peer. A consumer on SWR, a websocket cache or a fixture port swaps -the provider value and changes no component. - -The adapter is therefore the one public module excluded from the root barrel -(`BARREL_EXCLUDED` in `scripts/generate-exports.mjs`). `src/index.ts` is a single module: -a bundler tree-shakes *bindings* out of it, but still resolves every module it re-exports, -so re-exporting the adapter would make `import { Button } from "@corbits/react-ui"` fail -outright for anyone who did not install the optional peer. This is bundler-dependent — -Node and Vite fail, Turbopack compiles it — so `dep-guard` walks the relative import graph -from `src/index.ts` and fails if anything reachable from it imports an optional peer. - -## `use-collection-state` - -Every collection surface — a table, a list, a grid, a step rail — has the same four -states. `useCollectionState(request)` collapses a `CollectionResult` into exactly that -discriminated union and **renders nothing**. - -```ts -type CollectionState = - | { status: "loading" } - | { status: "error"; error: Error } - | { status: "empty" } - | { status: "ready"; items: readonly T[] }; -``` - -The ordering is the whole machine: `isLoading` wins, then `error`, then an empty `items`, -then ready. Getting it wrong is how a surface renders "no results" during a first load, or -an empty state over a failed request. It renders nothing because the markup belongs to the -surface; `isFetching`, `refetch`, `hasNextPage` and `fetchNextPage` pass through unchanged. - +imports a server package drags a database tree into a browser bundle. Data arrives as +props — the host owns its own data layer, caching and pagination. ## `CommandPalette` `CommandPalette` is a global-search overlay with no idea what it is searching. It takes @@ -215,30 +154,26 @@ validator yourself. ## Pieces, then one wrapper -A composite surface — an inspector, a registry list, a step diagram — is built as small -stateless pieces plus at most one composition wrapper. Each piece is its own file and its -own export, takes everything through props, holds no state of its own, and renders on its -own (`LiveRunHeader`, `LiveRunBanner`, `StepList`, `InspectorKv`, `InspectorPanelTitle`). -The wrapper (`LiveRunInspector`) contains no markup the pieces do not already carry: it -only decides which pieces render and in what order, so replacing one piece never means -forking the wrapper. `WorkflowStatusBadge` is the same idea one level down — a `Badge` -carrying a `StatusDot`, not a bespoke "status chip" component, because `Badge` and -`StatusDot` already exist and composing them is cheaper than a third thing that does -what the first two already do together. +A composite surface — an inspector, a step rail, a chat thread — is built as small +stateless pieces plus at most one composition wrapper. Each piece takes everything +through props, holds no state of its own, and renders on its own (`LiveRunBanner`, +`StepList`, `InspectorKv`, `InspectorPanelTitle`, `SidebarPanelBody`). A wrapper +contains no markup the pieces do not already carry: it only decides which pieces +render and in what order, so replacing one piece never means forking the wrapper. Behaviour and arithmetic split out the same way: a reusable behaviour becomes a headless hook in `src/hooks/` (`use-scroll-current-into-view` renders nothing and returns a ref), -and layout math becomes plain functions in `src/lib/` (`step-graph-layout`, like -`chart-geometry`, is numbers in and numbers out, checkable without rendering anything). +and layout math becomes plain functions in `src/lib/` (`chart-geometry` is numbers in +and numbers out, checkable without rendering anything). ## Layout | | | | --- | --- | | `src/ui/` | Components. Primitives, collection surfaces, shells, and the domain families. | -| `src/lib/` | Non-component source: `utils`, the `DataPort` seam and its adapter, the per-domain shapes, and the internal chart palette and geometry. | -| `src/hooks/` | 15 headless hooks: `use-collection-state`, `use-anchored-scroll`, `use-chat-dock`, `use-command-palette-navigation`, `use-controllable-state`, `use-delayed-autofocus`, `use-dismissable-popover`, `use-flip-transition`, `use-focus-trap`, `use-prefers-reduced-motion`, `use-render-rail`, `use-resizable-rail`, `use-scroll-current-into-view`, `use-sidebar-panel`, `use-view-mode`. | -| `src/blocks/` | Multi-file compositions (`login`, `access-notice`, `canvas-host`). | +| `src/lib/` | Non-component source: `utils`, the per-domain shapes, and the internal chart palette and geometry. | +| `src/hooks/` | 7 headless hooks: `use-command-palette-navigation`, `use-controllable-state`, `use-delayed-autofocus`, `use-dismissable-popover`, `use-list-selection`, `use-prefers-reduced-motion`, `use-scroll-current-into-view`. | +| `src/blocks/` | Multi-file compositions (`login`). | | `src/theme.css` | Tokens, keyframes, base layer. The only CSS source. | | `src/styles.css` | Two `@import`s. The entry Tailwind compiles into `dist/styles.css`. | | `src/index.ts` | The root barrel. **Generated** — do not edit. | @@ -255,6 +190,13 @@ line-by-line review of every file; it is a targeted grep-and-read over the patterns below, cross-checked against what similar components in the library already do. +This is a historical record of that pass. Several components it names — +`TenantSelector`, `ThreadSwitcher`, `SubagentDock`, `OnboardingTour`, +`DitherBackground`, `SidebarRail`, `StepSidebar`, `LiveRunInspector` and +others — were removed later, when a consumer-usage audit pruned every module +no known consumer imports. The findings and the standing rules they produced +still apply to what remains. + Every surveyed module is re-exported from the package root (`src/index.ts`), so "public surface" doesn't distinguish findings the way it might in an app — everything here is public. Blast radius instead counts how many other @@ -307,9 +249,9 @@ Audited every array-of-object prop in `src/ui`/`src/blocks` `kind-card-grid`, `progress-checklist`, `step-list`, `horizontal-stepper`, and others). **No findings.** Every one of these is a collection-rendering component — a table row set, a step rail, a filter list — consistent with -the `DataPort`/`use-collection-state` architecture above, where components +the props-only data flow above, where components render data the host doesn't have pre-built React elements for (it has -domain objects: a `Tenant`, a `WorkflowStep`, a `FilterSpec`). Slots are +domain objects: a `WorkflowStep`, a `FilterSpec`). Slots are used elsewhere in the library exactly where the content genuinely is caller-arbitrary markup (`NotificationsBell`'s `children`, `AuthLayout`'s `panel`). Swapping a config-array prop for children in a collection @@ -401,10 +343,9 @@ already in the tree. piece of state answers "is this open/collapsed/expanded" and a reasonable host might want to read or drive it — closing every other panel when one opens, restoring a collapsed sidebar from a saved preference — it's a prop -pair: `open` + `onOpenChange`, or `collapsed` + `onToggle`. `Sidebar`, -`StepSidebar` and `CanvasHost` set this precedent; `Dialog` and -`CommandPalette` apply it to popups; `NotificationsBell`, `TenantSelector`, -`ThreadSwitcher`, `ToolBlock` and `ToolNarrative` now do too, all +pair: `open` + `onOpenChange`, or `collapsed` + `onToggle`. `Sidebar` sets +this precedent; `Dialog` and `CommandPalette` apply it to popups; +`NotificationsBell`, `ToolBlock` and `ToolNarrative` do too, all uncontrolled-by-default so adding the pair is never a breaking change. The exception is state a parent has no legitimate reason to want: `ActivityBlock` stays a native `
`, and a text-truncation toggle stays local. If @@ -424,9 +365,8 @@ component whose job is to render caller-supplied content it has no opinion about (`NotificationsBell`'s `children`, `AuthLayout`'s `panel`) takes `children` or a named slot. A component whose job is to render a *collection* of the library's own domain shapes (a list of `WorkflowStep`, a -list of `Tenant`) takes that collection as a data prop, the same way -`DataPort` and `use-collection-state` already assume — that's what makes a -table, a list and a step rail interchangeable consumers of one +list of chat messages) takes that collection as a data prop — that's what +makes a table, a list and a step rail interchangeable consumers of one loading/empty/error contract. Don't reach for a config-object prop as a substitute for either of these; if you're tempted to add one, first check whether the content is actually caller-arbitrary (make it `children`) or @@ -436,12 +376,12 @@ shape). **Non-trivial imperative behavior lives in a headless hook, not the component body.** Event listeners, focus management, measurement, timers, keyboard handling — if two components would otherwise duplicate the same -`useEffect`, that's the hook boundary. `use-focus-trap`, -`use-command-palette-navigation`, `use-resizable-rail`, -`use-dismissable-popover` and `use-prefers-reduced-motion` are the existing -examples: each owns exactly the imperative part and returns refs/values, no -markup. Before writing a second `addEventListener` pair that looks like one -already in the tree, grep `src/hooks` first. +`useEffect`, that's the hook boundary. `use-command-palette-navigation`, +`use-dismissable-popover`, `use-prefers-reduced-motion` and +`use-controllable-state` are the existing examples: each owns exactly the +imperative part and returns refs/values, no markup. Before writing a second +`addEventListener` pair that looks like one already in the tree, grep +`src/hooks` first. **Every color comes from a token, with no unconditional literal fallback.** `src/theme.css` is the only place a hex, rgb or named color belongs. A @@ -456,23 +396,19 @@ literal is by construction correct in at most one theme. global CSS rule handles every `transition-*`/`animate-*` utility for free — don't re-implement that check for a component using only CSS transitions. The rule doesn't reach `requestAnimationFrame` loops or imperative calls -like `scrollIntoView`; those call `usePrefersReducedMotion()` (or, for -something that needs its own live `MediaQueryList` listener mid-loop, -follow `DitherBackground`'s pattern) rather than a one-shot -`matchMedia(...).matches` read that goes stale the moment the OS setting -changes. +like `scrollIntoView`; those call `usePrefersReducedMotion()` rather than a +one-shot `matchMedia(...).matches` read that goes stale the moment the OS +setting changes. ## Known limits -- **`DataPort` covers collections only.** Single-record reads and mutations are absent by decision, not by oversight. - **Tests exist but are partial.** `bun test` runs 338 tests co-located with their subjects (`src/**/*.test.tsx`), plus shared harnesses under `src/test/` — the measurable half of appearance (contrast, in both modes) is gated in the build, and a growing slice of behaviour is now covered by these, but most components - still have no test. -- **A rendering gallery exists but is partial.** Ladle (`bun run stories`, `bun run - stories:build`) renders 52 stories under `stories/`; most of the ~150 components have + still have no test.- **A rendering gallery exists but is partial.** Ladle (`bun run stories`, `bun run + stories:build`) renders 42 stories under `stories/`; most of the ~90 components have none, so visual review of an uncovered component still means rendering the package inside a consumer app. - **The prebuilt stylesheet restyles the consuming page** — it carries Tailwind's diff --git a/CHANGELOG.md b/CHANGELOG.md index f2db32e3..46ab9055 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,16 +12,41 @@ contract — modules that are not exported are internal and may change in any re ### Breaking changes since 0.1.0 +- **84 modules removed — every module no known consumer imports.** A usage audit across + the workbench, portal, scout and solutions-builder codebases showed these surfaces were + unreachable from any consumer, so they were deleted rather than carried as semver + promises. The removed clusters: the entire `DataPort` seam (`lib/data-port`, + `lib/tanstack-data-port`, `hooks/use-collection-state`) and its dependents + (`data-table`, `thread-list`, `command-queue`); the now-inbox surfaces (`mail-detail`, + `triage-pane`, `context-strip`, `activity-timeline`, `moment-walker`, `actor-summary`); + all scheduling (`schedule-*`, `run-once-flow`, + `recurrence-input`); the workflow registry (`workflow-registry-*`, `workflow-catalog`, + `workflow-dock`, `workflow-status-badge`); step-graph visualization (`step-graph`, + `step-graph-node`, `step-sidebar`, `step-primitives`, `lib/step-graph-layout`); + `subagent-dock`, `chat-dock` and `message-list`; the canvas/artifact-host surfaces + (`access-notice`, `canvas-host`, `canvas-host-chrome`, `generative-block-view`, + `artifact-detail`, `artifact-gallery`, `add-artifact-dialog`); and the unused + primitives and screens (`dial`, `pagination`, `radio-group`, `pulsing-ring`, + `dashboard`, `dashboard-section`, `whats-new`, `onboarding-tour`, + `reconnecting-overlay`, `stat-tile`, `delta-badge`, `count-table`, `heatmap`, + `kind-picker`, `toggle-list`, `managed-list`, `tenant-selector`, `thread-switcher`, + `section-nav`, `sidebar-rail`, `sidebar-section`, `read-block`, `render-rail`, + `failed-run-notice`, `live-run-header`, `live-run-inspector`, `time-range-control`, + `library-search-input`, `catalog-glyph`, `command`, `lib/command-registry`, + `dither-background`, and the hooks that only served them). `agent-turn` and + `activity-block` stayed — `chat-thread` uses them. The package is now 113 + public modules. **`@tanstack/react-query` is no longer a peer dependency** — + nothing in the package imports it. - **`AuthLayout` no longer defaults its decorative panel to `DitherCanvas`.** The panel is - now an explicit `panel` slot with no fallback — pass ``, - ``, or any other content. A caller that omitted `panel` to - get the dither canvas for free now gets a bare panel instead. + now an explicit `panel` slot with no fallback — pass `` or any other + content. A caller that omitted `panel` to get the dither canvas for free now gets a + bare panel instead. (`DitherBackground` was among the removed modules.) - **`ui/command` is removed.** `Command` was a second, weaker inline command surface next to `CommandPaletteInline` — index-based keyboard navigation (which loses the highlighted row when the list re-sorts under a keystroke) and self-filtering, where the palette - family deliberately leaves matching to the caller. `CommandAction` moves to - `lib/command-registry`, its only consumer; `commandMatches` is gone with the component. - Root-barrel imports of `CommandAction` still work; the `./ui/command` subpath is gone. + family deliberately leaves matching to the caller. `commandMatches` is gone + with the component. The `./ui/command` subpath is gone. `lib/command-registry` + (and `CommandAction`) is also in the unconsumed prune above. ## [0.1.0] - 2026-09-17 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e6497118..98b69ee7 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -12,8 +12,8 @@ bun install bun run build ``` -There is no database and no backend. If a change needs one, it belongs behind the -`DataPort` seam, not in a component. +There is no database and no backend. If a change needs one, it belongs in the +consumer's data layer, not in a component — data arrives as props. ## Gates @@ -48,25 +48,23 @@ the build gate does not cover them. Record the result in the change. ## The dep-guard -`scripts/dep-guard.mjs` walks every `.ts` / `.tsx` under `src/` and enforces three rules. +`scripts/dep-guard.mjs` walks every `.ts` / `.tsx` under `src/` and enforces two rules. The forbidden strings are named here deliberately — a guard whose forbidden string is a secret is a guard nobody can comply with. 1. **No `@workbench/*`.** A published file that imports an unpublished scope is uninstallable for everyone outside the project that defines it, and the failure lands in the *consumer's* install, where they cannot fix it. -2. **Only `lib/tanstack-data-port.ts` imports `@tanstack/react-query`.** That is what lets - the query library be an *optional* peer dependency. -3. **Nothing reachable from the root barrel imports an optional peer.** Rule 2 alone does - not cover this: re-exporting the adapter from `src/index.ts` keeps rule 2 green while - making the optional peer mandatory for every root import. +2. **Nothing reachable from the root barrel imports an optional peer.** Re-exporting a + module that statically imports an optional peer makes that peer mandatory for every + root import, so such a module must be kept out of the barrel (`BARREL_EXCLUDED`). The same rule, not machine-checked, applies to server-side packages: a component must never import one. They carry database drivers, and pulling one into a browser bundle is a -category error. Data reaches a component through props or a `DataPort`. +category error. Data reaches a component through props. -Wanting to relax any of this is the signal to widen the `DataPort` or add a prop — never -to import your way around it. +Wanting to relax any of this is the signal to add a prop — never to import your way +around it. ## Adding a component diff --git a/IMPLEMENTATION.md b/IMPLEMENTATION.md index 5b4dad1c..83facb5e 100644 --- a/IMPLEMENTATION.md +++ b/IMPLEMENTATION.md @@ -1,7 +1,7 @@ # Implementation Concrete toolchain, peers, formats and publish layout. Design of the -`DataPort` seam and the theme layer lives in [ARCHITECTURE.md](./ARCHITECTURE.md). +no-fetch rule and the theme layer lives in [ARCHITECTURE.md](./ARCHITECTURE.md). Why the library exists lives in [PRODUCT.md](./PRODUCT.md). ## Runtime @@ -14,8 +14,6 @@ Why the library exists lives in [PRODUCT.md](./PRODUCT.md). `@radix-ui/react-dialog` (`^1.1.15`), `@radix-ui/react-dropdown-menu` (`^2.1.16`), `@radix-ui/react-slot` (`^1.2.3`), `@radix-ui/react-tooltip` (`^1.2.8`). -- Optional peer: `@tanstack/react-query` (`^5.90.2`), needed only for - `createTanstackDataPort()`. Declared in `peerDependenciesMeta`. - Direct dependencies: `class-variance-authority`, `clsx`, `tailwind-merge`. - `sideEffects` is CSS-only (`**/*.css`). `files` publishes `dist/` only. @@ -38,8 +36,9 @@ alias. `prepack` runs the whole build so a failing gate cannot be packed. no-op when `dist/` is already present. Other scripts: `bun run typecheck`, `bun run lint` (eslint), -`bun run dep-guard` (`scripts/dep-guard.mjs`), `bun test`, `bun run stories` -/ `stories:build` ([Ladle](https://ladle.dev) over `stories/`). +`bun run dep-guard` (`scripts/dep-guard.mjs` — fails if any file imports +`@workbench/*`), `bun test`, `bun run stories` / `stories:build` +([Ladle](https://ladle.dev) over `stories/`). ## CSS artifacts @@ -64,20 +63,6 @@ writes `.dark`, `data-theme`, and `color-scheme` on a root (default mode is `"system"`. `ThemeToggle` / `useTheme` require the provider; the rest of the library does not. -## DataPort adapter - -`src/lib/data-port.ts` is the seam type plus `DataPortProvider` / -`useDataPort`. `src/lib/tanstack-data-port.ts` is the one implementation: -`useInfiniteQuery` from `@tanstack/react-query`, `initialPageParam: 0`, -`getNextPageParam` from `page.nextOffset`. That file is the only public -module on `BARREL_EXCLUDED` in `scripts/generate-exports.mjs` — import it by -subpath `@corbits/react-ui/lib/tanstack-data-port`. The host must already -have a TanStack `QueryClientProvider`. - -`dep-guard` fails if anything reachable from `src/index.ts` imports an -optional peer, if any file other than the adapter imports -`@tanstack/react-query`, or if any file imports `@workbench/*`. - ## Publish Registry install is the documented path (`npm` / `pnpm` / `yarn` / diff --git a/PRODUCT.md b/PRODUCT.md index 151a2f7b..e577828a 100644 --- a/PRODUCT.md +++ b/PRODUCT.md @@ -22,8 +22,8 @@ It is not a design-system starter, not a CLI, and not a template app. - Import one module at a time (`@corbits/react-ui/ui/button`) or from the root barrel, and bundle only what they used. - Render collection surfaces against *their* data. Components never fetch. - Data arrives as props, or through a `DataPort` the host provides. Pieces - that take no collection need no port at all. + Data arrives as props. Pieces that take no collection need no extra + host wiring. - Style with a single prebuilt sheet (`@corbits/react-ui/styles.css`) and no Tailwind, or import `@corbits/react-ui/theme.css` into an existing Tailwind v4 build. @@ -36,8 +36,7 @@ It is not a design-system starter, not a CLI, and not a template app. ## What it is not There is no CLI, no component generator, and no docs site. There is no -server, no database, and no default backend. `@tanstack/react-query` is -optional and only required if the host uses `createTanstackDataPort()`. +server, no database, and no default backend. The prebuilt stylesheet is not inert: it restyles the consuming page (Tailwind preflight plus a base layer). Import it at the root of an app that diff --git a/README.md b/README.md index 0ed8bc32..103f62e3 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,10 @@ # @corbits/react-ui -React components for agent and workflow surfaces — chat, runs, schedules, artifacts, analytics, collections. Import one module at a time, or from the root barrel. +React components for agent and workflow surfaces — chat, runs, artifacts, analytics, collections. Import one module at a time, or from the root barrel. ## Runtime support -Node >= 24 consumes built `dist/`. Peers: `react` and `react-dom` (18 or 19), `lucide-react`, `sonner`, `@radix-ui/react-dialog`, `@radix-ui/react-dropdown-menu`, `@radix-ui/react-slot`, `@radix-ui/react-tooltip`. `@tanstack/react-query` is an optional peer, needed only for `createTanstackDataPort()`. +Node >= 24 consumes built `dist/`. Peers: `react` and `react-dom` (18 or 19), `lucide-react`, `sonner`, `@radix-ui/react-dialog`, `@radix-ui/react-dropdown-menu`, `@radix-ui/react-slot`, `@radix-ui/react-tooltip`. ## Quickstart @@ -29,39 +29,30 @@ import { Button } from "@corbits/react-ui/ui/button"; ``` -Every component is importable by subpath (`@corbits/react-ui/ui/button`) or from the root (`@corbits/react-ui`). One module is subpath-only: `@corbits/react-ui/lib/tanstack-data-port`, because it statically imports the optional `@tanstack/react-query` peer. +Every component is importable by subpath (`@corbits/react-ui/ui/button`) or from the root (`@corbits/react-ui`). ```tsx import "@corbits/react-ui/styles.css"; import { Button } from "@corbits/react-ui/ui/button"; -import { DataTable } from "@corbits/react-ui/ui/data-table"; -import { DataPortProvider } from "@corbits/react-ui/lib/data-port"; -import { createTanstackDataPort } from "@corbits/react-ui/lib/tanstack-data-port"; +import { SortableTable } from "@corbits/react-ui/ui/sortable-table"; type Run = { id: string; name: string; status: string }; -export function Runs() { +export function Runs({ runs }: { readonly runs: readonly Run[] }) { return ( - - + <> + run.id} - request={{ - key: ["runs"], - pageSize: 50, - fetch: async ({ signal, offset, pageSize }) => { - const items: readonly Run[] = await api.runs({ signal, offset, pageSize }); - return { items, nextOffset: null }; - }, - }} columns={[ - { header: "Run", cell: (run) => run.name }, - { header: "Status", cell: (run) => run.status }, + { key: "name", header: "Run", cell: (run) => run.name }, + { key: "status", header: "Status", cell: (run) => run.status }, ]} /> - + ); } ``` @@ -70,9 +61,9 @@ This package ships no `"use client"` directives. In a React Server Components ap ## How it works -Components take data through a `DataPort` (or none). The root entry is re-exports only and the package is side-effect free aside from the CSS files, so either import style tree-shakes to what you used. Tokens and keyframes live in `theme.css`; `styles.css` is the prebuilt sheet for hosts that do not run Tailwind. +Components never fetch — data arrives as props. The root entry is re-exports only and the package is side-effect free aside from the CSS files, so either import style tree-shakes to what you used. Tokens and keyframes live in `theme.css`; `styles.css` is the prebuilt sheet for hosts that do not run Tailwind. -See [PRODUCT.md](./PRODUCT.md) for why this library exists, [ARCHITECTURE.md](./ARCHITECTURE.md) for the DataPort seam and the theme layer, and [IMPLEMENTATION.md](./IMPLEMENTATION.md) for toolchain and peers. +See [PRODUCT.md](./PRODUCT.md) for why this library exists, [ARCHITECTURE.md](./ARCHITECTURE.md) for the no-fetch rule and the theme layer, and [IMPLEMENTATION.md](./IMPLEMENTATION.md) for toolchain and peers. ## Development diff --git a/bun.lock b/bun.lock index 70a4347d..77cd011b 100644 --- a/bun.lock +++ b/bun.lock @@ -21,7 +21,6 @@ "@swc/core": "^1.13.5", "@tailwindcss/cli": "^4.3.3", "@tailwindcss/vite": "^4.3.3", - "@tanstack/react-query": "^5.90.2", "@types/bun": "^1.3.14", "@types/node": "^24.9.1", "@types/react": "^19.2.2", @@ -40,15 +39,11 @@ "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-tooltip": "^1.2.8", - "@tanstack/react-query": "^5.90.2", "lucide-react": "^0.545.0 || ^1.0.0", "react": "^18.2.0 || ^19.0.0", "react-dom": "^18.2.0 || ^19.0.0", "sonner": "^2.0.7", }, - "optionalPeers": [ - "@tanstack/react-query", - ], }, }, "packages": { @@ -472,10 +467,6 @@ "@tailwindcss/vite": ["@tailwindcss/vite@4.3.3", "", { "dependencies": { "@tailwindcss/node": "4.3.3", "@tailwindcss/oxide": "4.3.3", "tailwindcss": "4.3.3" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7 || ^8" } }, "sha512-yYU8cogLeSh/ms2jh8Fj7jaba/EWa7Ja6GoUqYZaraEuCI5YS6ms6ObZgjjedm+jm6XZjdNRWBpPP6Z86oOxcw=="], - "@tanstack/query-core": ["@tanstack/query-core@5.101.4", "", {}, "sha512-gNwcvOJcRbLWPOLG/2OBm+zM+Yv+MKsXKEOWC57USuZDEsI71hEErQsiEGx5wX9rzWWkfwM0fVSPoiIFSsxfiw=="], - - "@tanstack/react-query": ["@tanstack/react-query@5.101.4", "", { "dependencies": { "@tanstack/query-core": "5.101.4" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-yRg2pfOCxIs4ZJW3XYYHU/WgtD04FHSnfHlpRT7h7pR77hwkdRG4wxbKe4aq6P0RvXUTBSQpQeadS1SUYUe+KA=="], - "@tokenizer/inflate": ["@tokenizer/inflate@0.2.7", "", { "dependencies": { "debug": "^4.4.0", "fflate": "^0.8.2", "token-types": "^6.0.0" } }, "sha512-MADQgmZT1eKjp06jpI2yozxaU9uVs4GzzgSL+uEq7bVcJ9V1ZXQkeGNql1fsSI0gMy1vhvNTNbUqrx+pZfJVmg=="], "@tokenizer/token": ["@tokenizer/token@0.3.0", "", {}, "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A=="], diff --git a/package.json b/package.json index f30bd7eb..3129f69a 100644 --- a/package.json +++ b/package.json @@ -53,17 +53,11 @@ "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-tooltip": "^1.2.8", - "@tanstack/react-query": "^5.90.2", "lucide-react": "^0.545.0 || ^1.0.0", "react": "^18.2.0 || ^19.0.0", "react-dom": "^18.2.0 || ^19.0.0", "sonner": "^2.0.7" }, - "peerDependenciesMeta": { - "@tanstack/react-query": { - "optional": true - } - }, "devDependencies": { "@eslint/js": "^9.39.1", "@happy-dom/global-registrator": "^20.11.2", @@ -76,7 +70,6 @@ "@swc/core": "^1.13.5", "@tailwindcss/cli": "^4.3.3", "@tailwindcss/vite": "^4.3.3", - "@tanstack/react-query": "^5.90.2", "@types/bun": "^1.3.14", "@types/node": "^24.9.1", "@types/react": "^19.2.2", @@ -98,18 +91,6 @@ "./styles.css": "./dist/styles.css", "./theme.css": "./dist/theme.css", "./package.json": "./package.json", - "./blocks/access-notice/access-notice": { - "types": "./dist/blocks/access-notice/access-notice.d.ts", - "default": "./dist/blocks/access-notice/access-notice.js" - }, - "./blocks/canvas-host/canvas-host": { - "types": "./dist/blocks/canvas-host/canvas-host.d.ts", - "default": "./dist/blocks/canvas-host/canvas-host.js" - }, - "./blocks/canvas-host/canvas-host-chrome": { - "types": "./dist/blocks/canvas-host/canvas-host-chrome.d.ts", - "default": "./dist/blocks/canvas-host/canvas-host-chrome.js" - }, "./blocks/login/auth-layout": { "types": "./dist/blocks/login/auth-layout.d.ts", "default": "./dist/blocks/login/auth-layout.js" @@ -118,18 +99,6 @@ "types": "./dist/blocks/login/login-form.d.ts", "default": "./dist/blocks/login/login-form.js" }, - "./hooks/use-anchored-scroll": { - "types": "./dist/hooks/use-anchored-scroll.d.ts", - "default": "./dist/hooks/use-anchored-scroll.js" - }, - "./hooks/use-chat-dock": { - "types": "./dist/hooks/use-chat-dock.d.ts", - "default": "./dist/hooks/use-chat-dock.js" - }, - "./hooks/use-collection-state": { - "types": "./dist/hooks/use-collection-state.d.ts", - "default": "./dist/hooks/use-collection-state.js" - }, "./hooks/use-command-palette-navigation": { "types": "./dist/hooks/use-command-palette-navigation.d.ts", "default": "./dist/hooks/use-command-palette-navigation.js" @@ -146,14 +115,6 @@ "types": "./dist/hooks/use-dismissable-popover.d.ts", "default": "./dist/hooks/use-dismissable-popover.js" }, - "./hooks/use-flip-transition": { - "types": "./dist/hooks/use-flip-transition.d.ts", - "default": "./dist/hooks/use-flip-transition.js" - }, - "./hooks/use-focus-trap": { - "types": "./dist/hooks/use-focus-trap.d.ts", - "default": "./dist/hooks/use-focus-trap.js" - }, "./hooks/use-list-selection": { "types": "./dist/hooks/use-list-selection.d.ts", "default": "./dist/hooks/use-list-selection.js" @@ -162,38 +123,14 @@ "types": "./dist/hooks/use-prefers-reduced-motion.d.ts", "default": "./dist/hooks/use-prefers-reduced-motion.js" }, - "./hooks/use-render-rail": { - "types": "./dist/hooks/use-render-rail.d.ts", - "default": "./dist/hooks/use-render-rail.js" - }, - "./hooks/use-resizable-rail": { - "types": "./dist/hooks/use-resizable-rail.d.ts", - "default": "./dist/hooks/use-resizable-rail.js" - }, "./hooks/use-scroll-current-into-view": { "types": "./dist/hooks/use-scroll-current-into-view.d.ts", "default": "./dist/hooks/use-scroll-current-into-view.js" }, - "./hooks/use-sidebar-panel": { - "types": "./dist/hooks/use-sidebar-panel.d.ts", - "default": "./dist/hooks/use-sidebar-panel.js" - }, - "./hooks/use-view-mode": { - "types": "./dist/hooks/use-view-mode.d.ts", - "default": "./dist/hooks/use-view-mode.js" - }, - "./lib/activity": { - "types": "./dist/lib/activity.d.ts", - "default": "./dist/lib/activity.js" - }, "./lib/artifact": { "types": "./dist/lib/artifact.d.ts", "default": "./dist/lib/artifact.js" }, - "./lib/canvas-host-state": { - "types": "./dist/lib/canvas-host-state.d.ts", - "default": "./dist/lib/canvas-host-state.js" - }, "./lib/chart-palette": { "types": "./dist/lib/chart-palette.d.ts", "default": "./dist/lib/chart-palette.js" @@ -206,22 +143,10 @@ "types": "./dist/lib/chat-parts.d.ts", "default": "./dist/lib/chat-parts.js" }, - "./lib/command-registry": { - "types": "./dist/lib/command-registry.d.ts", - "default": "./dist/lib/command-registry.js" - }, "./lib/csv": { "types": "./dist/lib/csv.d.ts", "default": "./dist/lib/csv.js" }, - "./lib/data-port": { - "types": "./dist/lib/data-port.d.ts", - "default": "./dist/lib/data-port.js" - }, - "./lib/generative-block": { - "types": "./dist/lib/generative-block.d.ts", - "default": "./dist/lib/generative-block.js" - }, "./lib/metrics": { "types": "./dist/lib/metrics.d.ts", "default": "./dist/lib/metrics.js" @@ -234,22 +159,10 @@ "types": "./dist/lib/relative-time.d.ts", "default": "./dist/lib/relative-time.js" }, - "./lib/schedule": { - "types": "./dist/lib/schedule.d.ts", - "default": "./dist/lib/schedule.js" - }, - "./lib/tanstack-data-port": { - "types": "./dist/lib/tanstack-data-port.d.ts", - "default": "./dist/lib/tanstack-data-port.js" - }, "./lib/theme": { "types": "./dist/lib/theme.d.ts", "default": "./dist/lib/theme.js" }, - "./lib/time-range": { - "types": "./dist/lib/time-range.d.ts", - "default": "./dist/lib/time-range.js" - }, "./lib/url": { "types": "./dist/lib/url.d.ts", "default": "./dist/lib/url.js" @@ -282,18 +195,6 @@ "types": "./dist/ui/activity-block.d.ts", "default": "./dist/ui/activity-block.js" }, - "./ui/activity-timeline": { - "types": "./dist/ui/activity-timeline.d.ts", - "default": "./dist/ui/activity-timeline.js" - }, - "./ui/actor-summary": { - "types": "./dist/ui/actor-summary.d.ts", - "default": "./dist/ui/actor-summary.js" - }, - "./ui/add-artifact-dialog": { - "types": "./dist/ui/add-artifact-dialog.d.ts", - "default": "./dist/ui/add-artifact-dialog.js" - }, "./ui/agent-turn": { "types": "./dist/ui/agent-turn.d.ts", "default": "./dist/ui/agent-turn.js" @@ -310,14 +211,6 @@ "types": "./dist/ui/artifact-body.d.ts", "default": "./dist/ui/artifact-body.js" }, - "./ui/artifact-detail": { - "types": "./dist/ui/artifact-detail.d.ts", - "default": "./dist/ui/artifact-detail.js" - }, - "./ui/artifact-gallery": { - "types": "./dist/ui/artifact-gallery.d.ts", - "default": "./dist/ui/artifact-gallery.js" - }, "./ui/artifact-notice": { "types": "./dist/ui/artifact-notice.d.ts", "default": "./dist/ui/artifact-notice.js" @@ -354,10 +247,6 @@ "types": "./dist/ui/card.d.ts", "default": "./dist/ui/card.js" }, - "./ui/catalog-glyph": { - "types": "./dist/ui/catalog-glyph.d.ts", - "default": "./dist/ui/catalog-glyph.js" - }, "./ui/category-bars": { "types": "./dist/ui/category-bars.d.ts", "default": "./dist/ui/category-bars.js" @@ -370,10 +259,6 @@ "types": "./dist/ui/chat-composer.d.ts", "default": "./dist/ui/chat-composer.js" }, - "./ui/chat-dock": { - "types": "./dist/ui/chat-dock.d.ts", - "default": "./dist/ui/chat-dock.js" - }, "./ui/chat-dock-timing": { "types": "./dist/ui/chat-dock-timing.d.ts", "default": "./dist/ui/chat-dock-timing.js" @@ -398,10 +283,6 @@ "types": "./dist/ui/command-palette.d.ts", "default": "./dist/ui/command-palette.js" }, - "./ui/command-queue": { - "types": "./dist/ui/command-queue.d.ts", - "default": "./dist/ui/command-queue.js" - }, "./ui/compare-body": { "types": "./dist/ui/compare-body.d.ts", "default": "./dist/ui/compare-body.js" @@ -410,50 +291,18 @@ "types": "./dist/ui/confirm-button.d.ts", "default": "./dist/ui/confirm-button.js" }, - "./ui/context-strip": { - "types": "./dist/ui/context-strip.d.ts", - "default": "./dist/ui/context-strip.js" - }, "./ui/corbits-mark": { "types": "./dist/ui/corbits-mark.d.ts", "default": "./dist/ui/corbits-mark.js" }, - "./ui/count-table": { - "types": "./dist/ui/count-table.d.ts", - "default": "./dist/ui/count-table.js" - }, "./ui/csv-table": { "types": "./dist/ui/csv-table.d.ts", "default": "./dist/ui/csv-table.js" }, - "./ui/dashboard": { - "types": "./dist/ui/dashboard.d.ts", - "default": "./dist/ui/dashboard.js" - }, - "./ui/dashboard-section": { - "types": "./dist/ui/dashboard-section.d.ts", - "default": "./dist/ui/dashboard-section.js" - }, - "./ui/data-table": { - "types": "./dist/ui/data-table.d.ts", - "default": "./dist/ui/data-table.js" - }, - "./ui/delta-badge": { - "types": "./dist/ui/delta-badge.d.ts", - "default": "./dist/ui/delta-badge.js" - }, - "./ui/dial": { - "types": "./dist/ui/dial.d.ts", - "default": "./dist/ui/dial.js" - }, "./ui/dialog": { "types": "./dist/ui/dialog.d.ts", "default": "./dist/ui/dialog.js" }, - "./ui/dither-background": { - "types": "./dist/ui/dither-background.d.ts", - "default": "./dist/ui/dither-background.js" - }, "./ui/dither-canvas": { "types": "./dist/ui/dither-canvas.d.ts", "default": "./dist/ui/dither-canvas.js" @@ -466,10 +315,6 @@ "types": "./dist/ui/empty-state.d.ts", "default": "./dist/ui/empty-state.js" }, - "./ui/failed-run-notice": { - "types": "./dist/ui/failed-run-notice.d.ts", - "default": "./dist/ui/failed-run-notice.js" - }, "./ui/file-input": { "types": "./dist/ui/file-input.d.ts", "default": "./dist/ui/file-input.js" @@ -486,14 +331,6 @@ "types": "./dist/ui/gate-block.d.ts", "default": "./dist/ui/gate-block.js" }, - "./ui/generative-block-view": { - "types": "./dist/ui/generative-block-view.d.ts", - "default": "./dist/ui/generative-block-view.js" - }, - "./ui/heatmap": { - "types": "./dist/ui/heatmap.d.ts", - "default": "./dist/ui/heatmap.js" - }, "./ui/horizontal-stepper": { "types": "./dist/ui/horizontal-stepper.d.ts", "default": "./dist/ui/horizontal-stepper.js" @@ -514,18 +351,10 @@ "types": "./dist/ui/kind-card-grid.d.ts", "default": "./dist/ui/kind-card-grid.js" }, - "./ui/kind-picker": { - "types": "./dist/ui/kind-picker.d.ts", - "default": "./dist/ui/kind-picker.js" - }, "./ui/library-page-header": { "types": "./dist/ui/library-page-header.d.ts", "default": "./dist/ui/library-page-header.js" }, - "./ui/library-search-input": { - "types": "./dist/ui/library-search-input.d.ts", - "default": "./dist/ui/library-search-input.js" - }, "./ui/list-detail": { "types": "./dist/ui/list-detail.d.ts", "default": "./dist/ui/list-detail.js" @@ -534,26 +363,10 @@ "types": "./dist/ui/live-run-banner.d.ts", "default": "./dist/ui/live-run-banner.js" }, - "./ui/live-run-header": { - "types": "./dist/ui/live-run-header.d.ts", - "default": "./dist/ui/live-run-header.js" - }, - "./ui/live-run-inspector": { - "types": "./dist/ui/live-run-inspector.d.ts", - "default": "./dist/ui/live-run-inspector.js" - }, "./ui/live-status-line": { "types": "./dist/ui/live-status-line.d.ts", "default": "./dist/ui/live-status-line.js" }, - "./ui/mail-detail": { - "types": "./dist/ui/mail-detail.d.ts", - "default": "./dist/ui/mail-detail.js" - }, - "./ui/managed-list": { - "types": "./dist/ui/managed-list.d.ts", - "default": "./dist/ui/managed-list.js" - }, "./ui/menu": { "types": "./dist/ui/menu.d.ts", "default": "./dist/ui/menu.js" @@ -562,14 +375,6 @@ "types": "./dist/ui/message-bubble.d.ts", "default": "./dist/ui/message-bubble.js" }, - "./ui/message-list": { - "types": "./dist/ui/message-list.d.ts", - "default": "./dist/ui/message-list.js" - }, - "./ui/moment-walker": { - "types": "./dist/ui/moment-walker.d.ts", - "default": "./dist/ui/moment-walker.js" - }, "./ui/notifications-bell": { "types": "./dist/ui/notifications-bell.d.ts", "default": "./dist/ui/notifications-bell.js" @@ -578,10 +383,6 @@ "types": "./dist/ui/now-cards.d.ts", "default": "./dist/ui/now-cards.js" }, - "./ui/onboarding-tour": { - "types": "./dist/ui/onboarding-tour.d.ts", - "default": "./dist/ui/onboarding-tour.js" - }, "./ui/page-panel": { "types": "./dist/ui/page-panel.d.ts", "default": "./dist/ui/page-panel.js" @@ -590,10 +391,6 @@ "types": "./dist/ui/page-shell.d.ts", "default": "./dist/ui/page-shell.js" }, - "./ui/pagination": { - "types": "./dist/ui/pagination.d.ts", - "default": "./dist/ui/pagination.js" - }, "./ui/parts-renderer": { "types": "./dist/ui/parts-renderer.d.ts", "default": "./dist/ui/parts-renderer.js" @@ -610,10 +407,6 @@ "types": "./dist/ui/provider-mark.d.ts", "default": "./dist/ui/provider-mark.js" }, - "./ui/pulsing-ring": { - "types": "./dist/ui/pulsing-ring.d.ts", - "default": "./dist/ui/pulsing-ring.js" - }, "./ui/quick-reply-chips": { "types": "./dist/ui/quick-reply-chips.d.ts", "default": "./dist/ui/quick-reply-chips.js" @@ -622,26 +415,6 @@ "types": "./dist/ui/quote-card.d.ts", "default": "./dist/ui/quote-card.js" }, - "./ui/radio-group": { - "types": "./dist/ui/radio-group.d.ts", - "default": "./dist/ui/radio-group.js" - }, - "./ui/read-block": { - "types": "./dist/ui/read-block.d.ts", - "default": "./dist/ui/read-block.js" - }, - "./ui/reconnecting-overlay": { - "types": "./dist/ui/reconnecting-overlay.d.ts", - "default": "./dist/ui/reconnecting-overlay.js" - }, - "./ui/recurrence-input": { - "types": "./dist/ui/recurrence-input.d.ts", - "default": "./dist/ui/recurrence-input.js" - }, - "./ui/render-rail": { - "types": "./dist/ui/render-rail.d.ts", - "default": "./dist/ui/render-rail.js" - }, "./ui/research-body": { "types": "./dist/ui/research-body.d.ts", "default": "./dist/ui/research-body.js" @@ -654,38 +427,10 @@ "types": "./dist/ui/run-now-button.d.ts", "default": "./dist/ui/run-now-button.js" }, - "./ui/run-once-flow": { - "types": "./dist/ui/run-once-flow.d.ts", - "default": "./dist/ui/run-once-flow.js" - }, - "./ui/schedule-flow": { - "types": "./dist/ui/schedule-flow.d.ts", - "default": "./dist/ui/schedule-flow.js" - }, - "./ui/schedule-form-layout": { - "types": "./dist/ui/schedule-form-layout.d.ts", - "default": "./dist/ui/schedule-form-layout.js" - }, - "./ui/schedule-header": { - "types": "./dist/ui/schedule-header.d.ts", - "default": "./dist/ui/schedule-header.js" - }, - "./ui/schedule-inspector": { - "types": "./dist/ui/schedule-inspector.d.ts", - "default": "./dist/ui/schedule-inspector.js" - }, - "./ui/schedule-list": { - "types": "./dist/ui/schedule-list.d.ts", - "default": "./dist/ui/schedule-list.js" - }, "./ui/section": { "types": "./dist/ui/section.d.ts", "default": "./dist/ui/section.js" }, - "./ui/section-nav": { - "types": "./dist/ui/section-nav.d.ts", - "default": "./dist/ui/section-nav.js" - }, "./ui/select": { "types": "./dist/ui/select.d.ts", "default": "./dist/ui/select.js" @@ -710,14 +455,6 @@ "types": "./dist/ui/sidebar-panel.d.ts", "default": "./dist/ui/sidebar-panel.js" }, - "./ui/sidebar-rail": { - "types": "./dist/ui/sidebar-rail.d.ts", - "default": "./dist/ui/sidebar-rail.js" - }, - "./ui/sidebar-section": { - "types": "./dist/ui/sidebar-section.d.ts", - "default": "./dist/ui/sidebar-section.js" - }, "./ui/skeleton": { "types": "./dist/ui/skeleton.d.ts", "default": "./dist/ui/skeleton.js" @@ -734,38 +471,14 @@ "types": "./dist/ui/stat-grid.d.ts", "default": "./dist/ui/stat-grid.js" }, - "./ui/stat-tile": { - "types": "./dist/ui/stat-tile.d.ts", - "default": "./dist/ui/stat-tile.js" - }, "./ui/status-dot": { "types": "./dist/ui/status-dot.d.ts", "default": "./dist/ui/status-dot.js" }, - "./ui/step-graph": { - "types": "./dist/ui/step-graph.d.ts", - "default": "./dist/ui/step-graph.js" - }, - "./ui/step-graph-node": { - "types": "./dist/ui/step-graph-node.d.ts", - "default": "./dist/ui/step-graph-node.js" - }, "./ui/step-list": { "types": "./dist/ui/step-list.d.ts", "default": "./dist/ui/step-list.js" }, - "./ui/step-primitives": { - "types": "./dist/ui/step-primitives.d.ts", - "default": "./dist/ui/step-primitives.js" - }, - "./ui/step-sidebar": { - "types": "./dist/ui/step-sidebar.d.ts", - "default": "./dist/ui/step-sidebar.js" - }, - "./ui/subagent-dock": { - "types": "./dist/ui/subagent-dock.d.ts", - "default": "./dist/ui/subagent-dock.js" - }, "./ui/switch": { "types": "./dist/ui/switch.d.ts", "default": "./dist/ui/switch.js" @@ -778,10 +491,6 @@ "types": "./dist/ui/tabs.d.ts", "default": "./dist/ui/tabs.js" }, - "./ui/tenant-selector": { - "types": "./dist/ui/tenant-selector.d.ts", - "default": "./dist/ui/tenant-selector.js" - }, "./ui/textarea": { "types": "./dist/ui/textarea.d.ts", "default": "./dist/ui/textarea.js" @@ -794,18 +503,6 @@ "types": "./dist/ui/theme-toggle.d.ts", "default": "./dist/ui/theme-toggle.js" }, - "./ui/thread-list": { - "types": "./dist/ui/thread-list.d.ts", - "default": "./dist/ui/thread-list.js" - }, - "./ui/thread-switcher": { - "types": "./dist/ui/thread-switcher.d.ts", - "default": "./dist/ui/thread-switcher.js" - }, - "./ui/time-range-control": { - "types": "./dist/ui/time-range-control.d.ts", - "default": "./dist/ui/time-range-control.js" - }, "./ui/time-series-chart": { "types": "./dist/ui/time-series-chart.d.ts", "default": "./dist/ui/time-series-chart.js" @@ -814,10 +511,6 @@ "types": "./dist/ui/toast.d.ts", "default": "./dist/ui/toast.js" }, - "./ui/toggle-list": { - "types": "./dist/ui/toggle-list.d.ts", - "default": "./dist/ui/toggle-list.js" - }, "./ui/token-mosaic": { "types": "./dist/ui/token-mosaic.d.ts", "default": "./dist/ui/token-mosaic.js" @@ -842,10 +535,6 @@ "types": "./dist/ui/trace-waterfall.d.ts", "default": "./dist/ui/trace-waterfall.js" }, - "./ui/triage-pane": { - "types": "./dist/ui/triage-pane.d.ts", - "default": "./dist/ui/triage-pane.js" - }, "./ui/typing-indicator": { "types": "./dist/ui/typing-indicator.d.ts", "default": "./dist/ui/typing-indicator.js" @@ -853,34 +542,6 @@ "./ui/view-toggle": { "types": "./dist/ui/view-toggle.d.ts", "default": "./dist/ui/view-toggle.js" - }, - "./ui/whats-new": { - "types": "./dist/ui/whats-new.d.ts", - "default": "./dist/ui/whats-new.js" - }, - "./ui/workflow-catalog": { - "types": "./dist/ui/workflow-catalog.d.ts", - "default": "./dist/ui/workflow-catalog.js" - }, - "./ui/workflow-dock": { - "types": "./dist/ui/workflow-dock.d.ts", - "default": "./dist/ui/workflow-dock.js" - }, - "./ui/workflow-registry-list": { - "types": "./dist/ui/workflow-registry-list.d.ts", - "default": "./dist/ui/workflow-registry-list.js" - }, - "./ui/workflow-registry-row": { - "types": "./dist/ui/workflow-registry-row.d.ts", - "default": "./dist/ui/workflow-registry-row.js" - }, - "./ui/workflow-registry-section": { - "types": "./dist/ui/workflow-registry-section.d.ts", - "default": "./dist/ui/workflow-registry-section.js" - }, - "./ui/workflow-status-badge": { - "types": "./dist/ui/workflow-status-badge.d.ts", - "default": "./dist/ui/workflow-status-badge.js" } } } diff --git a/scripts/dep-guard.mjs b/scripts/dep-guard.mjs index 52c495d5..488423ed 100644 --- a/scripts/dep-guard.mjs +++ b/scripts/dep-guard.mjs @@ -1,20 +1,13 @@ -// Three import rules that types alone cannot enforce. Each one is a property a +// Two import rules that types alone cannot enforce. Each one is a property a // consumer would feel and no other step in the build would notice. // // 1. Nothing imports from @workbench/*. corbits-ui is a clean rewrite; a single // leaked import would drag the old package back in. // -// 2. Only lib/tanstack-data-port.ts imports @tanstack/react-query. That module -// is one implementation of the DataPort seam, and keeping it the sole -// importer is what lets the query library stay an *optional* peer -// dependency. -// -// 3. Nothing reachable from the root barrel imports an optional peer. This is -// rule 2's real point, and rule 2 alone does not cover it: the barrel is a -// single module, so re-exporting the adapter from index.ts loads it on *any* -// root import and crashes for every consumer who did not install the peer — -// while rule 2 stays green, because the adapter is still the only file with -// the import. That is exactly the bug this rule was added to catch. +// 2. Nothing reachable from the root barrel imports an optional peer. The +// barrel is a single module, so re-exporting a module that statically +// imports an optional peer loads it on *any* root import and crashes for +// every consumer who did not install that peer. // // Only Node and Vite surface it; a Turbopack consumer builds cleanly, so // this cannot be left to whichever bundler the person testing happened to @@ -24,15 +17,10 @@ import { dirname, join, relative, resolve } from "node:path"; const ROOT = new URL("../src", import.meta.url).pathname; -const OPTIONAL_PEERS = ["@tanstack/react-query"]; +const OPTIONAL_PEERS = []; const RULES = [ { label: "@workbench/*", pattern: /["'](@workbench\/[^"']*)["']/g, allow: () => false }, - { - label: "@tanstack/react-query", - pattern: /["'](@tanstack\/react-query)["']/g, - allow: (file) => file === "lib/tanstack-data-port.ts", - }, ]; const walk = (dir) => @@ -45,7 +33,7 @@ const sources = walk(ROOT).filter((path) => /\.tsx?$/.test(path)); const read = (path) => readFileSync(path, "utf8"); const id = (path) => relative(ROOT, path); -// --- Rules 1 and 2: who imports what --------------------------------------- +// --- Rule 1: who imports what ---------------------------------------------- const violations = sources.flatMap((path) => { const source = read(path); @@ -54,7 +42,7 @@ const violations = sources.flatMap((path) => { ); }); -// --- Rule 3: what the root barrel drags in --------------------------------- +// --- Rule 2: what the root barrel drags in --------------------------------- /** Source paths reachable from `entry` through relative imports. */ function reachableFrom(entry) { diff --git a/scripts/generate-exports.mjs b/scripts/generate-exports.mjs index a69c6cb7..c7177e67 100644 --- a/scripts/generate-exports.mjs +++ b/scripts/generate-exports.mjs @@ -14,17 +14,16 @@ // the root barrel*. A module that statically imports an optional peer // dependency cannot be re-exported from `index.ts`, because the barrel is one // module — importing anything from the root would load it and crash for every -// consumer who did not install that peer. This is the whole reason the DataPort -// seam and its TanStack adapter are separate files, and `dep-guard` enforces -// the property independently. +// consumer who did not install that peer. `dep-guard` enforces the property +// independently. import { readdirSync, readFileSync, statSync, writeFileSync } from "node:fs"; import { join, relative } from "node:path"; const ROOT = new URL("..", import.meta.url).pathname; const SRC = join(ROOT, "src"); -const INTERNAL = new Set(["lib/chart-geometry", "lib/step-graph-layout"]); -const BARREL_EXCLUDED = new Set(["lib/tanstack-data-port"]); +const INTERNAL = new Set(["lib/chart-geometry"]); +const BARREL_EXCLUDED = new Set(); const walk = (dir) => readdirSync(dir).flatMap((entry) => { diff --git a/src/blocks/access-notice/access-notice.tsx b/src/blocks/access-notice/access-notice.tsx deleted file mode 100644 index e92dd117..00000000 --- a/src/blocks/access-notice/access-notice.tsx +++ /dev/null @@ -1,46 +0,0 @@ -import type { ReactNode } from "react"; - -import { cn } from "../../lib/utils.js"; - -export type AccessNoticeProps = { - readonly title: string; - /** What is happening and what to do next. Two sentences at most. */ - readonly description: ReactNode; - /** The way forward — "Contact your administrator", "Sign out". */ - readonly actions?: ReactNode; - readonly brand?: ReactNode; - readonly className?: string; -}; - -/** - * The page someone lands on when they are signed in but have nothing to see: - * awaiting an invitation, awaiting approval, removed from the last workspace - * they had. - * - * A distinct surface rather than an empty dashboard, because those states are - * not emptiness — they are a specific thing that happened, with a specific - * next step, and rendering them as "no items yet" tells someone to wait when - * they should be emailing an administrator. - * - * `

` inside `
`: this is the whole page, not a panel on one. A page - * whose only content is a notice still needs a document outline, and this is it. - * - * No illustration slot. The correct thing here is one sentence saying what to - * do; artwork on a dead end is decoration on someone's bad moment. - */ -export function AccessNotice({ title, description, actions, brand, className }: AccessNoticeProps) { - return ( -
- {brand === undefined ? null : ( -
{brand}
- )} -
-
-

{title}

-

{description}

- {actions === undefined ? null :
{actions}
} -
-
-
- ); -} diff --git a/src/blocks/canvas-host/canvas-host-chrome.tsx b/src/blocks/canvas-host/canvas-host-chrome.tsx deleted file mode 100644 index ff61efc3..00000000 --- a/src/blocks/canvas-host/canvas-host-chrome.tsx +++ /dev/null @@ -1,90 +0,0 @@ -import { Maximize2, MessageCircle, Minimize2, X } from "lucide-react"; - -import { cn } from "../../lib/utils.js"; -import { Badge } from "../../ui/badge.js"; -import { Button } from "../../ui/button.js"; - -/** - * Extends an icon button's hit area to the 40px floor without inflating its - * visual size — `size="icon"` renders at 36px (`size-9`). Same `::after` - * technique as `ReasoningPartView` in `parts-renderer.tsx`, just applied to - * all four sides of a square button instead of the height of a full-width - * row: 2px of inset on every edge closes the 4px gap. - */ -const ICON_HIT_AREA = "relative after:absolute after:-inset-0.5 after:content-['']"; - -/** - * The canvas column's header: title, a neutral "kind" badge, focus-toggle, - * close. No orange here — `bg-primary` is reserved for the one deliberate - * call-to-action a surface has, and a chrome row that's on screen for the - * entire time the canvas is open is not that; both icon buttons stay - * `ghost` even while focused. - */ -export function CanvasHostHeader({ - title, - kind, - focus, - onFocusChange, - onClose, - className, -}: { - readonly title: string; - readonly kind: string; - readonly focus: boolean; - readonly onFocusChange: (focus: boolean) => void; - readonly onClose: () => void; - readonly className?: string; -}) { - return ( -
-
-

{title}

- {kind} -
-
- - -
-
- ); -} - -/** - * A decorative stand-in for the chat column once focus mode has collapsed it - * past usability. `aria-hidden`: it conveys nothing a screen reader needs — - * the real chat content underneath is `invisible` rather than removed (see - * `canvas-host.tsx`'s layout-mode comment), and the way back is the - * focus-toggle button in `CanvasHostHeader` above, not this rail. - */ -export function ChatRailIndicator({ className }: { className?: string }) { - return ( -
- -
- ); -} diff --git a/src/blocks/canvas-host/canvas-host.test.tsx b/src/blocks/canvas-host/canvas-host.test.tsx deleted file mode 100644 index 4a037397..00000000 --- a/src/blocks/canvas-host/canvas-host.test.tsx +++ /dev/null @@ -1,390 +0,0 @@ -import { act } from "react"; -import { createRoot, type Root } from "react-dom/client"; -import { describe, expect, mock, test } from "bun:test"; - -import { - CanvasHost, - CHAT_CONTENT_VISIBILITY_CLASS, - GRID_CLASS, - type CanvasHostContent, -} from "./canvas-host.js"; - -type Mounted = { - container: HTMLElement; - unmount: () => void; -}; - -function render(node: React.ReactElement): Mounted { - const container = document.createElement("div"); - document.body.appendChild(container); - let root!: Root; - act(() => { - root = createRoot(container); - root.render(node); - }); - return { - container, - unmount: () => { - act(() => root.unmount()); - container.remove(); - }, - }; -} - -const CONTENT: CanvasHostContent<{ body: string }> = { - kind: "note", - title: "Release notes", - data: { body: "v1.2.0 shipped" }, -}; - -function noop() {} - -describe("CanvasHost content-driven state", () => { - test("content === null renders the empty state, not the render-prop", () => { - const renderCanvas = mock(() =>
should not render
); - const mounted = render( - , - ); - expect(renderCanvas).not.toHaveBeenCalled(); - expect(mounted.container.querySelector('[data-slot="empty-state"]')).not.toBeNull(); - expect(mounted.container.textContent).toContain("Nothing open"); - mounted.unmount(); - }); - - test("content set calls the render-prop instead of the empty state", () => { - const mounted = render( -
{content.title}
} - focus={false} - onFocusChange={noop} - onClose={noop} - />, - ); - expect(mounted.container.querySelector('[data-slot="empty-state"]')).toBeNull(); - expect(mounted.container.querySelector('[data-testid="canvas-body"]')?.textContent).toBe("Release notes"); - mounted.unmount(); - }); - - test("re-rendering with a new focus value changes the layout — the host, not the component, owns focus", () => { - const container = document.createElement("div"); - document.body.appendChild(container); - let root!: Root; - act(() => { - root = createRoot(container); - root.render( -
{content.title}
} - focus={false} - onFocusChange={noop} - onClose={noop} - />, - ); - }); - expect(container.querySelector('[data-slot="canvas-host"]')?.getAttribute("data-layout")).toBe("split"); - - act(() => { - root.render( -
{content.title}
} - focus={true} - onFocusChange={noop} - onClose={noop} - />, - ); - }); - expect(container.querySelector('[data-slot="canvas-host"]')?.getAttribute("data-layout")).toBe("focus"); - - act(() => root.unmount()); - container.remove(); - }); -}); - -describe("CanvasHost focus prop is fully controlled", () => { - function renderWithFocus(focus: boolean) { - return render( -
{content.title}
} - focus={focus} - onFocusChange={noop} - onClose={noop} - />, - ); - } - - test("focus=false renders split layout", () => { - const mounted = renderWithFocus(false); - expect(mounted.container.querySelector('[data-slot="canvas-host"]')?.getAttribute("data-layout")).toBe("split"); - mounted.unmount(); - }); - - test("focus=true renders focus layout — driven purely by the prop, not internal state", () => { - const mounted = renderWithFocus(true); - expect(mounted.container.querySelector('[data-slot="canvas-host"]')?.getAttribute("data-layout")).toBe("focus"); - mounted.unmount(); - }); -}); - -describe("CanvasHost header interactions", () => { - test("clicking close calls onClose exactly once and nothing else", () => { - const onClose = mock(() => {}); - const onFocusChange = mock(() => {}); - const mounted = render( -
{content.title}
} - focus={false} - onFocusChange={onFocusChange} - onClose={onClose} - />, - ); - const closeButton = mounted.container.querySelector('button[aria-label="Close canvas"]') as HTMLButtonElement; - act(() => closeButton.click()); - expect(onClose).toHaveBeenCalledTimes(1); - expect(onFocusChange).not.toHaveBeenCalled(); - mounted.unmount(); - }); - - test("clicking the focus toggle calls onFocusChange with the next value", () => { - const onFocusChange = mock(() => {}); - const mounted = render( -
{content.title}
} - focus={false} - onFocusChange={onFocusChange} - onClose={noop} - />, - ); - const focusButton = mounted.container.querySelector('button[aria-label="Focus canvas"]') as HTMLButtonElement; - act(() => focusButton.click()); - expect(onFocusChange).toHaveBeenCalledTimes(1); - expect(onFocusChange).toHaveBeenCalledWith(true); - mounted.unmount(); - }); - - test("focus and close are real } - />, - ); - expect(mounted.container.querySelector('[data-slot="chat-panel-footer"]')).not.toBeNull(); - mounted.unmount(); - }); -}); - -describe("CanvasHost layout class tables", () => { - test("GRID_CLASS: split shows chat below `lg`, both columns at `lg`+", () => { - expect(GRID_CLASS.chat).toBe("grid-cols-[minmax(0,1fr)_0px]"); - expect(GRID_CLASS.split).toBe("grid-cols-[minmax(0,1fr)_0px] lg:grid-cols-[minmax(0,1fr)_min(28rem,40%)]"); - expect(GRID_CLASS.focus).toBe("grid-cols-[0px_minmax(0,1fr)] lg:grid-cols-[4rem_minmax(0,1fr)]"); - }); - - test("CHAT_CONTENT_VISIBILITY_CLASS: chat stays visible in chat/split, hidden only in focus", () => { - expect(CHAT_CONTENT_VISIBILITY_CLASS.chat).toBe(""); - expect(CHAT_CONTENT_VISIBILITY_CLASS.split).toBe(""); - expect(CHAT_CONTENT_VISIBILITY_CLASS.focus).toBe("invisible pointer-events-none"); - }); -}); diff --git a/src/blocks/canvas-host/canvas-host.tsx b/src/blocks/canvas-host/canvas-host.tsx deleted file mode 100644 index 58efad69..00000000 --- a/src/blocks/canvas-host/canvas-host.tsx +++ /dev/null @@ -1,203 +0,0 @@ -import { useEffect, type ReactNode } from "react"; - -import { isEscapeConsumedByNestedUI } from "../../hooks/use-chat-dock.js"; -import type { CanvasHostContent } from "../../lib/canvas-host-state.js"; -import type { Part } from "../../lib/chat-parts.js"; -import { cn } from "../../lib/utils.js"; -import { ChatPanel, ChatPanelFooter } from "../../ui/chat-panel.js"; -import { EmptyState } from "../../ui/empty-state.js"; -import { MessageList } from "../../ui/message-list.js"; -import { PartsRenderer } from "../../ui/parts-renderer.js"; -import { CanvasHostHeader, ChatRailIndicator } from "./canvas-host-chrome.js"; - -export type { CanvasHostContent } from "../../lib/canvas-host-state.js"; - -export type CanvasHostMessage = { - readonly id: string; - readonly parts: readonly Part[]; -}; - -export type CanvasHostProps = { - readonly messages: readonly CanvasHostMessage[]; - /** `null` means nothing is open — the canvas column renders its empty state and collapses. */ - readonly content: CanvasHostContent | null; - /** The host's own renderer for `content` — this package knows nothing about what a `kind` means. */ - readonly renderCanvas: (content: CanvasHostContent) => ReactNode; - readonly focus: boolean; - readonly onFocusChange: (focus: boolean) => void; - readonly onClose: () => void; - readonly chatHeader?: ReactNode; - readonly composer?: ReactNode | null; - /** Shown in place of the transcript while `messages` is empty. */ - readonly emptyChat?: ReactNode; - readonly className?: string; -}; - -/** - * Three ways the two columns can occupy the frame. `chat` and `split` differ - * only in whether the canvas track has anything to show; `focus` is the one - * mode a reader chooses deliberately, via the header's focus-toggle. - */ -type CanvasHostLayoutMode = "chat" | "split" | "focus"; - -function resolveLayoutMode(content: CanvasHostContent | null, focus: boolean): CanvasHostLayoutMode { - if (content === null) return "chat"; - return focus ? "focus" : "split"; -} - -/** - * Grid tracks for the chat/canvas pair, keyed by mode. Every entry keeps - * exactly two tracks so the browser can interpolate `grid-template-columns` - * between modes instead of snapping (a transition across a differing track - * count cannot animate) — see the class on the root element below. - * - * Below `lg` (1024px) there is never room for both columns, so `split` and - * `focus` diverge deliberately instead of collapsing to the same thing: - * - * | below `lg` | `lg`+ - * -----------|----------------------------|------------------------------ - * chat | chat full, canvas 0px | chat full, canvas 0px - * split | chat full, canvas 0px | chat + canvas share the row - * focus | canvas full, chat 0px | canvas full, chat rail (4rem) - * - * `split` below `lg` keeps the chat column full-width with the canvas - * merely off-screen (not torn down) — a reader who opens a canvas on a - * phone still has their conversation, and the header's focus toggle is what - * swaps to `focus` (canvas full-width) and back, exactly the affordance the - * toggle already provides at `lg`+. Without this split, any open canvas - * below `lg` would trap the reader away from the composer with no way back - * short of closing the canvas outright. - */ -export const GRID_CLASS: Record = { - chat: "grid-cols-[minmax(0,1fr)_0px]", - split: "grid-cols-[minmax(0,1fr)_0px] lg:grid-cols-[minmax(0,1fr)_min(28rem,40%)]", - focus: "grid-cols-[0px_minmax(0,1fr)] lg:grid-cols-[4rem_minmax(0,1fr)]", -}; - -/** - * Visibility for the chat column's *interactive* content, keyed by mode. - * `invisible` (not `hidden`, not `inert`) is what removes it from both - * hit-testing and the tab order without any JS knowledge of the current - * breakpoint. `chat` and `split` both keep the chat content visible at every - * breakpoint now — in `split`, `GRID_CLASS` alone is what takes the canvas - * column out of view below `lg` (a `0px` track), so no separate visibility - * toggle is needed here. `focus` hides the chat content at every - * breakpoint: below `lg` the canvas owns the whole row, and at `lg`+ the - * chat column is a decorative rail (`ChatRailIndicator`), never real content. - */ -export const CHAT_CONTENT_VISIBILITY_CLASS: Record = { - chat: "", - split: "", - focus: "invisible pointer-events-none", -}; - -/** - * The dual-column generative-UI chat host: chat on the left, a - * consumer-rendered canvas on the right. Fully controlled — `content` and - * `focus` are props, `onFocusChange`/`onClose` are callbacks, and this - * component owns no state of its own beyond the derived `mode` above. A - * host wanting a state container to drive these props can reach for - * `lib/canvas-host-state.ts`'s pure transition functions, but nothing here - * requires it. - * - * Escape steps down exactly like `useChatDock`: focused → split first, then - * split/chat → closed on a second press. A reader who filled the screen - * with the canvas and hits Escape expects to land back at the split view, - * not to lose the canvas outright. - */ -export function CanvasHost({ - messages, - content, - renderCanvas, - focus, - onFocusChange, - onClose, - chatHeader, - composer, - emptyChat, - className, -}: CanvasHostProps) { - const mode = resolveLayoutMode(content, focus); - - useEffect(() => { - if (content === null) return; - const onKeyDown = (event: KeyboardEvent) => { - if (event.key !== "Escape") return; - if (isEscapeConsumedByNestedUI(event, "canvas-host")) return; - if (focus) { - onFocusChange(false); - } else { - onClose(); - } - }; - window.addEventListener("keydown", onKeyDown); - return () => window.removeEventListener("keydown", onKeyDown); - }, [content, focus, onFocusChange, onClose]); - - return ( -
-
- {mode === "focus" ? : null} -
- - {chatHeader} - - {messages.length === 0 - ? emptyChat - : messages.map((message) => ( -
- -
- ))} -
- {/* `null` and omitted must behave identically — no empty bordered footer either way. */} - {composer === undefined || composer === null ? null : {composer}} -
-
-
- -
- {content === null ? ( - - ) : ( - <> - -
-
{renderCanvas(content)}
-
- - )} -
-
- ); -} diff --git a/src/hooks/use-anchored-scroll.test.tsx b/src/hooks/use-anchored-scroll.test.tsx deleted file mode 100644 index ee7b1d96..00000000 --- a/src/hooks/use-anchored-scroll.test.tsx +++ /dev/null @@ -1,193 +0,0 @@ -import { act } from "react"; -import { createRoot, type Root } from "react-dom/client"; -import { describe, expect, test } from "bun:test"; - -import { useAnchoredScroll } from "./use-anchored-scroll.js"; -import { renderHook } from "../test/render-hook.js"; - -/** happy-dom reports zero for scroll metrics unless a test stubs them. */ -function stubScrollMetrics(el: HTMLElement, { scrollHeight, scrollTop, clientHeight }: Record) { - Object.defineProperty(el, "scrollHeight", { configurable: true, value: scrollHeight }); - Object.defineProperty(el, "scrollTop", { configurable: true, value: scrollTop, writable: true }); - Object.defineProperty(el, "clientHeight", { configurable: true, value: clientHeight }); -} - -/** - * happy-dom's `ResizeObserver` never actually fires — there's no layout - * engine behind it to detect a real size change. This stub hands the test - * direct control over the callback the hook registers, so a test can - * trigger it the same way a real resize would. - */ -function stubResizeObserver(): { fire: () => void; restore: () => void } { - let callback: (() => void) | undefined; - class StubResizeObserver { - constructor(cb: () => void) { - callback = cb; - } - observe() {} - disconnect() {} - } - const original = globalThis.ResizeObserver; - globalThis.ResizeObserver = StubResizeObserver as unknown as typeof ResizeObserver; - return { - fire: () => callback?.(), - restore: () => { - globalThis.ResizeObserver = original; - }, - }; -} - -/** - * happy-dom's `scrollHeight`/`clientHeight` getters are real accessors - * backed by an internal field that always reads 0 — there's no layout - * engine behind them. Overriding on `Element.prototype` (not per-instance) - * is what lets a real, freshly-mounted DOM node report non-zero metrics the - * moment `useAnchoredScroll`'s own mount effect reads them. - */ -function withScrollMetrics(scrollHeight: number, clientHeight: number, fn: () => T): T { - const scrollHeightDescriptor = Object.getOwnPropertyDescriptor(Element.prototype, "scrollHeight"); - const clientHeightDescriptor = Object.getOwnPropertyDescriptor(Element.prototype, "clientHeight"); - Object.defineProperty(Element.prototype, "scrollHeight", { configurable: true, get: () => scrollHeight }); - Object.defineProperty(Element.prototype, "clientHeight", { configurable: true, get: () => clientHeight }); - try { - return fn(); - } finally { - if (scrollHeightDescriptor !== undefined) { - Object.defineProperty(Element.prototype, "scrollHeight", scrollHeightDescriptor); - } - if (clientHeightDescriptor !== undefined) { - Object.defineProperty(Element.prototype, "clientHeight", clientHeightDescriptor); - } - } -} - -function ScrollHarness({ itemCount }: { itemCount: number }) { - const { containerRef, contentRef, handleScroll } = useAnchoredScroll(itemCount); - return ( -
-
-
- ); -} - -describe("useAnchoredScroll", () => { - test("pins to the bottom on first render", () => { - const container = document.createElement("div"); - document.body.appendChild(container); - let root!: Root; - - withScrollMetrics(500, 200, () => { - act(() => { - root = createRoot(container); - root.render(); - }); - }); - - const scrollEl = container.firstElementChild as HTMLDivElement; - expect(scrollEl.scrollTop).toBe(500); - - act(() => root.unmount()); - container.remove(); - }); - - test("scrolls to the bottom when itemCount grows while pinned", () => { - let count = 1; - const hook = renderHook(() => useAnchoredScroll(count)); - const el = document.createElement("div"); - stubScrollMetrics(el, { scrollHeight: 500, scrollTop: 300, clientHeight: 200 }); - hook.result.current.containerRef.current = el; - - count = 2; - stubScrollMetrics(el, { scrollHeight: 700, scrollTop: 300, clientHeight: 200 }); - act(() => hook.rerender()); - - expect(el.scrollTop).toBe(700); - hook.unmount(); - }); - - test("does not move scrollTop when the reader has scrolled away from the bottom", () => { - let count = 1; - const hook = renderHook(() => useAnchoredScroll(count)); - const el = document.createElement("div"); - hook.result.current.containerRef.current = el; - - // Reader scrolled up: 300px from the bottom, well past the pin threshold. - stubScrollMetrics(el, { scrollHeight: 900, scrollTop: 100, clientHeight: 200 }); - act(() => hook.result.current.handleScroll()); - - count = 2; - stubScrollMetrics(el, { scrollHeight: 1100, scrollTop: 100, clientHeight: 200 }); - act(() => hook.rerender()); - - expect(el.scrollTop).toBe(100); - hook.unmount(); - }); - - test("re-pins once the reader scrolls back within the bottom threshold", () => { - let count = 1; - const hook = renderHook(() => useAnchoredScroll(count)); - const el = document.createElement("div"); - hook.result.current.containerRef.current = el; - - stubScrollMetrics(el, { scrollHeight: 900, scrollTop: 100, clientHeight: 200 }); - act(() => hook.result.current.handleScroll()); - - // Reader scrolls back down, within the 40px pin threshold. - stubScrollMetrics(el, { scrollHeight: 900, scrollTop: 690, clientHeight: 200 }); - act(() => hook.result.current.handleScroll()); - - count = 2; - stubScrollMetrics(el, { scrollHeight: 1100, scrollTop: 690, clientHeight: 200 }); - act(() => hook.rerender()); - - expect(el.scrollTop).toBe(1100); - hook.unmount(); - }); - - test("re-pins on in-place content growth while itemCount stays constant", () => { - const resizeObserver = stubResizeObserver(); - try { - const hook = renderHook(() => useAnchoredScroll(1)); - const container = document.createElement("div"); - const content = document.createElement("div"); - hook.result.current.containerRef.current = container; - stubScrollMetrics(container, { scrollHeight: 500, scrollTop: 300, clientHeight: 200 }); - - act(() => hook.result.current.contentRef(content)); - - // The content element grows taller in place (streaming text, a - // growing tool-output block) — itemCount never changes, only - // scrollHeight does. - stubScrollMetrics(container, { scrollHeight: 900, scrollTop: 300, clientHeight: 200 }); - act(() => resizeObserver.fire()); - - expect(container.scrollTop).toBe(900); - hook.unmount(); - } finally { - resizeObserver.restore(); - } - }); - - test("does not re-pin on in-place content growth once the reader has scrolled away", () => { - const resizeObserver = stubResizeObserver(); - try { - const hook = renderHook(() => useAnchoredScroll(1)); - const container = document.createElement("div"); - const content = document.createElement("div"); - hook.result.current.containerRef.current = container; - - stubScrollMetrics(container, { scrollHeight: 900, scrollTop: 100, clientHeight: 200 }); - act(() => hook.result.current.handleScroll()); - - act(() => hook.result.current.contentRef(content)); - - stubScrollMetrics(container, { scrollHeight: 1200, scrollTop: 100, clientHeight: 200 }); - act(() => resizeObserver.fire()); - - expect(container.scrollTop).toBe(100); - hook.unmount(); - } finally { - resizeObserver.restore(); - } - }); -}); diff --git a/src/hooks/use-anchored-scroll.ts b/src/hooks/use-anchored-scroll.ts deleted file mode 100644 index d4ef0b8b..00000000 --- a/src/hooks/use-anchored-scroll.ts +++ /dev/null @@ -1,78 +0,0 @@ -import { useCallback, useEffect, useRef, type RefObject } from "react"; - -const BOTTOM_PIN_THRESHOLD_PX = 40; - -export type UseAnchoredScrollResult = { - /** Attach to the scrollable container. */ - readonly containerRef: RefObject; - /** - * Callback ref for the element that wraps the scrollable content (the - * direct child that actually grows: new items, streaming text, an - * expanding tool-output block). A callback ref, not a `RefObject`, so a - * `ResizeObserver` attaches the instant the node mounts rather than only - * on the hook's own first render — it is what catches in-place content - * growth, since `itemCount` alone only fires when an item is added or - * removed, never when an existing item grows taller. - */ - readonly contentRef: (node: HTMLElement | null) => void; - /** Attach to the container's `onScroll`. */ - readonly handleScroll: () => void; -}; - -/** - * Stick-to-bottom scrolling for a growing message list: while the reader is - * at (or near) the bottom, a new item keeps them pinned there; once they - * scroll up to read history, new items arrive quietly and their position - * holds — nothing yanks them back down. - * - * "Near the bottom" is a `BOTTOM_PIN_THRESHOLD_PX` band rather than an exact - * match: a container that is pinned but has a few stray pixels of subpixel - * scroll rounding must still count as pinned, or the reader gets un-pinned by - * their own browser's rendering. - * - * `itemCount` is a single number — the length of whatever list is rendered — - * rather than the list itself, so a new array identity with the same length - * never fires a scroll that has nothing new to show for it. The initial - * render always lands pinned to the bottom, matching how a chat transcript - * should open: at the most recent message. - */ -export function useAnchoredScroll( - itemCount: number, -): UseAnchoredScrollResult { - const containerRef = useRef(null); - const pinnedRef = useRef(true); - const resizeObserverRef = useRef(null); - - const scrollToBottom = () => { - const container = containerRef.current; - if (container === null) return; - container.scrollTop = container.scrollHeight; - }; - - const handleScroll = () => { - const container = containerRef.current; - if (container === null) return; - const distanceFromBottom = container.scrollHeight - container.scrollTop - container.clientHeight; - pinnedRef.current = distanceFromBottom <= BOTTOM_PIN_THRESHOLD_PX; - }; - - useEffect(() => { - if (pinnedRef.current) scrollToBottom(); - }, [itemCount]); - - // Re-pins on in-place content growth — streaming text or a growing - // tool-output block changes the content's height without changing - // `itemCount`, so the effect above alone never catches it. - const contentRef = useCallback((node: HTMLElement | null) => { - resizeObserverRef.current?.disconnect(); - resizeObserverRef.current = null; - if (node === null || typeof ResizeObserver === "undefined") return; - const observer = new ResizeObserver(() => { - if (pinnedRef.current) scrollToBottom(); - }); - observer.observe(node); - resizeObserverRef.current = observer; - }, []); - - return { containerRef, contentRef, handleScroll }; -} diff --git a/src/hooks/use-chat-dock.test.tsx b/src/hooks/use-chat-dock.test.tsx deleted file mode 100644 index 9b5b62e3..00000000 --- a/src/hooks/use-chat-dock.test.tsx +++ /dev/null @@ -1,141 +0,0 @@ -import { act } from "react"; -import { describe, expect, test } from "bun:test"; - -import { useChatDock } from "./use-chat-dock.js"; -import { renderHook } from "../test/render-hook.js"; - -function pressEscape(): void { - act(() => { - window.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" })); - }); -} - -describe("useChatDock", () => { - test("open moves closed -> docked", () => { - const hook = renderHook(() => useChatDock()); - act(() => hook.result.current.open()); - expect(hook.result.current.mode).toBe("docked"); - }); - - test("expand moves docked -> fullpage, collapse moves back", () => { - const hook = renderHook(() => useChatDock("docked")); - act(() => hook.result.current.expand()); - expect(hook.result.current.mode).toBe("fullpage"); - act(() => hook.result.current.collapse()); - expect(hook.result.current.mode).toBe("docked"); - }); - - test("close moves any open mode back to closed", () => { - const hook = renderHook(() => useChatDock("fullpage")); - act(() => hook.result.current.close()); - expect(hook.result.current.mode).toBe("closed"); - }); - - test("open is a no-op once already open", () => { - const hook = renderHook(() => useChatDock("fullpage")); - act(() => hook.result.current.open()); - expect(hook.result.current.mode).toBe("fullpage"); - }); - - test("collapse is a no-op while already closed", () => { - const hook = renderHook(() => useChatDock("closed")); - act(() => hook.result.current.collapse()); - expect(hook.result.current.mode).toBe("closed"); - }); - - test("Escape steps fullpage -> docked, not straight to closed", () => { - const hook = renderHook(() => useChatDock("fullpage")); - pressEscape(); - expect(hook.result.current.mode).toBe("docked"); - }); - - test("Escape closes directly from docked", () => { - const hook = renderHook(() => useChatDock("docked")); - pressEscape(); - expect(hook.result.current.mode).toBe("closed"); - }); - - test("Escape does nothing while closed", () => { - const hook = renderHook(() => useChatDock("closed")); - pressEscape(); - expect(hook.result.current.mode).toBe("closed"); - }); - - test("Escape already handled by nested UI (defaultPrevented) does not step the dock down", () => { - const hook = renderHook(() => useChatDock("fullpage")); - act(() => { - const event = new KeyboardEvent("keydown", { key: "Escape", cancelable: true, bubbles: true }); - event.preventDefault(); - window.dispatchEvent(event); - }); - expect(hook.result.current.mode).toBe("fullpage"); - }); - - test("Escape originating inside a nested overlay (e.g. a native select) does not step the dock down", () => { - const dockContainer = document.createElement("div"); - dockContainer.dataset.slot = "chat-dock"; - document.body.appendChild(dockContainer); - const select = document.createElement("select"); - dockContainer.appendChild(select); - - const hook = renderHook(() => useChatDock("fullpage")); - act(() => { - select.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true, composed: true })); - }); - expect(hook.result.current.mode).toBe("fullpage"); - - dockContainer.remove(); - }); - - test("Escape originating inside a portal-rendered overlay (role=dialog) does not step the dock down, even outside the dock's DOM subtree", () => { - const dockContainer = document.createElement("div"); - dockContainer.dataset.slot = "chat-dock"; - document.body.appendChild(dockContainer); - - const portalOverlay = document.createElement("div"); - portalOverlay.setAttribute("role", "dialog"); - document.body.appendChild(portalOverlay); - - const hook = renderHook(() => useChatDock("fullpage")); - act(() => { - portalOverlay.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true, composed: true })); - }); - expect(hook.result.current.mode).toBe("fullpage"); - - dockContainer.remove(); - portalOverlay.remove(); - }); - - test("Escape from within the dock's own container (no nested overlay) still steps the dock down", () => { - const dockContainer = document.createElement("div"); - dockContainer.dataset.slot = "chat-dock"; - document.body.appendChild(dockContainer); - const button = document.createElement("button"); - dockContainer.appendChild(button); - - const hook = renderHook(() => useChatDock("fullpage")); - act(() => { - button.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true, composed: true })); - }); - expect(hook.result.current.mode).toBe("docked"); - - dockContainer.remove(); - }); - - test("shouldAnimateEntrance is true only when the previous mode was closed", () => { - const hook = renderHook(() => useChatDock()); - expect(hook.result.current.shouldAnimateEntrance).toBe(false); - - act(() => hook.result.current.open()); - expect(hook.result.current.mode).toBe("docked"); - expect(hook.result.current.shouldAnimateEntrance).toBe(true); - - act(() => hook.result.current.expand()); - expect(hook.result.current.mode).toBe("fullpage"); - expect(hook.result.current.shouldAnimateEntrance).toBe(false); - - act(() => hook.result.current.collapse()); - expect(hook.result.current.mode).toBe("docked"); - expect(hook.result.current.shouldAnimateEntrance).toBe(false); - }); -}); diff --git a/src/hooks/use-chat-dock.ts b/src/hooks/use-chat-dock.ts deleted file mode 100644 index b99e709b..00000000 --- a/src/hooks/use-chat-dock.ts +++ /dev/null @@ -1,104 +0,0 @@ -import { useCallback, useEffect, useRef, useState } from "react"; - -/** - * The three ways the dock can occupy the screen. `docked` and `fullpage` are - * both "open" — the difference is size and position only, never a remount — - * so the transition between them is a plain CSS transition on one element. - */ -export type ChatDockMode = "closed" | "docked" | "fullpage"; - -/** - * Elements whose own Escape handling should win over the dock's step-down — - * a ` setTitle(event.target.value)} - placeholder="Name this artifact" - /> - -