diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 85924977..72a9063d 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -42,10 +42,11 @@ published. Source imports are relative and carry `.js` extensions. There is no path alias, so the emitted files are valid ESM for Node as well as for every bundler. -The package ships no `"use client"` directives; a React Server Components consumer marks -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. +Every component module that calls a hook, creates a context, defines an inline event +handler, or imports an optional peer (Radix dialog, dropdown-menu or tooltip, or +`sonner`) starts with `"use client"`, and SWC carries the directive into `dist/`. A React Server Components consumer renders any +component directly; stateless components stay server-renderable. Plain-React bundlers +ignore the directive. `dep-guard` enforces the rule, and checks `dist/` after a build. ## No fetch, ever diff --git a/CHANGELOG.md b/CHANGELOG.md index d3cdd4cd..bd18ce71 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -76,6 +76,11 @@ contract — modules that are not exported are internal and may change in any re ### Added +- **`"use client"` on stateful components.** The 45 `ui/` and `blocks/` modules that call + a hook, create a context, define an inline event handler, or import a Radix dialog, + dropdown-menu or tooltip, or `sonner`, ship the directive, so a React Server Components app renders them without + its own client wrapper. + - **`ui/shimmer-text` and `ui/thinking-label`** — a token-pure shimmer sweep over text, and a rotating-verb status label built on it. Verb rotation is width-stable (stacked grid, crossfade) and stops under reduced motion via diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 98b69ee7..50bd13c4 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -77,7 +77,10 @@ around it. back after it ships is not. If it is public but statically imports an *optional* peer, add it to `BARREL_EXCLUDED` in the same file instead — it keeps its subpath but must stay out of the root barrel. `dep-guard` fails if you forget. -4. Do not add `"use client"`. The package ships no client-boundary directives. +4. If a `ui/` or `blocks/` module calls a hook, creates a context, defines an inline + event handler, or imports an optional peer, start it with `"use client"`; + `@radix-ui/react-slot` alone does not count. Leave stateless components and + `hooks/` / `lib/` modules unmarked. `dep-guard` fails either way you get it wrong. 5. If it needs a new package, decide deliberately: a framework-level library the consumer already has is a **peer** dependency, a small implementation detail is a real dependency. Adding a peer is a breaking change for existing consumers. diff --git a/README.md b/README.md index c68fefc7..14d0ec00 100644 --- a/README.md +++ b/README.md @@ -108,7 +108,7 @@ export function Conversation() { Every component is importable by subpath (`@corbits/react-ui/ui/button`); all but the optional-peer modules above are also exported from the root (`@corbits/react-ui`). -This package ships no `"use client"` directives. In a React Server Components app, re-export stateful components from a file you mark yourself, using subpaths rather than the root barrel. +Stateful components ship `"use client"`, so a React Server Components app imports them directly, from the root or a subpath. Other bundlers ignore the directive. ## Theming diff --git a/package.json b/package.json index 3301f364..0570ea64 100644 --- a/package.json +++ b/package.json @@ -27,7 +27,7 @@ "access": "public" }, "scripts": { - "build": "bun run clean && bun run generate && bun run build:js && bun run build:types && bun run build:css && bun run contrast-test", + "build": "bun run clean && bun run generate && bun run build:js && bun run build:types && bun run build:css && bun run contrast-test && bun run dep-guard", "clean": "rm -rf dist", "generate": "node scripts/generate-exports.mjs", "build:js": "swc src -d dist --strip-leading-paths --extensions .ts,.tsx --ignore \"**/*.test.ts\" --ignore \"**/*.test.tsx\"", diff --git a/scripts/dep-guard.mjs b/scripts/dep-guard.mjs index c508fbb0..9cc8f14c 100644 --- a/scripts/dep-guard.mjs +++ b/scripts/dep-guard.mjs @@ -1,4 +1,4 @@ -// Two import rules that types alone cannot enforce. Each one is a property a +// Three source 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 @@ -12,7 +12,13 @@ // Only Node and Vite surface it; a Turbopack consumer builds cleanly, so // this cannot be left to whichever bundler the person testing happened to // use. -import { readdirSync, readFileSync, statSync } from "node:fs"; +// +// 3. A `ui/` or `blocks/` component starts with "use client" exactly when it +// calls a hook, creates a context, defines an inline event handler, or +// imports an optional peer. Missing, it breaks React Server Components +// consumers; extra, it makes a stateless component client-only for no +// reason. After a build, the emitted dist/ module must keep the directive. +import { existsSync, readdirSync, readFileSync, statSync } from "node:fs"; import { dirname, join, relative, resolve } from "node:path"; const ROOT = new URL("../src", import.meta.url).pathname; @@ -85,11 +91,37 @@ for (const path of reachableFrom(barrel)) { } } +// --- Rule 3: client boundaries ------------------------------------------------ + +const CLIENT_ONLY = new RegExp( + [ + String.raw`\buse[A-Z]\w*\s*[<(]`, + "createContext", + String.raw`\bon[A-Z]\w*=\{\s*(\(|\w+\s*=>)`, + `["'](${OPTIONAL_PEERS.map((peer) => peer.replace("/", "\\/")).join("|")})["']`, + ].join("|"), +); +const DIST = new URL("../dist", import.meta.url).pathname; +const stripComments = (source) => source.replace(/\/\*[\s\S]*?\*\/|(^|[^:])\/\/.*$/gm, "$1"); + +for (const path of sources) { + if (!/^(ui|blocks)\//.test(id(path)) || /\.test\.tsx?$/.test(path)) continue; + const source = read(path); + const marked = source.startsWith('"use client";'); + if (CLIENT_ONLY.test(stripComments(source)) !== marked) { + violations.push(`${id(path)}: ${marked ? "stateless but marked" : "stateful but missing"} "use client"`); + } + const built = join(DIST, id(path).replace(/\.tsx?$/, ".js")); + if (marked && existsSync(built) && !read(built).startsWith('"use client";')) { + violations.push(`${id(path)}: the build dropped "use client" from ${relative(DIST, built)}`); + } +} + if (violations.length > 0) { console.error(`dep-guard: forbidden imports\n${[...new Set(violations)].join("\n")}`); process.exit(1); } console.log( `dep-guard: clean (${RULES.map((rule) => rule.label).join(", ")}; ` + - `root barrel free of ${OPTIONAL_PEERS.join(", ")})`, + `root barrel free of ${OPTIONAL_PEERS.join(", ")}; client boundaries match)`, ); diff --git a/src/blocks/login/login-form.tsx b/src/blocks/login/login-form.tsx index 27b8545f..4213553a 100644 --- a/src/blocks/login/login-form.tsx +++ b/src/blocks/login/login-form.tsx @@ -1,3 +1,5 @@ +"use client"; + import { useState } from "react"; import type { ReactNode } from "react"; diff --git a/src/ui/animated-number.tsx b/src/ui/animated-number.tsx index 7e5b0822..9fb517f4 100644 --- a/src/ui/animated-number.tsx +++ b/src/ui/animated-number.tsx @@ -1,3 +1,5 @@ +"use client"; + import { useEffect, useRef, useState } from "react"; import { usePrefersReducedMotion } from "../hooks/use-prefers-reduced-motion.js"; diff --git a/src/ui/approval-card.tsx b/src/ui/approval-card.tsx index 74a67689..c03dac75 100644 --- a/src/ui/approval-card.tsx +++ b/src/ui/approval-card.tsx @@ -1,3 +1,5 @@ +"use client"; + import { useState } from "react"; import type { ReactNode } from "react"; diff --git a/src/ui/boot-screen.tsx b/src/ui/boot-screen.tsx index 6dbad7b3..8835483d 100644 --- a/src/ui/boot-screen.tsx +++ b/src/ui/boot-screen.tsx @@ -1,3 +1,5 @@ +"use client"; + import { useId } from "react"; import type { ReactNode } from "react"; diff --git a/src/ui/bulk-action-bar.tsx b/src/ui/bulk-action-bar.tsx index 675728cf..7d3b5f20 100644 --- a/src/ui/bulk-action-bar.tsx +++ b/src/ui/bulk-action-bar.tsx @@ -1,3 +1,5 @@ +"use client"; + import { useEffect } from "react"; import type { ReactNode } from "react"; diff --git a/src/ui/chat-composer.tsx b/src/ui/chat-composer.tsx index 20d29d82..d6aa1ed4 100644 --- a/src/ui/chat-composer.tsx +++ b/src/ui/chat-composer.tsx @@ -1,3 +1,5 @@ +"use client"; + import { useRef } from "react"; import { useDelayedAutofocus } from "../hooks/use-delayed-autofocus.js"; diff --git a/src/ui/chat-input.tsx b/src/ui/chat-input.tsx index 0d222208..bb67bb98 100644 --- a/src/ui/chat-input.tsx +++ b/src/ui/chat-input.tsx @@ -1,3 +1,5 @@ +"use client"; + import { ArrowUp, Paperclip, Square, X } from "lucide-react"; import { useEffect, useRef, type RefObject } from "react"; diff --git a/src/ui/chat-thread.tsx b/src/ui/chat-thread.tsx index 36f7ef7a..9322f027 100644 --- a/src/ui/chat-thread.tsx +++ b/src/ui/chat-thread.tsx @@ -1,3 +1,5 @@ +"use client"; + import { useEffect, useRef } from "react"; import type { ReactNode } from "react"; diff --git a/src/ui/checkbox.tsx b/src/ui/checkbox.tsx index 8a9371a2..ccc43029 100644 --- a/src/ui/checkbox.tsx +++ b/src/ui/checkbox.tsx @@ -1,3 +1,5 @@ +"use client"; + import { Check, Minus } from "lucide-react"; import { useEffect, useId, useRef } from "react"; diff --git a/src/ui/command-palette.tsx b/src/ui/command-palette.tsx index f97363c7..c47fc9d7 100644 --- a/src/ui/command-palette.tsx +++ b/src/ui/command-palette.tsx @@ -1,3 +1,5 @@ +"use client"; + import { useEffect, useId, useMemo, useRef } from "react"; import type { KeyboardEvent as ReactKeyboardEvent, ReactNode, RefObject } from "react"; diff --git a/src/ui/confirm-button.tsx b/src/ui/confirm-button.tsx index 66018be3..86e753ed 100644 --- a/src/ui/confirm-button.tsx +++ b/src/ui/confirm-button.tsx @@ -1,3 +1,5 @@ +"use client"; + import { useEffect, useRef, useState } from "react"; import type { ReactNode } from "react"; diff --git a/src/ui/csv-table.tsx b/src/ui/csv-table.tsx index 1e5964ad..b0c79c10 100644 --- a/src/ui/csv-table.tsx +++ b/src/ui/csv-table.tsx @@ -1,3 +1,5 @@ +"use client"; + import { useMemo } from "react"; import { capCsv, isTabular, parseCsv } from "../lib/csv.js"; diff --git a/src/ui/dialog.tsx b/src/ui/dialog.tsx index b82a0427..d6166fbf 100644 --- a/src/ui/dialog.tsx +++ b/src/ui/dialog.tsx @@ -1,3 +1,5 @@ +"use client"; + import * as DialogPrimitive from "@radix-ui/react-dialog"; import { XIcon } from "lucide-react"; import type * as React from "react"; diff --git a/src/ui/dither-canvas.tsx b/src/ui/dither-canvas.tsx index 27870b78..54077991 100644 --- a/src/ui/dither-canvas.tsx +++ b/src/ui/dither-canvas.tsx @@ -1,3 +1,5 @@ +"use client"; + import { useEffect, useRef } from "react"; import { usePrefersReducedMotion } from "../hooks/use-prefers-reduced-motion.js"; diff --git a/src/ui/file-input.tsx b/src/ui/file-input.tsx index 5d9b4817..1744dab5 100644 --- a/src/ui/file-input.tsx +++ b/src/ui/file-input.tsx @@ -1,3 +1,5 @@ +"use client"; + import { Upload } from "lucide-react"; import { useRef, useState } from "react"; import type * as React from "react"; diff --git a/src/ui/filter-bar.tsx b/src/ui/filter-bar.tsx index f4580506..20a5cbcb 100644 --- a/src/ui/filter-bar.tsx +++ b/src/ui/filter-bar.tsx @@ -1,3 +1,5 @@ +"use client"; + import { X } from "lucide-react"; import type { ReactNode } from "react"; diff --git a/src/ui/horizontal-stepper.tsx b/src/ui/horizontal-stepper.tsx index 7a73732f..c722a9ee 100644 --- a/src/ui/horizontal-stepper.tsx +++ b/src/ui/horizontal-stepper.tsx @@ -1,3 +1,5 @@ +"use client"; + import { useScrollCurrentIntoView } from "../hooks/use-scroll-current-into-view.js"; import type { WorkflowStep, WorkflowStepStatus } from "../lib/workflow-run-progress.js"; import { workflowStepGlyph, workflowStepLabelClass } from "../lib/workflow-run-progress.js"; diff --git a/src/ui/intake-form.tsx b/src/ui/intake-form.tsx index ca31366e..1ac45e2d 100644 --- a/src/ui/intake-form.tsx +++ b/src/ui/intake-form.tsx @@ -1,3 +1,5 @@ +"use client"; + import { useId } from "react"; import { cn } from "../lib/utils.js"; diff --git a/src/ui/kind-card-grid.tsx b/src/ui/kind-card-grid.tsx index 3b280f1e..b733cd05 100644 --- a/src/ui/kind-card-grid.tsx +++ b/src/ui/kind-card-grid.tsx @@ -1,3 +1,5 @@ +"use client"; + import type { ReactNode } from "react"; import { cn } from "../lib/utils.js"; diff --git a/src/ui/menu.tsx b/src/ui/menu.tsx index bb40ade4..94646205 100644 --- a/src/ui/menu.tsx +++ b/src/ui/menu.tsx @@ -1,3 +1,5 @@ +"use client"; + import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"; import type * as React from "react"; diff --git a/src/ui/mic-button.tsx b/src/ui/mic-button.tsx index c8228a0b..9dd717c5 100644 --- a/src/ui/mic-button.tsx +++ b/src/ui/mic-button.tsx @@ -1,3 +1,5 @@ +"use client"; + import { Mic, MicOff, Square } from "lucide-react"; import { cn } from "../lib/utils.js"; diff --git a/src/ui/mic-permission-dialog.tsx b/src/ui/mic-permission-dialog.tsx index f91948e0..82232e5d 100644 --- a/src/ui/mic-permission-dialog.tsx +++ b/src/ui/mic-permission-dialog.tsx @@ -1,3 +1,5 @@ +"use client"; + import { Button } from "./button.js"; import { Dialog, diff --git a/src/ui/notifications-bell.tsx b/src/ui/notifications-bell.tsx index 26fda6b3..c9c23e81 100644 --- a/src/ui/notifications-bell.tsx +++ b/src/ui/notifications-bell.tsx @@ -1,3 +1,5 @@ +"use client"; + import { Bell } from "lucide-react"; import { useEffect, useId, useRef } from "react"; import type { ReactNode } from "react"; diff --git a/src/ui/parts-renderer.tsx b/src/ui/parts-renderer.tsx index 01121189..d298649e 100644 --- a/src/ui/parts-renderer.tsx +++ b/src/ui/parts-renderer.tsx @@ -1,3 +1,5 @@ +"use client"; + import { ChevronRight, Paperclip } from "lucide-react"; import { useState } from "react"; diff --git a/src/ui/quick-reply-chips.tsx b/src/ui/quick-reply-chips.tsx index f743cadd..c103d944 100644 --- a/src/ui/quick-reply-chips.tsx +++ b/src/ui/quick-reply-chips.tsx @@ -1,3 +1,5 @@ +"use client"; + import type { QuickReply } from "../lib/chat-message.js"; import { cn } from "../lib/utils.js"; diff --git a/src/ui/quote-card.tsx b/src/ui/quote-card.tsx index 8dd3166b..6b42160d 100644 --- a/src/ui/quote-card.tsx +++ b/src/ui/quote-card.tsx @@ -1,3 +1,5 @@ +"use client"; + import { useState } from "react"; import { cn } from "../lib/utils.js"; diff --git a/src/ui/reasoning-block.tsx b/src/ui/reasoning-block.tsx index f28ce7d3..6d1aedd4 100644 --- a/src/ui/reasoning-block.tsx +++ b/src/ui/reasoning-block.tsx @@ -1,3 +1,5 @@ +"use client"; + import { ChevronRight } from "lucide-react"; import { useControllableState } from "../hooks/use-controllable-state.js"; diff --git a/src/ui/run-now-button.tsx b/src/ui/run-now-button.tsx index 9e53707d..a761a3fd 100644 --- a/src/ui/run-now-button.tsx +++ b/src/ui/run-now-button.tsx @@ -1,3 +1,5 @@ +"use client"; + import { useEffect, useRef, useState } from "react"; import { Button, type ButtonProps } from "./button.js"; diff --git a/src/ui/selection-checkbox.tsx b/src/ui/selection-checkbox.tsx index 29b69474..d4e9187e 100644 --- a/src/ui/selection-checkbox.tsx +++ b/src/ui/selection-checkbox.tsx @@ -1,3 +1,5 @@ +"use client"; + import { Check, Minus } from "lucide-react"; import { useId } from "react"; diff --git a/src/ui/sidebar-item-row.tsx b/src/ui/sidebar-item-row.tsx index 0689ffc5..89af3a5f 100644 --- a/src/ui/sidebar-item-row.tsx +++ b/src/ui/sidebar-item-row.tsx @@ -1,3 +1,5 @@ +"use client"; + import { useEffect, useState } from "react"; import type * as React from "react"; diff --git a/src/ui/sortable-table.tsx b/src/ui/sortable-table.tsx index 3a569728..df11aed3 100644 --- a/src/ui/sortable-table.tsx +++ b/src/ui/sortable-table.tsx @@ -1,3 +1,5 @@ +"use client"; + import { ChevronDown, ChevronUp, ChevronsUpDown } from "lucide-react"; import { useMemo, useState } from "react"; import type { ReactNode } from "react"; diff --git a/src/ui/switch.tsx b/src/ui/switch.tsx index 064bd923..f8d69268 100644 --- a/src/ui/switch.tsx +++ b/src/ui/switch.tsx @@ -1,3 +1,5 @@ +"use client"; + import { cn } from "../lib/utils.js"; export type SwitchProps = { diff --git a/src/ui/tabs.tsx b/src/ui/tabs.tsx index df4ce414..71274927 100644 --- a/src/ui/tabs.tsx +++ b/src/ui/tabs.tsx @@ -1,3 +1,5 @@ +"use client"; + import { useId, useRef } from "react"; import type { ReactNode } from "react"; diff --git a/src/ui/textarea.tsx b/src/ui/textarea.tsx index 291120da..ad319367 100644 --- a/src/ui/textarea.tsx +++ b/src/ui/textarea.tsx @@ -1,3 +1,5 @@ +"use client"; + import type * as React from "react"; import { useEffect, useRef } from "react"; diff --git a/src/ui/theme-provider.tsx b/src/ui/theme-provider.tsx index 7dd10bba..da7e589a 100644 --- a/src/ui/theme-provider.tsx +++ b/src/ui/theme-provider.tsx @@ -1,3 +1,5 @@ +"use client"; + import { createContext, useCallback, diff --git a/src/ui/theme-toggle.tsx b/src/ui/theme-toggle.tsx index 094a3574..c3f48931 100644 --- a/src/ui/theme-toggle.tsx +++ b/src/ui/theme-toggle.tsx @@ -1,3 +1,5 @@ +"use client"; + import { Monitor, Moon, Sun } from "lucide-react"; import { Button, type ButtonProps } from "./button.js"; diff --git a/src/ui/thinking-label.tsx b/src/ui/thinking-label.tsx index b6071819..b391e0fa 100644 --- a/src/ui/thinking-label.tsx +++ b/src/ui/thinking-label.tsx @@ -1,3 +1,5 @@ +"use client"; + import { useEffect, useState } from "react"; import { usePrefersReducedMotion } from "../hooks/use-prefers-reduced-motion.js"; diff --git a/src/ui/thinking-mark.tsx b/src/ui/thinking-mark.tsx index f405f49e..f3a5302c 100644 --- a/src/ui/thinking-mark.tsx +++ b/src/ui/thinking-mark.tsx @@ -1,3 +1,5 @@ +"use client"; + import { useId, type CSSProperties } from "react"; import { cn } from "../lib/utils.js"; import { CORBITS_MARK_PATH } from "./corbits-mark.js"; diff --git a/src/ui/time-series-chart.tsx b/src/ui/time-series-chart.tsx index 2bfcf30c..472b77cf 100644 --- a/src/ui/time-series-chart.tsx +++ b/src/ui/time-series-chart.tsx @@ -1,3 +1,5 @@ +"use client"; + import { useRef, useState } from "react"; import { areaPath, formatCompact, linePath, niceTicks, scaleLinear } from "../lib/chart-geometry.js"; diff --git a/src/ui/toast.tsx b/src/ui/toast.tsx index 0281b3c9..e12aa64c 100644 --- a/src/ui/toast.tsx +++ b/src/ui/toast.tsx @@ -1,3 +1,5 @@ +"use client"; + import { Toaster as Sonner, toast as sonnerToast } from "sonner"; const TOAST_DURATION_MS = 1800; diff --git a/src/ui/tool-block.tsx b/src/ui/tool-block.tsx index 366adbb3..47673f03 100644 --- a/src/ui/tool-block.tsx +++ b/src/ui/tool-block.tsx @@ -1,3 +1,5 @@ +"use client"; + import { ChevronRight } from "lucide-react"; import { useControllableState } from "../hooks/use-controllable-state.js"; diff --git a/src/ui/tool-narrative.tsx b/src/ui/tool-narrative.tsx index 13b47db1..751dd362 100644 --- a/src/ui/tool-narrative.tsx +++ b/src/ui/tool-narrative.tsx @@ -1,3 +1,5 @@ +"use client"; + import { ChevronRight } from "lucide-react"; import { useControllableState } from "../hooks/use-controllable-state.js"; diff --git a/src/ui/tool-picker.tsx b/src/ui/tool-picker.tsx index a9f96ca4..c5635a52 100644 --- a/src/ui/tool-picker.tsx +++ b/src/ui/tool-picker.tsx @@ -1,3 +1,5 @@ +"use client"; + import { useEffect, useId, useMemo, useRef, useState } from "react"; import type { KeyboardEvent as ReactKeyboardEvent, ReactNode } from "react"; diff --git a/src/ui/tooltip.tsx b/src/ui/tooltip.tsx index ab8c3dc5..65913a1e 100644 --- a/src/ui/tooltip.tsx +++ b/src/ui/tooltip.tsx @@ -1,3 +1,5 @@ +"use client"; + import * as TooltipPrimitive from "@radix-ui/react-tooltip"; import { InfoIcon } from "lucide-react"; diff --git a/src/ui/view-toggle.tsx b/src/ui/view-toggle.tsx index 1cb24ad3..fa3e1ece 100644 --- a/src/ui/view-toggle.tsx +++ b/src/ui/view-toggle.tsx @@ -1,3 +1,5 @@ +"use client"; + import { LayoutGrid, Rows3 } from "lucide-react"; import { cn } from "../lib/utils.js";