Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 5 additions & 4 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 4 additions & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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\"",
Expand Down
38 changes: 35 additions & 3 deletions scripts/dep-guard.mjs
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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;
Expand Down Expand Up @@ -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)`,
);
2 changes: 2 additions & 0 deletions src/blocks/login/login-form.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
"use client";

import { useState } from "react";
import type { ReactNode } from "react";

Expand Down
2 changes: 2 additions & 0 deletions src/ui/animated-number.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
"use client";

import { useEffect, useRef, useState } from "react";

import { usePrefersReducedMotion } from "../hooks/use-prefers-reduced-motion.js";
Expand Down
2 changes: 2 additions & 0 deletions src/ui/approval-card.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
"use client";

import { useState } from "react";
import type { ReactNode } from "react";

Expand Down
2 changes: 2 additions & 0 deletions src/ui/boot-screen.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
"use client";

import { useId } from "react";
import type { ReactNode } from "react";

Expand Down
2 changes: 2 additions & 0 deletions src/ui/bulk-action-bar.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
"use client";

import { useEffect } from "react";
import type { ReactNode } from "react";

Expand Down
2 changes: 2 additions & 0 deletions src/ui/chat-composer.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
"use client";

import { useRef } from "react";

import { useDelayedAutofocus } from "../hooks/use-delayed-autofocus.js";
Expand Down
2 changes: 2 additions & 0 deletions src/ui/chat-input.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
"use client";

import { ArrowUp, Paperclip, Square, X } from "lucide-react";
import { useEffect, useRef, type RefObject } from "react";

Expand Down
2 changes: 2 additions & 0 deletions src/ui/chat-thread.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
"use client";

import { useEffect, useRef } from "react";
import type { ReactNode } from "react";

Expand Down
2 changes: 2 additions & 0 deletions src/ui/checkbox.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
"use client";

import { Check, Minus } from "lucide-react";
import { useEffect, useId, useRef } from "react";

Expand Down
2 changes: 2 additions & 0 deletions src/ui/command-palette.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
"use client";

import { useEffect, useId, useMemo, useRef } from "react";
import type { KeyboardEvent as ReactKeyboardEvent, ReactNode, RefObject } from "react";

Expand Down
2 changes: 2 additions & 0 deletions src/ui/confirm-button.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
"use client";

import { useEffect, useRef, useState } from "react";
import type { ReactNode } from "react";

Expand Down
2 changes: 2 additions & 0 deletions src/ui/csv-table.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
"use client";

import { useMemo } from "react";

import { capCsv, isTabular, parseCsv } from "../lib/csv.js";
Expand Down
2 changes: 2 additions & 0 deletions src/ui/dialog.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down
2 changes: 2 additions & 0 deletions src/ui/dither-canvas.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
"use client";

import { useEffect, useRef } from "react";

import { usePrefersReducedMotion } from "../hooks/use-prefers-reduced-motion.js";
Expand Down
2 changes: 2 additions & 0 deletions src/ui/file-input.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
"use client";

import { Upload } from "lucide-react";
import { useRef, useState } from "react";
import type * as React from "react";
Expand Down
2 changes: 2 additions & 0 deletions src/ui/filter-bar.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
"use client";

import { X } from "lucide-react";
import type { ReactNode } from "react";

Expand Down
2 changes: 2 additions & 0 deletions src/ui/horizontal-stepper.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down
2 changes: 2 additions & 0 deletions src/ui/intake-form.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
"use client";

import { useId } from "react";

import { cn } from "../lib/utils.js";
Expand Down
2 changes: 2 additions & 0 deletions src/ui/kind-card-grid.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
"use client";

import type { ReactNode } from "react";

import { cn } from "../lib/utils.js";
Expand Down
2 changes: 2 additions & 0 deletions src/ui/menu.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
"use client";

import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
import type * as React from "react";

Expand Down
2 changes: 2 additions & 0 deletions src/ui/mic-button.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
"use client";

import { Mic, MicOff, Square } from "lucide-react";

import { cn } from "../lib/utils.js";
Expand Down
2 changes: 2 additions & 0 deletions src/ui/mic-permission-dialog.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
"use client";

import { Button } from "./button.js";
import {
Dialog,
Expand Down
2 changes: 2 additions & 0 deletions src/ui/notifications-bell.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
"use client";

import { Bell } from "lucide-react";
import { useEffect, useId, useRef } from "react";
import type { ReactNode } from "react";
Expand Down
2 changes: 2 additions & 0 deletions src/ui/parts-renderer.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
"use client";

import { ChevronRight, Paperclip } from "lucide-react";
import { useState } from "react";

Expand Down
2 changes: 2 additions & 0 deletions src/ui/quick-reply-chips.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
"use client";

import type { QuickReply } from "../lib/chat-message.js";
import { cn } from "../lib/utils.js";

Expand Down
2 changes: 2 additions & 0 deletions src/ui/quote-card.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
"use client";

import { useState } from "react";

import { cn } from "../lib/utils.js";
Expand Down
2 changes: 2 additions & 0 deletions src/ui/reasoning-block.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
"use client";

import { ChevronRight } from "lucide-react";

import { useControllableState } from "../hooks/use-controllable-state.js";
Expand Down
2 changes: 2 additions & 0 deletions src/ui/run-now-button.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
"use client";

import { useEffect, useRef, useState } from "react";

import { Button, type ButtonProps } from "./button.js";
Expand Down
2 changes: 2 additions & 0 deletions src/ui/selection-checkbox.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
"use client";

import { Check, Minus } from "lucide-react";
import { useId } from "react";

Expand Down
2 changes: 2 additions & 0 deletions src/ui/sidebar-item-row.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
"use client";

import { useEffect, useState } from "react";
import type * as React from "react";

Expand Down
2 changes: 2 additions & 0 deletions src/ui/sortable-table.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
"use client";

import { ChevronDown, ChevronUp, ChevronsUpDown } from "lucide-react";
import { useMemo, useState } from "react";
import type { ReactNode } from "react";
Expand Down
2 changes: 2 additions & 0 deletions src/ui/switch.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
"use client";

import { cn } from "../lib/utils.js";

export type SwitchProps = {
Expand Down
2 changes: 2 additions & 0 deletions src/ui/tabs.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
"use client";

import { useId, useRef } from "react";
import type { ReactNode } from "react";

Expand Down
2 changes: 2 additions & 0 deletions src/ui/textarea.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
"use client";

import type * as React from "react";
import { useEffect, useRef } from "react";

Expand Down
2 changes: 2 additions & 0 deletions src/ui/theme-provider.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
"use client";

import {
createContext,
useCallback,
Expand Down
2 changes: 2 additions & 0 deletions src/ui/theme-toggle.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
"use client";

import { Monitor, Moon, Sun } from "lucide-react";

import { Button, type ButtonProps } from "./button.js";
Expand Down
2 changes: 2 additions & 0 deletions src/ui/thinking-label.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
"use client";

import { useEffect, useState } from "react";

import { usePrefersReducedMotion } from "../hooks/use-prefers-reduced-motion.js";
Expand Down
2 changes: 2 additions & 0 deletions src/ui/thinking-mark.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down
2 changes: 2 additions & 0 deletions src/ui/time-series-chart.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
"use client";

import { useRef, useState } from "react";

import { areaPath, formatCompact, linePath, niceTicks, scaleLinear } from "../lib/chart-geometry.js";
Expand Down
2 changes: 2 additions & 0 deletions src/ui/toast.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
"use client";

import { Toaster as Sonner, toast as sonnerToast } from "sonner";

const TOAST_DURATION_MS = 1800;
Expand Down
2 changes: 2 additions & 0 deletions src/ui/tool-block.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
"use client";

import { ChevronRight } from "lucide-react";

import { useControllableState } from "../hooks/use-controllable-state.js";
Expand Down
2 changes: 2 additions & 0 deletions src/ui/tool-narrative.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
"use client";

import { ChevronRight } from "lucide-react";

import { useControllableState } from "../hooks/use-controllable-state.js";
Expand Down
2 changes: 2 additions & 0 deletions src/ui/tool-picker.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
"use client";

import { useEffect, useId, useMemo, useRef, useState } from "react";
import type { KeyboardEvent as ReactKeyboardEvent, ReactNode } from "react";

Expand Down
2 changes: 2 additions & 0 deletions src/ui/tooltip.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
"use client";

import * as TooltipPrimitive from "@radix-ui/react-tooltip";
import { InfoIcon } from "lucide-react";

Expand Down
2 changes: 2 additions & 0 deletions src/ui/view-toggle.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
"use client";

import { LayoutGrid, Rows3 } from "lucide-react";

import { cn } from "../lib/utils.js";
Expand Down
Loading