From a063c6a0a094f8baa54e3bbe29bd367913e430c5 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Mon, 10 Aug 2026 02:52:26 -0700 Subject: [PATCH 001/117] Exclude co-located tests from the public exports surface (#11) generate-exports walked every .ts/.tsx under src/, including *.test.*, and re-exported them from the root barrel. Consumers that import from @corbits/react-ui then pull bun:test into their vite/rollup build and fail. Filter test modules out of both the barrel and the exports map. --- scripts/generate-exports.mjs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/scripts/generate-exports.mjs b/scripts/generate-exports.mjs index fc97c7d2..a69c6cb7 100644 --- a/scripts/generate-exports.mjs +++ b/scripts/generate-exports.mjs @@ -34,6 +34,9 @@ const walk = (dir) => const modules = walk(SRC) .filter((path) => /\.tsx?$/.test(path)) + // Tests are co-located under src/ and must never enter the public surface — + // re-exporting them from the barrel pulls bun:test into every consumer build. + .filter((path) => !/\.test\.tsx?$/.test(path)) .map((path) => relative(SRC, path).replace(/\.tsx?$/, "")) .filter( (id) => From 7668a1acd125e8c30e539c35b57f135e4ba0f63a Mon Sep 17 00:00:00 2001 From: Pratik <62953475+pratikbuilds@users.noreply.github.com> Date: Mon, 10 Aug 2026 15:37:36 +0545 Subject: [PATCH 002/117] Add Dialog stories for center and sheet placements (#14) * Add Dialog stories for center and sheet placements * Lengthen the right-sheet story so DialogBody actually scrolls --- stories/primitives/dialog.stories.tsx | 106 ++++++++++++++++++++++++++ 1 file changed, 106 insertions(+) create mode 100644 stories/primitives/dialog.stories.tsx diff --git a/stories/primitives/dialog.stories.tsx b/stories/primitives/dialog.stories.tsx new file mode 100644 index 00000000..936b1b28 --- /dev/null +++ b/stories/primitives/dialog.stories.tsx @@ -0,0 +1,106 @@ +import { Button } from "../../src/ui/button.js"; +import { + Dialog, + DialogBody, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "../../src/ui/dialog.js"; +import { Input } from "../../src/ui/input.js"; + +export default { title: "Primitives / Dialog" }; + +export const Center = () => ( + + + + + + + Archive this run? + + Archived runs stay searchable. You can restore them later from the archive. + + + + + + + + + + + + +); + +export const RightSheet = () => ( + + + + + + + Run details + Inspect the latest attempt without leaving the list. + + +

+ Status: Succeeded +

+

+ Duration: 1m 42s +

+

+ Trigger: schedule / daily-9am +

+ {Array.from({ length: 40 }, (_, i) => ( +

+ Step {i + 1}: completed with no warnings. +

+ ))} +
+ + + + + +
+
+); + +export const LeftSheet = () => ( + + + + + + + Filters + Narrow the workflow list. Escape or the X closes this sheet. + + + + + + + + + + + + + + + +); From fbc52580ab796088be4809989b0fcee8f6aa1e37 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Thu, 13 Aug 2026 23:28:27 -0700 Subject: [PATCH 003/117] Allowlist URL schemes at every agent/artifact-supplied link sink (#19) * Allowlist URL schemes at every agent/artifact-supplied link sink Citation, quote, and download links throughout the library render agent- and artifact-supplied strings straight into href/src. Without a scheme check, a javascript: or data: URL in a research citation, a quote source, an artifact download, or an add-artifact link submission becomes a live, clickable script. Adds src/lib/url.ts: isSafeUrl/toSafeHref, a shared parse-based allowlist (new URL(x).protocol against http:/https: by default). This generalizes the https-only check embed-body.tsx already had for its iframe src, rather than growing a second one. Relative and protocol-relative URLs fail to parse without a base and are rejected, since every sink here links to a third-party resource with no in-app page a bare path could mean. Non-allowlisted values never become a placeholder href like "#" - they fall back to plain text. Applied to research-body.tsx (citation/quote/cluster-item links), artifact-body.tsx (download links and image src), embed-body.tsx (iframe src and its download link, generalized from the prior local check), and add-artifact-dialog.tsx (link intake validation). * Allowlist the artifact gallery card thumbnail src too Missed in the first pass: ArtifactCard renders artifact.thumbnailUrl straight into an img src, the same field already guarded in artifact-body.tsx's image form. --- package.json | 4 ++ src/index.ts | 1 + src/lib/url.test.ts | 81 +++++++++++++++++++++ src/lib/url.ts | 41 +++++++++++ src/ui/add-artifact-dialog.tsx | 5 +- src/ui/artifact-body.tsx | 20 +++--- src/ui/artifact-gallery.tsx | 6 +- src/ui/embed-body.tsx | 24 ++----- src/ui/research-body.test.tsx | 92 ++++++++++++++++++++++++ src/ui/research-body.tsx | 126 +++++++++++++++++++-------------- 10 files changed, 313 insertions(+), 87 deletions(-) create mode 100644 src/lib/url.test.ts create mode 100644 src/lib/url.ts create mode 100644 src/ui/research-body.test.tsx diff --git a/package.json b/package.json index 537c0b40..f5da86b6 100644 --- a/package.json +++ b/package.json @@ -199,6 +199,10 @@ "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" + }, "./lib/utils": { "types": "./dist/lib/utils.d.ts", "default": "./dist/lib/utils.js" diff --git a/src/index.ts b/src/index.ts index cb50d5b5..5304aa23 100644 --- a/src/index.ts +++ b/src/index.ts @@ -35,6 +35,7 @@ export * from "./lib/relative-time.js"; export * from "./lib/schedule.js"; export * from "./lib/theme.js"; export * from "./lib/time-range.js"; +export * from "./lib/url.js"; export * from "./lib/utils.js"; export * from "./lib/workflow-display-flow.js"; export * from "./lib/workflow-registry.js"; diff --git a/src/lib/url.test.ts b/src/lib/url.test.ts new file mode 100644 index 00000000..809000ea --- /dev/null +++ b/src/lib/url.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, test } from "bun:test"; + +import { isSafeUrl, toSafeHref } from "./url.js"; + +describe("isSafeUrl", () => { + test("accepts http and https URLs", () => { + expect(isSafeUrl("https://example.com/report")).toBe(true); + expect(isSafeUrl("http://example.com/report")).toBe(true); + }); + + test("rejects javascript: URLs", () => { + expect(isSafeUrl("javascript:alert(1)")).toBe(false); + }); + + test("rejects javascript: URLs regardless of case", () => { + expect(isSafeUrl("JavaScript:alert(1)")).toBe(false); + }); + + test("rejects javascript: URLs with an embedded tab", () => { + expect(isSafeUrl("java\tscript:alert(1)")).toBe(false); + }); + + test("rejects javascript: URLs with an embedded newline", () => { + expect(isSafeUrl("java\nscript:alert(1)")).toBe(false); + }); + + test("rejects javascript: URLs with leading whitespace", () => { + expect(isSafeUrl(" javascript:alert(1)")).toBe(false); + }); + + test("rejects data: URLs", () => { + expect(isSafeUrl("data:text/html,")).toBe(false); + }); + + test("rejects vbscript: URLs", () => { + expect(isSafeUrl("vbscript:msgbox(1)")).toBe(false); + }); + + test("rejects file: URLs", () => { + expect(isSafeUrl("file:///etc/passwd")).toBe(false); + }); + + test("rejects protocol-relative URLs", () => { + expect(isSafeUrl("//evil.example/payload")).toBe(false); + }); + + test("rejects relative paths", () => { + expect(isSafeUrl("/relative/path")).toBe(false); + expect(isSafeUrl("relative/path")).toBe(false); + }); + + test("rejects unparseable strings", () => { + expect(isSafeUrl("not a url")).toBe(false); + expect(isSafeUrl("")).toBe(false); + }); + + test("honors a narrower allowlist", () => { + expect(isSafeUrl("http://example.com", ["https:"])).toBe(false); + expect(isSafeUrl("https://example.com", ["https:"])).toBe(true); + }); +}); + +describe("toSafeHref", () => { + test("returns the URL unchanged when safe", () => { + expect(toSafeHref("https://example.com/x")).toBe("https://example.com/x"); + }); + + test("returns undefined for an unsafe scheme", () => { + expect(toSafeHref("javascript:alert(1)")).toBeUndefined(); + }); + + test("returns undefined for undefined input", () => { + expect(toSafeHref(undefined)).toBeUndefined(); + }); + + test("never returns a placeholder like '#' for unsafe input", () => { + const result = toSafeHref("javascript:alert(1)"); + expect(result).not.toBe("#"); + expect(result).toBeUndefined(); + }); +}); diff --git a/src/lib/url.ts b/src/lib/url.ts new file mode 100644 index 00000000..2a1307a2 --- /dev/null +++ b/src/lib/url.ts @@ -0,0 +1,41 @@ +const DEFAULT_ALLOWED_PROTOCOLS: readonly string[] = ["http:", "https:"]; + +/** + * Is `url` safe to hand to a live sink — `href`, `src`, `window.open` — as-is? + * + * Agent- and artifact-supplied strings are untrusted input: a `javascript:` + * or `data:` value handed straight to `href` becomes a live script the + * moment someone clicks it. Checking `.protocol` after `new URL()` parses the + * string, rather than matching a prefix, is what defeats tricks like embedded + * tabs/newlines (`java\tscript:`) or mixed case (`JavaScript:`) — the URL + * parser strips and normalizes both away before the comparison ever runs. + * + * Relative and protocol-relative URLs (`/path`, `//host/path`) fail to parse + * without a base and are rejected rather than resolved against one: every + * sink this guards links to a third-party resource — a citation, a download, + * an embed — so there is no in-app page a bare path could sensibly mean, and + * inventing a base to resolve against would only guess wrong. + */ +export function isSafeUrl(url: string, allowedProtocols: readonly string[] = DEFAULT_ALLOWED_PROTOCOLS): boolean { + try { + return allowedProtocols.includes(new URL(url).protocol); + } catch { + return false; + } +} + +/** + * `url` back, unchanged, if `isSafeUrl` accepts it — otherwise `undefined`. + * + * The shape callers want at a render site: a value they can put straight + * into `href`/`src` when present, and a clean signal to fall back to plain + * text when not. Never returns a placeholder like `"#"` — a disabled-looking + * link that is secretly still a link is its own trap. + */ +export function toSafeHref( + url: string | undefined, + allowedProtocols: readonly string[] = DEFAULT_ALLOWED_PROTOCOLS, +): string | undefined { + if (url === undefined) return undefined; + return isSafeUrl(url, allowedProtocols) ? url : undefined; +} diff --git a/src/ui/add-artifact-dialog.tsx b/src/ui/add-artifact-dialog.tsx index fce2723e..c2d16656 100644 --- a/src/ui/add-artifact-dialog.tsx +++ b/src/ui/add-artifact-dialog.tsx @@ -1,5 +1,6 @@ import { useState } from "react"; +import { isSafeUrl } from "../lib/url.js"; import { Button } from "./button.js"; import { Dialog, @@ -85,9 +86,7 @@ export function AddArtifactDialog({ open, onOpenChange, onSubmit }: AddArtifactD if (trimmedTitle === "") return "Give the artifact a name."; if (trimmedValue === "") return mode === "link" ? "Enter a URL." : "Enter some text."; if (mode === "link") { - try { - new URL(trimmedValue); - } catch { + if (!isSafeUrl(trimmedValue)) { return "That does not look like a URL."; } return { source: "link", title: trimmedTitle, url: trimmedValue }; diff --git a/src/ui/artifact-body.tsx b/src/ui/artifact-body.tsx index d15ab640..7c9205a7 100644 --- a/src/ui/artifact-body.tsx +++ b/src/ui/artifact-body.tsx @@ -1,6 +1,7 @@ import { Download } from "lucide-react"; import { type Artifact, type ArtifactForm, artifactForm } from "../lib/artifact.js"; +import { toSafeHref } from "../lib/url.js"; import { cn } from "../lib/utils.js"; import { ArtifactNotice } from "./artifact-notice.js"; import { buttonVariants } from "./button.js"; @@ -46,7 +47,7 @@ export function ArtifactBody({ artifact, form, className }: ArtifactBodyProps) { case "image": { // The thumbnail URL, not the content: an image artifact's bytes live // behind a route, and `content` for one is either empty or a caption. - const src = artifact.thumbnailUrl ?? artifact.downloadUrl; + const src = toSafeHref(artifact.thumbnailUrl ?? artifact.downloadUrl); if (src === undefined) return ; return (
@@ -59,15 +60,12 @@ export function ArtifactBody({ artifact, form, className }: ArtifactBodyProps) { } case "download": { - if (artifact.downloadUrl === undefined) { + const downloadUrl = toSafeHref(artifact.downloadUrl); + if (downloadUrl === undefined) { return ; } return ( - + Download {artifact.title} @@ -83,13 +81,14 @@ export function ArtifactBody({ artifact, form, className }: ArtifactBodyProps) { /> ); - case "table": + case "table": { if (blank) return ; + const downloadUrl = toSafeHref(artifact.downloadUrl); return (
- {artifact.downloadUrl === undefined ? null : ( + {downloadUrl === undefined ? null : ( @@ -100,6 +99,7 @@ export function ArtifactBody({ artifact, form, className }: ArtifactBodyProps) {
); + } case "comparison": return ; diff --git a/src/ui/artifact-gallery.tsx b/src/ui/artifact-gallery.tsx index 044bc5bb..6ceed66c 100644 --- a/src/ui/artifact-gallery.tsx +++ b/src/ui/artifact-gallery.tsx @@ -5,6 +5,7 @@ import { useCollectionState } from "../hooks/use-collection-state.js"; import { type Artifact, artifactKindLabel } from "../lib/artifact.js"; import type { CollectionRequest } from "../lib/data-port.js"; import { formatRelativeTime } from "../lib/relative-time.js"; +import { toSafeHref } from "../lib/url.js"; import { cn } from "../lib/utils.js"; import { Button } from "./button.js"; import { CatalogGlyph } from "./catalog-glyph.js"; @@ -36,11 +37,12 @@ export function ArtifactCard({ readonly now?: number; }) { const stamp = artifact.updatedAt ?? artifact.createdAt; + const thumbnailUrl = toSafeHref(artifact.thumbnailUrl); const inner = ( <> - {artifact.thumbnailUrl === undefined ? ( + {thumbnailUrl === undefined ? ( ) : ( // Decorative: the title sits directly below in the same control, so @@ -49,7 +51,7 @@ export function ArtifactCard({ // A plain , not `next/image`: this file is copied into whatever // app installs it, and half of those are not Next apps. A framework // import here would be a build error for them. - + )} diff --git a/src/ui/embed-body.tsx b/src/ui/embed-body.tsx index 7ece173b..1763d7e9 100644 --- a/src/ui/embed-body.tsx +++ b/src/ui/embed-body.tsx @@ -1,5 +1,6 @@ import { ExternalLink } from "lucide-react"; +import { isSafeUrl, toSafeHref } from "../lib/url.js"; import { cn } from "../lib/utils.js"; import { ArtifactNotice } from "./artifact-notice.js"; import { buttonVariants } from "./button.js"; @@ -20,21 +21,7 @@ import { buttonVariants } from "./button.js"; */ const EMBED_SANDBOX = "allow-scripts allow-same-origin allow-presentation"; -/** - * `https:` only, checked by parsing rather than by prefix. - * - * `url.startsWith("https://")` passes `https://evil` and, more to the point, - * misses that the string may not be a URL at all. It also rejects `javascript:` - * and `data:` payloads outright, which is the actual reason this exists — an - * embed URL comes from an artifact payload, which came from a model or a user. - */ -function isEmbeddable(url: string): boolean { - try { - return new URL(url).protocol === "https:"; - } catch { - return false; - } -} +const EMBED_ALLOWED_PROTOCOLS = ["https:"] as const; export type EmbedBodyProps = { /** Must be `https:`. Anything else renders the fallback notice. */ @@ -67,9 +54,10 @@ export type EmbedBodyProps = { * to detect our way into. */ export function EmbedBody({ url, title, description, downloadUrl, aspect = "wide", className }: EmbedBodyProps) { - if (!isEmbeddable(url)) { + if (!isSafeUrl(url, EMBED_ALLOWED_PROTOCOLS)) { return ; } + const safeDownloadUrl = toSafeHref(downloadUrl); return (
@@ -92,8 +80,8 @@ export function EmbedBody({ url, title, description, downloadUrl, aspect = "wide Open in a new tab - {downloadUrl === undefined ? null : ( - + {safeDownloadUrl === undefined ? null : ( + Download )} diff --git a/src/ui/research-body.test.tsx b/src/ui/research-body.test.tsx new file mode 100644 index 00000000..8a361b0a --- /dev/null +++ b/src/ui/research-body.test.tsx @@ -0,0 +1,92 @@ +import { describe, expect, test } from "bun:test"; +import { act, createElement } from "react"; +import { createRoot } from "react-dom/client"; + +import { ResearchBody, type ResearchBrief } from "./research-body.js"; + +function mount(brief: ResearchBrief) { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + + act(() => { + root.render(createElement(ResearchBody, { brief })); + }); + + return { + container, + unmount: () => root.unmount(), + }; +} + +describe("ResearchBody", () => { + test("a javascript: citation URL renders as inert text, not a live link", () => { + const brief: ResearchBrief = { + topic: "Test topic", + sourceCount: 1, + itemCount: 1, + citations: [{ url: "javascript:alert(1)", title: "Malicious citation", source: "Untrusted feed" }], + }; + + const { container, unmount } = mount(brief); + + expect(container.textContent).toContain("Malicious citation"); + const anchors = Array.from(container.querySelectorAll("a")); + expect(anchors.some((a) => a.getAttribute("href")?.startsWith("javascript:"))).toBe(false); + expect(anchors).toHaveLength(0); + + unmount(); + }); + + test("an https: citation URL still renders as a live link", () => { + const brief: ResearchBrief = { + topic: "Test topic", + sourceCount: 1, + itemCount: 1, + citations: [{ url: "https://example.com/report", title: "Safe citation", source: "Trusted feed" }], + }; + + const { container, unmount } = mount(brief); + + const anchor = container.querySelector("a"); + expect(anchor?.getAttribute("href")).toBe("https://example.com/report"); + + unmount(); + }); + + test("a javascript: quote source URL renders no link", () => { + const brief: ResearchBrief = { + topic: "Test topic", + sourceCount: 1, + itemCount: 1, + quotes: [{ quote: "A quoted line.", source: "Untrusted feed", url: "javascript:alert(1)" }], + }; + + const { container, unmount } = mount(brief); + + expect(container.querySelectorAll("a")).toHaveLength(0); + + unmount(); + }); + + test("a javascript: cluster item URL renders no link", () => { + const brief: ResearchBrief = { + topic: "Test topic", + sourceCount: 1, + itemCount: 1, + clusters: [ + { + id: "c1", + title: "Cluster", + items: [{ url: "javascript:alert(1)", title: "Item", source: "Untrusted feed" }], + }, + ], + }; + + const { container, unmount } = mount(brief); + + expect(container.querySelectorAll("a")).toHaveLength(0); + + unmount(); + }); +}); diff --git a/src/ui/research-body.tsx b/src/ui/research-body.tsx index bf230cb1..aa48b8d6 100644 --- a/src/ui/research-body.tsx +++ b/src/ui/research-body.tsx @@ -1,3 +1,4 @@ +import { toSafeHref } from "../lib/url.js"; import { cn } from "../lib/utils.js"; export type ResearchSource = { @@ -132,18 +133,25 @@ export function ResearchBody({ )} {cluster.items === undefined || cluster.items.length === 0 ? null : ( )} @@ -154,32 +162,35 @@ export function ResearchBody({ {brief.quotes === undefined || brief.quotes.length === 0 ? null : (

Quotes

- {brief.quotes.map((quote) => ( - //
/
rather than a div and a span: a quotation - // with an attribution is exactly what the element pair is for, and - // it is what makes the attribution reachable from the quote for a - // screen reader instead of being a loose line of text after it. -
-
{quote.quote}
-
- {quote.author === undefined ? "" : `${quote.author} · `} - {quote.source} - {quote.url === undefined ? null : ( - <> - {" · "} - - source - - - )} -
-
- ))} + {brief.quotes.map((quote) => { + const href = toSafeHref(quote.url); + return ( + //
/
rather than a div and a span: a quotation + // with an attribution is exactly what the element pair is for, and + // it is what makes the attribution reachable from the quote for a + // screen reader instead of being a loose line of text after it. +
+
{quote.quote}
+
+ {quote.author === undefined ? "" : `${quote.author} · `} + {quote.source} + {href === undefined ? null : ( + <> + {" · "} + + source + + + )} +
+
+ ); + })}
)} @@ -187,22 +198,29 @@ export function ResearchBody({

Citations

    - {citations.map((citation, index) => ( -
  1. - {index + 1} - - - {citation.title ?? citation.url} - - · {citation.source} - -
  2. - ))} + {citations.map((citation, index) => { + const href = toSafeHref(citation.url); + return ( +
  3. + {index + 1} + + {href === undefined ? ( + {citation.title ?? citation.url} + ) : ( + + {citation.title ?? citation.url} + + )} + · {citation.source} + +
  4. + ); + })}
)} From 1d638e5b5fd4fb9512823a4a218794b74d8e6998 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Fri, 14 Aug 2026 09:19:57 -0700 Subject: [PATCH 004/117] TraceWaterfall honesty for ordinal spans, plus an accessible InfoTooltip (#20) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TraceWaterfall now honors TraceSpan.timingSource: "ordinal" spans (positioned only by event order, no real duration) render as a point marker instead of a proportional bar. durationLabel is now string | null and renders "—" centrally in both the inline text and the accessible table. Adds InfoTooltip, a focusable Radix-based tooltip primitive replacing the inaccessible title-attribute pattern. --- bun.lock | 7 +- package.json | 36 ++++- scripts/contrast-test.mjs | 2 + src/index.ts | 8 + src/theme.css | 72 +++++++++ src/ui/avatar.tsx | 128 +++++++++++++++ src/ui/badge.tsx | 4 + src/ui/block-card.tsx | 73 +++++++++ src/ui/category-bars.tsx | 75 +++++++++ src/ui/command-palette.test.tsx | 57 +++++++ src/ui/command-palette.tsx | 53 ++++-- src/ui/dialog.tsx | 83 ++++++++-- src/ui/filter-chip.tsx | 2 +- src/ui/kind-card-grid.tsx | 81 ++++++++++ src/ui/profile-card.tsx | 146 +++++++++++++++++ src/ui/rich-empty-state.tsx | 22 ++- src/ui/sidebar-item-row.tsx | 6 +- src/ui/sidebar-rail.tsx | 14 +- src/ui/stat-grid.tsx | 54 +++++-- src/ui/status-dot.tsx | 6 +- src/ui/toast.tsx | 51 +++--- src/ui/tooltip.test.tsx | 75 +++++++++ src/ui/tooltip.tsx | 59 +++++++ src/ui/trace-waterfall.test.tsx | 118 ++++++++++++++ src/ui/trace-waterfall.tsx | 138 ++++++++++++++++ src/ui/triage-pane.tsx | 72 +++++++++ stories/chat/block-card.stories.tsx | 23 +++ .../primitives/command-palette.stories.tsx | 47 ++++++ stories/primitives/dialog.stories.tsx | 32 ++++ .../primitives/rich-empty-state.stories.tsx | 11 ++ .../sidebar-rail-and-panel.stories.tsx | 32 +++- stories/primitives/stat-grid.stories.tsx | 8 + stories/primitives/toast.stories.tsx | 21 +++ tests/ui/block-card.test.tsx | 74 +++++++++ tests/ui/dialog.test.tsx | 151 ++++++++++++++++++ tests/ui/filter-chip.test.tsx | 46 ++++++ tests/ui/rich-empty-state.test.tsx | 52 ++++++ tests/ui/sidebar-item-row.test.tsx | 48 ++++++ tests/ui/sidebar-rail.test.tsx | 61 +++++++ tests/ui/stat-grid.test.tsx | 102 ++++++++++++ tests/ui/toast-edge.test.tsx | 86 ++++++++++ tests/ui/toast.test.tsx | 101 ++++++++++++ 42 files changed, 2264 insertions(+), 73 deletions(-) create mode 100644 src/ui/avatar.tsx create mode 100644 src/ui/block-card.tsx create mode 100644 src/ui/category-bars.tsx create mode 100644 src/ui/kind-card-grid.tsx create mode 100644 src/ui/profile-card.tsx create mode 100644 src/ui/tooltip.test.tsx create mode 100644 src/ui/tooltip.tsx create mode 100644 src/ui/trace-waterfall.test.tsx create mode 100644 src/ui/trace-waterfall.tsx create mode 100644 src/ui/triage-pane.tsx create mode 100644 stories/chat/block-card.stories.tsx create mode 100644 stories/primitives/command-palette.stories.tsx create mode 100644 stories/primitives/toast.stories.tsx create mode 100644 tests/ui/block-card.test.tsx create mode 100644 tests/ui/dialog.test.tsx create mode 100644 tests/ui/filter-chip.test.tsx create mode 100644 tests/ui/rich-empty-state.test.tsx create mode 100644 tests/ui/sidebar-item-row.test.tsx create mode 100644 tests/ui/sidebar-rail.test.tsx create mode 100644 tests/ui/stat-grid.test.tsx create mode 100644 tests/ui/toast-edge.test.tsx create mode 100644 tests/ui/toast.test.tsx diff --git a/bun.lock b/bun.lock index da946653..70a4347d 100644 --- a/bun.lock +++ b/bun.lock @@ -16,13 +16,13 @@ "@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", "@swc/cli": "^0.7.7", "@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/bun": "^1.3.14", "@types/node": "^24.9.1", "@types/react": "^19.2.2", "@types/react-dom": "^19.2.1", @@ -39,6 +39,7 @@ "@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", "@tanstack/react-query": "^5.90.2", "lucide-react": "^0.545.0 || ^1.0.0", "react": "^18.2.0 || ^19.0.0", @@ -329,6 +330,8 @@ "@radix-ui/react-slot": ["@radix-ui/react-slot@1.3.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.5" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-qx7oqnYbxnK9kYI9m317qmFmEgo6ywqWvbTogdj7cL9p3/yx4M48p7Rnw5z3H890cL/ow/EeWJsuTykeZVXP5Q=="], + "@radix-ui/react-tooltip": ["@radix-ui/react-tooltip@1.2.16", "", { "dependencies": { "@radix-ui/primitive": "1.1.7", "@radix-ui/react-compose-refs": "1.1.5", "@radix-ui/react-context": "1.2.2", "@radix-ui/react-dismissable-layer": "1.1.19", "@radix-ui/react-id": "1.1.4", "@radix-ui/react-popper": "1.3.7", "@radix-ui/react-portal": "1.1.17", "@radix-ui/react-presence": "1.1.10", "@radix-ui/react-primitive": "2.1.10", "@radix-ui/react-slot": "1.3.3", "@radix-ui/react-use-controllable-state": "1.2.6", "@radix-ui/react-use-layout-effect": "1.1.4", "@radix-ui/react-visually-hidden": "1.2.11" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-6EamKFRRnlpdadndbZ6LMwycfwkwPte1B42hs6QA0gYhjaOKqW4PZ4pjaW9UrlDX5eVt/OjncE7BFTPL5nmZhg=="], + "@radix-ui/react-use-callback-ref": ["@radix-ui/react-use-callback-ref@1.1.4", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-R6OUY2e2fA6Yn6s+VSx5KBV6Nx8LQEhu+cz7LCej18rQ1HLyg9PSC9jP/ZNx0o6FAIK9c0F1kHylzSxKsdlkrQ=="], "@radix-ui/react-use-controllable-state": ["@radix-ui/react-use-controllable-state@1.2.6", "", { "dependencies": { "@radix-ui/primitive": "1.1.7", "@radix-ui/react-use-effect-event": "0.0.5", "@radix-ui/react-use-layout-effect": "1.1.4" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-uEQJGT97ZA/TgP/Hydw47lHu+/vQj6z/0jA+WeTbK1o9Rx45GImjpD0tc3W5ad3D6XTSR6e1yEO0FvGq6WQfVQ=="], @@ -343,6 +346,8 @@ "@radix-ui/react-use-size": ["@radix-ui/react-use-size@1.1.4", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.4" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-D3anSY15EJoxrihpsXI6SMrmmonnQtR2ni7arO+Lfdg3O95b9hNXxONk8jA5C8ANdF/h5HMAxejgs8PWJ6rlhw=="], + "@radix-ui/react-visually-hidden": ["@radix-ui/react-visually-hidden@1.2.11", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.10" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-NFS86RYYZb4/exihaESBGOpMJFz8MGLAfu3mOBSGByVnVPC9JPASfYubxd/8KbkQK0sYAv8lVQDEQukDX/qXvQ=="], + "@radix-ui/rect": ["@radix-ui/rect@1.1.3", "", {}, "sha512-JtyZR+mqgBibTo8xea3B6ZRmzZiM/YeVBtUkas6zMuXjAlfIFIW2FgqeM9eLyvEaYX66vr6DJMK+4U6LV0KhNw=="], "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-beta.27", "", {}, "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA=="], diff --git a/package.json b/package.json index f5da86b6..97f518a8 100644 --- a/package.json +++ b/package.json @@ -47,6 +47,7 @@ "@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", "@tanstack/react-query": "^5.90.2", "lucide-react": "^0.545.0 || ^1.0.0", "react": "^18.2.0 || ^19.0.0", @@ -62,15 +63,16 @@ "@eslint/js": "^9.39.1", "@happy-dom/global-registrator": "^20.11.2", "@ladle/react": "^5.1.1", - "@types/bun": "^1.3.14", "@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", "@swc/cli": "^0.7.7", "@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", "@types/react-dom": "^19.2.1", @@ -271,6 +273,10 @@ "types": "./dist/ui/artifact-notice.d.ts", "default": "./dist/ui/artifact-notice.js" }, + "./ui/avatar": { + "types": "./dist/ui/avatar.d.ts", + "default": "./dist/ui/avatar.js" + }, "./ui/badge": { "types": "./dist/ui/badge.d.ts", "default": "./dist/ui/badge.js" @@ -279,6 +285,10 @@ "types": "./dist/ui/bar-chart.d.ts", "default": "./dist/ui/bar-chart.js" }, + "./ui/block-card": { + "types": "./dist/ui/block-card.d.ts", + "default": "./dist/ui/block-card.js" + }, "./ui/boot-screen": { "types": "./dist/ui/boot-screen.d.ts", "default": "./dist/ui/boot-screen.js" @@ -295,6 +305,10 @@ "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" + }, "./ui/chart-frame": { "types": "./dist/ui/chart-frame.d.ts", "default": "./dist/ui/chart-frame.js" @@ -439,6 +453,10 @@ "types": "./dist/ui/intake-form.d.ts", "default": "./dist/ui/intake-form.js" }, + "./ui/kind-card-grid": { + "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" @@ -515,6 +533,10 @@ "types": "./dist/ui/pagination.d.ts", "default": "./dist/ui/pagination.js" }, + "./ui/profile-card": { + "types": "./dist/ui/profile-card.d.ts", + "default": "./dist/ui/profile-card.js" + }, "./ui/progress-checklist": { "types": "./dist/ui/progress-checklist.d.ts", "default": "./dist/ui/progress-checklist.js" @@ -715,10 +737,22 @@ "types": "./dist/ui/tool-narrative.d.ts", "default": "./dist/ui/tool-narrative.js" }, + "./ui/tooltip": { + "types": "./dist/ui/tooltip.d.ts", + "default": "./dist/ui/tooltip.js" + }, "./ui/top-bar": { "types": "./dist/ui/top-bar.d.ts", "default": "./dist/ui/top-bar.js" }, + "./ui/trace-waterfall": { + "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" diff --git a/scripts/contrast-test.mjs b/scripts/contrast-test.mjs index 81a0102b..888429bd 100644 --- a/scripts/contrast-test.mjs +++ b/scripts/contrast-test.mjs @@ -49,6 +49,8 @@ const REQUIRED = [ "border", "input", "ring", + "ok", + "warn", // Five slots, matching CHART_SERIES_SLOTS in lib/chart-palette.ts. "chart-1", "chart-2", diff --git a/src/index.ts b/src/index.ts index 5304aa23..d9bf3f41 100644 --- a/src/index.ts +++ b/src/index.ts @@ -53,12 +53,15 @@ export * from "./ui/artifact-body.js"; export * from "./ui/artifact-detail.js"; export * from "./ui/artifact-gallery.js"; export * from "./ui/artifact-notice.js"; +export * from "./ui/avatar.js"; export * from "./ui/badge.js"; export * from "./ui/bar-chart.js"; +export * from "./ui/block-card.js"; export * from "./ui/boot-screen.js"; export * from "./ui/button.js"; export * from "./ui/card.js"; export * from "./ui/catalog-glyph.js"; +export * from "./ui/category-bars.js"; export * from "./ui/chart-frame.js"; export * from "./ui/chat-composer.js"; export * from "./ui/chat-dock.js"; @@ -95,6 +98,7 @@ export * from "./ui/horizontal-stepper.js"; export * from "./ui/input.js"; export * from "./ui/inspector-shell.js"; export * from "./ui/intake-form.js"; +export * from "./ui/kind-card-grid.js"; export * from "./ui/kind-picker.js"; export * from "./ui/library-page-header.js"; export * from "./ui/library-search-input.js"; @@ -114,6 +118,7 @@ export * from "./ui/onboarding-tour.js"; export * from "./ui/page-panel.js"; export * from "./ui/page-shell.js"; export * from "./ui/pagination.js"; +export * from "./ui/profile-card.js"; export * from "./ui/progress-checklist.js"; export * from "./ui/provider-mark.js"; export * from "./ui/quick-reply-chips.js"; @@ -164,7 +169,10 @@ export * from "./ui/toast.js"; export * from "./ui/toggle-list.js"; export * from "./ui/token-mosaic.js"; export * from "./ui/tool-narrative.js"; +export * from "./ui/tooltip.js"; export * from "./ui/top-bar.js"; +export * from "./ui/trace-waterfall.js"; +export * from "./ui/triage-pane.js"; export * from "./ui/typing-indicator.js"; export * from "./ui/view-toggle.js"; export * from "./ui/whats-new.js"; diff --git a/src/theme.css b/src/theme.css index 4f6decf6..42822288 100644 --- a/src/theme.css +++ b/src/theme.css @@ -58,9 +58,24 @@ --destructive: #9e2b25; --destructive-foreground: #ffffff; --border: #dfe3e6; + --border-strong: #c3ccd2; --input: #8a8080; --ring: #9a5416; + /* Status text, not a fill/foreground pair — used where a step or delivery + state needs to read as "good"/"caution" beyond what --success/--destructive + already carry (those are fills). --warn also colours a medium/high + RiskBadge; low risk stays --muted-foreground so only the levels worth + flagging draw the eye. Matches Workbench's app.css exactly, so a block + lifted from a consumer app keeps its colours. */ + --ok: #37904a; + --warn: #b7791f; + + /* The chrome's elevation. The design is flat — floating chrome separates + itself with --border-strong, not depth — so the token ships as `none` + and exists as the single knob if elevation ever returns. */ + --shadow: none; + /* Chart series, a fixed order rather than a pool — slot 1 is always the first series in the data. See lib/chart-palette.ts for why these steps and this ordering. The orange family is deliberately absent: orange is the reserved @@ -98,8 +113,13 @@ --destructive: #e6796f; --destructive-foreground: #000000; --border: #423a3b; + --border-strong: #5c5254; --input: #8a8080; --ring: #e98428; + --shadow: none; + + --ok: #5cb573; + --warn: #d9a441; --chart-1: #5a95d8; --chart-2: #63a86a; @@ -118,6 +138,7 @@ --card: #f3efe8; --muted: #ebe6df; --border: #e2d9ce; + --border-strong: #c9bcab; --secondary: #d4cfc6; --secondary-foreground: #2b2627; --accent: #d4cfc6; @@ -128,6 +149,7 @@ --card: #322a26; --muted: #1c1614; --border: #4a3f3a; + --border-strong: #665750; --secondary: #3d342e; --secondary-foreground: #e4d5bc; --accent: #3d342e; @@ -138,6 +160,7 @@ --card: #eef2f6; --muted: #e8edf2; --border: #d5dde6; + --border-strong: #b6c3d0; --secondary: #b8c9d9; --secondary-foreground: #2b2627; --accent: #b8c9d9; @@ -148,12 +171,26 @@ --card: #1e242c; --muted: #14181e; --border: #3a424c; + --border-strong: #525c68; --secondary: #2a3d52; --secondary-foreground: #e4d5bc; --accent: #2a3d52; --accent-foreground: #e4d5bc; } +/* SidebarItemRow's own restyle points, deliberately outside `@theme inline`: + a Tailwind `bg-primary/10`/`rounded-md` pair is undefeatable without a + specificity war, since the utility and its override compile to the same + specificity and whichever rule is later in the sheet wins. A CSS variable + read at render time has none of that — a consumer sets it once on their own + root/scope and the row already reads it, no `!important` and no rebuild. + `color-mix` re-derives from `--primary` so both modes stay correct without + a `.dark` override of their own. */ +:root { + --sidebar-row-radius: var(--radius); + --sidebar-row-selected-bg: color-mix(in srgb, var(--primary) 10%, transparent); +} + @theme inline { --color-background: var(--background); --color-foreground: var(--foreground); @@ -176,8 +213,12 @@ --color-destructive: var(--destructive); --color-destructive-foreground: var(--destructive-foreground); --color-border: var(--border); + --color-border-strong: var(--border-strong); --color-input: var(--input); --color-ring: var(--ring); + --color-ok: var(--ok); + --color-warn: var(--warn); + --shadow-surface: var(--shadow); /* The brand faces are named directly and are not bundled — the package ships no font files. A consumer that loads Red Hat Display / Space Mono gets the @@ -293,6 +334,20 @@ } } +/* The block card's header dot: a plain opacity blink, distinct from the + status dot's outward-scaling ring — this one sits inline in a dense header + row where a scaling ring would collide with the title text next to it. + Used by ui/block-card. */ +@keyframes corbits-block-pulse { + 0%, + 100% { + opacity: 1; + } + 50% { + opacity: 0.35; + } +} + /* The boot screen's progress sweep. An indeterminate bar rather than a spinner: it says "still working" without implying a percentage nobody knows. Ships by ui/boot-screen. */ @@ -393,6 +448,23 @@ } } +/* Toast motion: a pure 160ms fade, centered above the bottom edge — no + slide, no lift. Sonner enters and exits by transitioning translateY, so + the transform is pinned (!important, since sonner injects its stylesheet + at runtime after any linked sheet) and only opacity is left to move. The + durations stay normal-weight declarations so the reduced-motion override + below still collapses them. Ships by ui/toast. */ +[data-sonner-toaster] [data-sonner-toast].corbits-toast { + left: 50%; + transform: translateX(-50%) !important; + transition-property: opacity; + transition-duration: 160ms; + transition-timing-function: cubic-bezier(0.22, 1, 0.36, 1); +} +[data-sonner-toaster] [data-sonner-toast].corbits-toast[data-removed="true"] { + transition-timing-function: cubic-bezier(0.4, 0, 1, 1); +} + /* Motion is opt-out at the foundation, not per component: anything animated anywhere collapses to a single frame when the OS asks for reduced motion. Component families added later inherit this for free. */ diff --git a/src/ui/avatar.tsx b/src/ui/avatar.tsx new file mode 100644 index 00000000..32f665f4 --- /dev/null +++ b/src/ui/avatar.tsx @@ -0,0 +1,128 @@ +import type { ReactNode } from "react"; + +import { cn } from "../lib/utils.js"; + +export type AvatarTone = "neutral" | "agent" | "agent2" | "agent3"; + +export type AvatarProps = { + /** One or two characters shown when no image is provided. */ + readonly initials: string; + readonly label: string; + readonly tone?: AvatarTone; + readonly size?: "sm" | "md" | "lg"; + /** Tenant monogram badge overlaid on the corner. */ + readonly tenantMonogram?: string; + readonly className?: string; +}; + +const SIZE_CLASS = { + sm: "size-6 text-[10px]", + md: "size-8 text-xs", + lg: "size-10 text-sm", +} as const; + +const TONE_CLASS: Record = { + neutral: "bg-muted text-muted-foreground", + agent: "bg-primary text-primary-foreground", + agent2: "bg-accent text-accent-foreground", + agent3: "bg-success text-success-foreground", +}; + +const BADGE_SIZE = { + sm: "size-3 text-[7px]", + md: "size-3.5 text-[8px]", + lg: "size-4 text-[9px]", +} as const; + +/** + * Initials avatar with optional tenant monogram badge. Images are + * intentionally unsupported here: identity headers should not flash or + * depend on third-party avatar hosts. + */ +export function Avatar({ + initials, + label, + tone = "neutral", + size = "md", + tenantMonogram, + className, +}: AvatarProps) { + return ( + + {initials.slice(0, 2)} + {tenantMonogram === undefined ? null : ( + + {tenantMonogram.slice(0, 1)} + + )} + + ); +} + +export type AvatarStackItem = { + readonly id: string; + readonly initials: string; + readonly label: string; + readonly tone?: AvatarTone; + readonly tenantMonogram?: string; +}; + +export type AvatarStackProps = { + readonly items: readonly AvatarStackItem[]; + /** Cap visible avatars; remainder becomes a +N chip. */ + readonly max?: number; + readonly size?: "sm" | "md"; + readonly className?: string; +}; + +/** + * Overlapping avatar stack for thread participants and channel members. + */ +export function AvatarStack({ items, max = 4, size = "sm", className }: AvatarStackProps) { + const visible = items.slice(0, max); + const overflow = items.length - visible.length; + return ( + + {visible.map((item, index) => ( + 0 && "-ml-1.5")} + /> + ))} + {overflow <= 0 ? null : ( + 0 && "-ml-1.5", + )} + aria-label={`${overflow} more`} + > + +{overflow} + + )} + + ); +} + +export type AvatarStackSlot = ReactNode; diff --git a/src/ui/badge.tsx b/src/ui/badge.tsx index dd5dc339..83c62d53 100644 --- a/src/ui/badge.tsx +++ b/src/ui/badge.tsx @@ -16,7 +16,11 @@ const badgeVariants = cva( accent: "border-primary-emphasis/40 bg-primary/10 text-primary-emphasis", info: "border-accent bg-accent text-accent-foreground", success: "border-success bg-success text-success-foreground", + // Warning reuses accent amber until a dedicated warning pair lands in theme. + warning: "border-primary-emphasis/40 bg-primary/10 text-primary-emphasis", danger: "border-destructive bg-destructive text-destructive-foreground", + shared: "border-accent bg-accent text-accent-foreground", + access: "border-primary-emphasis/40 bg-primary/10 text-primary-emphasis", }, }, defaultVariants: { tone: "neutral" }, diff --git a/src/ui/block-card.tsx b/src/ui/block-card.tsx new file mode 100644 index 00000000..b2062de5 --- /dev/null +++ b/src/ui/block-card.tsx @@ -0,0 +1,73 @@ +import { TriangleAlert } from "lucide-react"; +import type { ReactNode } from "react"; + +import { cn } from "../lib/utils.js"; + +export type BlockCardProps = { + readonly title: string; + readonly children: ReactNode; + readonly className?: string; +}; + +/** + * The generative-UI block frame: a bordered, square-cornered card with a + * pulse-dot header. Zero radius is deliberate — it is what separates a + * generated block from the theme's otherwise-rounded surfaces, so a reader + * can tell "the agent made this" at a glance. Lifted out of a consumer app + * unchanged; see `RiskBadge` below for the tone it pairs with inside + * `children`. + */ +export function BlockCard({ title, children, className }: BlockCardProps) { + return ( +
+
+ + {title} +
+
+ {children} +
+
+ ); +} + +export type RiskLevel = "low" | "medium" | "high"; + +export type RiskBadgeProps = { + readonly level: RiskLevel; + readonly label: string; + readonly note?: string; + readonly className?: string; +}; + +const RISK_TONE_CLASS: Record = { + low: "text-muted-foreground", + medium: "text-warn", + high: "text-destructive", +}; + +/** + * A risk callout for a block whose action carries consequence — approve this + * order cancellation, run this migration. `level` drives colour only; the + * `label` is what actually says what the risk is, so the badge is never a + * bare colour dot. + */ +export function RiskBadge({ level, label, note, className }: RiskBadgeProps) { + return ( + + + {label} + {note === undefined ? null : {note}} + + ); +} diff --git a/src/ui/category-bars.tsx b/src/ui/category-bars.tsx new file mode 100644 index 00000000..1e3a00aa --- /dev/null +++ b/src/ui/category-bars.tsx @@ -0,0 +1,75 @@ +import { formatCompact } from "../lib/chart-geometry.js"; +import { cn } from "../lib/utils.js"; +import { ChartFrame } from "./chart-frame.js"; + +export type CategoryBarDatum = { + readonly label: string; + readonly value: number; + /** Optional secondary value shown beside the primary (e.g. cost). */ + readonly secondaryLabel?: string; +}; + +export type CategoryBarsProps = { + readonly title: string; + readonly description?: string; + readonly data: readonly CategoryBarDatum[]; + readonly valueLabel?: string; + readonly format?: (value: number) => string; + readonly className?: string; +}; + +/** + * Dense horizontal category ranking for cost-by-model / calls-by-tool + * style tables. Bars share one series colour; length encodes magnitude. + * Absent secondary labels stay empty rather than inventing a zero. + */ +export function CategoryBars({ + title, + description, + data, + valueLabel = "Value", + format = formatCompact, + className, +}: CategoryBarsProps) { + const max = Math.max(0, ...data.map((d) => d.value)); + const axisMax = max === 0 ? 1 : max; + + return ( + [ + datum.label, + format(datum.value), + datum.secondaryLabel ?? "—", + ]), + }} + className={className} + > +
+ {data.map((datum) => { + const pct = (datum.value / axisMax) * 100; + return ( +
+

{datum.label}

+
+
+
+
+

{format(datum.value)}

+ {datum.secondaryLabel === undefined ? null : ( +

{datum.secondaryLabel}

+ )} +
+
+ ); + })} +
+ + ); +} diff --git a/src/ui/command-palette.test.tsx b/src/ui/command-palette.test.tsx index 5d733c17..74a0e3e4 100644 --- a/src/ui/command-palette.test.tsx +++ b/src/ui/command-palette.test.tsx @@ -158,4 +158,61 @@ describe("CommandPalette", () => { expect(loadMoreCalls).toBe(1); root.unmount(); }); + + test("inputAccessory renders leading the input, inside the input row", () => { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + act(() => { + root.render( + createElement(CommandPalette, { + open: true, + onOpenChange: () => {}, + query: "", + onQueryChange: () => {}, + groups: GROUPS, + onSelect: () => {}, + inputAccessory: createElement("span", { "data-testid": "scope-chip" }, "This bench"), + }), + ); + }); + const accessory = document.body.querySelector("[data-slot='command-palette-input-accessory']"); + expect(accessory).not.toBeNull(); + expect(accessory?.textContent).toBe("This bench"); + const input = document.body.querySelector("input"); + expect(input).not.toBeNull(); + const position = accessory!.compareDocumentPosition(input!); + expect(Boolean(position & Node.DOCUMENT_POSITION_FOLLOWING)).toBe(true); + root.unmount(); + }); + + test("footer renders below the results as a legend strip", () => { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + act(() => { + root.render( + createElement(CommandPalette, { + open: true, + onOpenChange: () => {}, + query: "", + onQueryChange: () => {}, + groups: GROUPS, + onSelect: () => {}, + footer: "↑↓ to navigate", + }), + ); + }); + const footer = document.body.querySelector("[data-slot='command-palette-footer']"); + expect(footer).not.toBeNull(); + expect(footer?.textContent).toBe("↑↓ to navigate"); + root.unmount(); + }); + + test("omits the accessory and footer slots when not provided", () => { + const { unmount } = mount(); + expect(document.body.querySelector("[data-slot='command-palette-input-accessory']")).toBeNull(); + expect(document.body.querySelector("[data-slot='command-palette-footer']")).toBeNull(); + unmount(); + }); }); diff --git a/src/ui/command-palette.tsx b/src/ui/command-palette.tsx index 66949c99..5f49434b 100644 --- a/src/ui/command-palette.tsx +++ b/src/ui/command-palette.tsx @@ -39,6 +39,10 @@ export type CommandPaletteProps = { readonly hasMore?: boolean; readonly onLoadMore?: () => void; readonly placeholder?: string; + /** Leading content in the input row — a scope chip, a source badge. Sits before the input, inside the same row. */ + readonly inputAccessory?: ReactNode; + /** A legend or hint strip pinned under the results, e.g. keyboard shortcut hints. */ + readonly footer?: ReactNode; readonly className?: string; }; @@ -77,6 +81,8 @@ export function CommandPalette({ hasMore = false, onLoadMore, placeholder = "Search or jump to…", + inputAccessory, + footer, className, }: CommandPaletteProps) { const inputRef = useRef(null); @@ -113,23 +119,30 @@ export function CommandPalette({ onKeyDown={navigation.onKeyDown} > Search - onQueryChange(event.target.value)} - className="h-12 w-full shrink-0 border-b border-border bg-transparent px-4 text-sm outline-none placeholder:text-muted-foreground" - /> +
+ {inputAccessory === undefined ? null : ( +
+ {inputAccessory} +
+ )} + onQueryChange(event.target.value)} + className="h-full min-w-0 flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground" + /> +
) : null} + + {footer === undefined ? null : ( +
+ {footer} +
+ )} ); diff --git a/src/ui/dialog.tsx b/src/ui/dialog.tsx index eda7ddff..b82a0427 100644 --- a/src/ui/dialog.tsx +++ b/src/ui/dialog.tsx @@ -18,9 +18,10 @@ import { cn } from "../lib/utils.js"; * under two names. * * `side="center"` is the default because the unqualified word "dialog" means a - * centred one. `left` and `right` are full-height sheets; they are the only - * variants that need `DialogBody`, which scrolls while the header and footer - * stay put. + * centred one. Centre dialogs own max-height and overflow so tall forms scroll + * inside `DialogBody` rather than growing past the viewport. `left` and + * `right` are full-height sheets; they also use `DialogBody` so the header and + * footer stay put while the middle scrolls. */ export const Dialog = DialogPrimitive.Root; export const DialogTrigger = DialogPrimitive.Trigger; @@ -29,8 +30,13 @@ export const DialogClose = DialogPrimitive.Close; export type DialogSide = "center" | "left" | "right"; const SIDE_CLASS: Record = { + // Guttered width + capped height so the surface never kisses the viewport + // edge or spills past ~one form-screen of content. Overflow is clipped here; + // scroll lives on DialogBody. center: - "top-1/2 left-1/2 w-full max-w-lg -translate-x-1/2 -translate-y-1/2 rounded-lg border " + + "top-1/2 left-1/2 w-[min(calc(100vw-2rem),32rem)] max-h-[min(90dvh,42rem)] " + + "overflow-hidden -translate-x-1/2 -translate-y-1/2 rounded-lg border " + + "max-sm:w-[min(calc(100vw-1.25rem),32rem)] max-sm:max-h-[min(92dvh,42rem)] " + "data-[state=open]:animate-[corbits-dialog-in_150ms_ease-out] data-[state=closed]:animate-[corbits-dialog-out_120ms_ease-in]", right: "inset-y-0 right-0 w-full max-w-lg border-l " + @@ -48,7 +54,7 @@ export function DialogContent({ }: React.ComponentProps & { side?: DialogSide }) { return ( - + @@ -74,24 +88,69 @@ export function DialogContent({ /** `pr-8` keeps the title clear of the close control in the corner. */ export function DialogHeader({ className, ...props }: React.ComponentProps<"div">) { - return
; + return ( +
+ ); } -/** Scrolls; the header and footer stay put. Mostly wanted by the side variants. */ +/** Scrolls; the header and footer stay put. Needed by tall centre dialogs and side sheets. */ export function DialogBody({ className, ...props }: React.ComponentProps<"div">) { - return
; + return ( +
+ ); } export function DialogFooter({ className, ...props }: React.ComponentProps<"div">) { return ( -
+
*]:w-full", + className, + )} + {...props} + /> ); } export function DialogTitle({ className, ...props }: React.ComponentProps) { - return ; + return ( + + ); } export function DialogDescription({ className, ...props }: React.ComponentProps) { - return ; + return ( + + ); +} + +/** Uppercase field label used inside dialog forms. */ +export function DialogFieldLabel({ className, ...props }: React.ComponentProps<"label">) { + return ( +