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" - /> - -