diff --git a/apps/registry/components/component-preview/component-preview.tsx b/apps/registry/components/component-preview/component-preview.tsx
index 4252b15..d3bf301 100644
--- a/apps/registry/components/component-preview/component-preview.tsx
+++ b/apps/registry/components/component-preview/component-preview.tsx
@@ -99,6 +99,12 @@ import {
FileUpload,
Flashcard,
FloatingActionButton,
+ Form,
+ FormControl,
+ FormDescription,
+ FormItem,
+ FormLabel,
+ FormMessage,
Glossary,
HorizontalScrollRow,
HoverCard,
@@ -123,6 +129,7 @@ import {
MenubarSeparator,
MenubarTrigger,
MetricGauge,
+ MultiSelect,
NavigationMenu,
NavigationMenuContent,
NavigationMenuItem,
@@ -150,6 +157,8 @@ import {
ScopeSelector,
ScrollArea,
SearchBar,
+ SegmentedControl,
+ SegmentedControlItem,
Select,
SelectContent,
SelectItem,
@@ -190,6 +199,7 @@ import {
TabsContent,
TabsList,
TabsTrigger,
+ TagsInput,
Terminal,
Textarea,
ThemeProvider,
@@ -543,6 +553,63 @@ function CheckboxPreview() {
);
}
+function FormPreview() {
+ return (
+
+
+
+ );
+}
+
+function MultiSelectPreview() {
+ return (
+
+
+
+ );
+}
+
+function TagsInputPreview() {
+ return (
+
+
+
+ );
+}
+
+function SegmentedControlPreview() {
+ return (
+
+
+ Board
+ List
+ Timeline
+
+
+ );
+}
+
function TerminalPreview() {
return (
;
case "button":
return ;
+ case "anchor-port":
+ return (
+
+ );
case "callout":
return ;
case "calendar":
@@ -2120,6 +2191,18 @@ export function ComponentPreview({ componentName }: ComponentPreviewProps) {
return ;
case "carousel":
return ;
+ case "canvas-shell":
+ return (
+
+ );
+ case "canvas-view":
+ return (
+
+ );
+ case "connector-edge":
+ return (
+
+ );
case "category-filter":
return ;
case "checkbox":
@@ -2162,6 +2245,10 @@ export function ComponentPreview({ componentName }: ComponentPreviewProps) {
return ;
case "flashcard":
return ;
+ case "edge-label":
+ return (
+
+ );
case "file-upload":
return ;
case "filter-bar":
@@ -2170,6 +2257,12 @@ export function ComponentPreview({ componentName }: ComponentPreviewProps) {
);
case "floating-action-button":
return ;
+ case "form":
+ return ;
+ case "group-hull":
+ return (
+
+ );
case "horizontal-scroll-row":
return ;
case "hover-card":
@@ -2186,6 +2279,10 @@ export function ComponentPreview({ componentName }: ComponentPreviewProps) {
return ;
case "keyboard-shortcuts-help":
return ;
+ case "left-rail":
+ return (
+
+ );
case "lang-provider":
return ;
case "learning-objectives":
@@ -2202,12 +2299,22 @@ export function ComponentPreview({ componentName }: ComponentPreviewProps) {
return ;
case "menubar":
return ;
+ case "mini-map-panel":
+ return (
+
+ );
case "metric-gauge":
return ;
case "model-selector":
return (
);
+ case "multi-select":
+ return ;
+ case "tags-input":
+ return ;
+ case "segmented-control":
+ return ;
case "navbar-saas":
return (
@@ -2218,6 +2325,14 @@ export function ComponentPreview({ componentName }: ComponentPreviewProps) {
return ;
case "number-ticker":
return ;
+ case "object-card":
+ return (
+
+ );
+ case "object-handle":
+ return (
+
+ );
case "order-book":
return ;
case "pagination":
@@ -2242,6 +2357,10 @@ export function ComponentPreview({ componentName }: ComponentPreviewProps) {
return ;
case "resizable":
return ;
+ case "right-dock":
+ return (
+
+ );
case "scroll-area":
return ;
case "search-bar":
@@ -2294,6 +2413,10 @@ export function ComponentPreview({ componentName }: ComponentPreviewProps) {
return (
);
+ case "top-bar":
+ return (
+
+ );
case "table":
return ;
case "tabs":
@@ -2342,10 +2465,18 @@ export function ComponentPreview({ componentName }: ComponentPreviewProps) {
return ;
case "view-switcher":
return ;
+ case "workspace-switcher":
+ return (
+
+ );
case "wallet-card":
return ;
case "watchlist":
return ;
+ case "zoom-hud":
+ return (
+
+ );
case "world-clock-bar":
return ;
default:
diff --git a/apps/registry/components/storybook-embed/storybook-embed.tsx b/apps/registry/components/storybook-embed/storybook-embed.tsx
index aa5ebf3..31e0286 100644
--- a/apps/registry/components/storybook-embed/storybook-embed.tsx
+++ b/apps/registry/components/storybook-embed/storybook-embed.tsx
@@ -2,12 +2,12 @@
import * as React from "react";
+import { ToggleGroup, ToggleGroupItem } from "@vllnt/ui";
+
const STORYBOOK_URL =
process.env.NEXT_PUBLIC_STORYBOOK_URL ?? "http://localhost:6006";
-function toStoryId(componentName: string): string {
- return `components-${componentName}--default`;
-}
+type PreviewTheme = "dark" | "light";
type StorybookEmbedProps = {
className?: string;
@@ -16,51 +16,194 @@ type StorybookEmbedProps = {
storyId?: string;
};
-export function StorybookEmbed({
- className,
+type PreviewThemeControlsProps = {
+ onValueChange: (value: PreviewTheme) => void;
+ value: null | PreviewTheme;
+};
+
+function toStoryId(componentName: string): string {
+ return `components-${componentName}--default`;
+}
+
+function normalizePreviewTheme(theme: string | undefined): PreviewTheme {
+ return theme === "dark" ? "dark" : "light";
+}
+
+function resolveDocumentTheme(): PreviewTheme {
+ if (typeof document !== "undefined") {
+ return document.documentElement.classList.contains("dark")
+ ? "dark"
+ : "light";
+ }
+
+ if (typeof window !== "undefined") {
+ return window.matchMedia("(prefers-color-scheme: dark)").matches
+ ? "dark"
+ : "light";
+ }
+
+ return "light";
+}
+
+function buildStorybookIframeSource(
+ storyId: string,
+ previewTheme: PreviewTheme,
+): string {
+ const url = new URL("/iframe.html", STORYBOOK_URL);
+
+ url.searchParams.set("id", storyId);
+ url.searchParams.set("viewMode", "story");
+ url.searchParams.set("shortcuts", "false");
+ url.searchParams.set("singleStory", "true");
+ url.searchParams.set("globals", `theme:${previewTheme}`);
+
+ return url.toString();
+}
+
+function PreviewThemeControls({
+ onValueChange,
+ value,
+}: PreviewThemeControlsProps): React.ReactElement {
+ return (
+
+
+
Preview
+
+ Switch between light and dark to inspect the embedded Storybook
+ preview.
+
+
+
{
+ if (nextValue !== "light" && nextValue !== "dark") {
+ return;
+ }
+
+ onValueChange(nextValue);
+ }}
+ size="sm"
+ type="single"
+ value={value ?? undefined}
+ variant="outline"
+ >
+
+ Light
+
+
+ Dark
+
+
+
+ );
+}
+
+function StorybookIframe({
componentName,
- height = 400,
- storyId,
-}: StorybookEmbedProps): React.ReactElement {
+ height,
+ iframeSource,
+}: {
+ componentName: string;
+ height: number;
+ iframeSource: string;
+}): React.ReactElement {
const [isLoaded, setIsLoaded] = React.useState(false);
- const [iframeSource, setIframeSource] = React.useState("");
- const resolvedStoryId = encodeURIComponent(
- storyId ?? toStoryId(componentName),
- );
React.useEffect(() => {
- setIframeSource(
- `${STORYBOOK_URL}/iframe.html?id=${resolvedStoryId}&viewMode=story&shortcuts=false&singleStory=true`,
- );
- }, [resolvedStoryId]);
+ setIsLoaded(false);
+ }, [iframeSource]);
return (
-
+
{isLoaded ? null : (
)}
+
+ );
+}
+
+export function StorybookEmbed({
+ className,
+ componentName,
+ height = 400,
+ storyId,
+}: StorybookEmbedProps): React.ReactElement {
+ const [hasManualThemeSelection, setHasManualThemeSelection] =
+ React.useState(false);
+ const [previewTheme, setPreviewTheme] = React.useState
(
+ null,
+ );
+ const resolvedStoryId = storyId ?? toStoryId(componentName);
+
+ React.useEffect(() => {
+ if (hasManualThemeSelection) {
+ return;
+ }
+
+ const updateTheme = () => {
+ setPreviewTheme(normalizePreviewTheme(resolveDocumentTheme()));
+ };
+
+ updateTheme();
+
+ const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
+ const observer = new MutationObserver(() => {
+ updateTheme();
+ });
+
+ observer.observe(document.documentElement, {
+ attributeFilter: ["class"],
+ attributes: true,
+ });
+ mediaQuery.addEventListener("change", updateTheme);
+
+ return () => {
+ observer.disconnect();
+ mediaQuery.removeEventListener("change", updateTheme);
+ };
+ }, [hasManualThemeSelection]);
+
+ const iframeSource = React.useMemo(() => {
+ if (previewTheme === null) {
+ return "";
+ }
+
+ return buildStorybookIframeSource(resolvedStoryId, previewTheme);
+ }, [previewTheme, resolvedStoryId]);
+
+ return (
+
+
{
+ setHasManualThemeSelection(true);
+ setPreviewTheme(value);
+ }}
+ value={previewTheme}
+ />
{iframeSource ? (
-
diff --git a/apps/registry/lib/component-metadata.json b/apps/registry/lib/component-metadata.json
index 31fce01..40a6733 100644
--- a/apps/registry/lib/component-metadata.json
+++ b/apps/registry/lib/component-metadata.json
@@ -161,19 +161,72 @@
],
"title": "Alert Dialog"
},
+ "anchor-port": {
+ "category": "utility",
+ "defaultStoryId": "canvas-anchorport--default",
+ "description": "Port marker for object inputs, outputs, and bidirectional links on the canvas.",
+ "name": "anchor-port",
+ "stories": [
+ {
+ "id": "canvas-anchorport--default",
+ "name": "Default"
+ }
+ ],
+ "title": "Anchor Port"
+ },
"animated-text": {
"category": "utility",
- "defaultStoryId": "utility-animatedtext--default",
+ "defaultStoryId": "utility-animatedtext--terminal",
"description": "Staggered text reveal for headings, pull quotes, and short supporting copy.",
"name": "animated-text",
"stories": [
{
- "id": "utility-animatedtext--default",
- "name": "Default"
+ "id": "utility-animatedtext--terminal",
+ "name": "Terminal"
},
{
- "id": "utility-animatedtext--characters",
- "name": "Characters"
+ "id": "utility-animatedtext--matrix",
+ "name": "Matrix"
+ },
+ {
+ "id": "utility-animatedtext--matrix-terminal-grid",
+ "name": "Matrix Terminal Grid"
+ },
+ {
+ "id": "utility-animatedtext--decipher",
+ "name": "Decipher"
+ },
+ {
+ "id": "utility-animatedtext--decipher-reverse",
+ "name": "Decipher Reverse"
+ },
+ {
+ "id": "utility-animatedtext--decipher-random",
+ "name": "Decipher Random"
+ },
+ {
+ "id": "utility-animatedtext--decipher-ascii-digits",
+ "name": "Decipher Ascii Digits"
+ },
+ {
+ "id": "utility-animatedtext--decipher-terminal-boxes",
+ "name": "Decipher Terminal Boxes"
+ },
+ {
+ "id": "utility-animatedtext--decipher-block-noise",
+ "name": "Decipher Block Noise"
+ },
+ {
+ "id": "utility-animatedtext--decipher-unicode-sigils",
+ "name": "Decipher Unicode Sigils"
+ },
+ {
+ "id": "utility-animatedtext--typewriter",
+ "name": "Typewriter"
+ },
+ {
+ "id": "utility-animatedtext--reveal",
+ "name": "Reveal"
}
],
"title": "Animated Text"
@@ -410,6 +463,32 @@
],
"title": "Candlestick Chart"
},
+ "canvas-shell": {
+ "category": "utility",
+ "defaultStoryId": "layout-canvasshell--default",
+ "description": "Layout shell for canvas workspaces with top bar, left rail, right dock, and bottom slot regions.",
+ "name": "canvas-shell",
+ "stories": [
+ {
+ "id": "layout-canvasshell--default",
+ "name": "Default"
+ }
+ ],
+ "title": "Canvas Shell"
+ },
+ "canvas-view": {
+ "category": "utility",
+ "defaultStoryId": "layout-canvasview--default",
+ "description": "Interactive pan-and-zoom viewport for spatial surfaces with keyboard, wheel, and overlay support.",
+ "name": "canvas-view",
+ "stories": [
+ {
+ "id": "layout-canvasview--default",
+ "name": "Default"
+ }
+ ],
+ "title": "Canvas View"
+ },
"card": {
"category": "content",
"defaultStoryId": "content-card--default",
@@ -570,6 +649,19 @@
],
"title": "Completion Dialog"
},
+ "connector-edge": {
+ "category": "utility",
+ "defaultStoryId": "canvas-connectoredge--default",
+ "description": "Curved edge between canvas objects with optional inline label state.",
+ "name": "connector-edge",
+ "stories": [
+ {
+ "id": "canvas-connectoredge--default",
+ "name": "Default"
+ }
+ ],
+ "title": "Connector Edge"
+ },
"content-intro": {
"category": "content",
"defaultStoryId": "content-contentintro--default",
@@ -716,6 +808,19 @@
],
"title": "Dropdown Menu"
},
+ "edge-label": {
+ "category": "overlay",
+ "defaultStoryId": "canvas-edgelabel--default",
+ "description": "Inline edge label for relationship semantics such as streams, handoffs, or policies.",
+ "name": "edge-label",
+ "stories": [
+ {
+ "id": "canvas-edgelabel--default",
+ "name": "Default"
+ }
+ ],
+ "title": "Edge Label"
+ },
"exercise": {
"category": "learning",
"defaultStoryId": "learning-exercise--default",
@@ -807,6 +912,36 @@
],
"title": "Flow Diagram"
},
+ "form": {
+ "category": "form",
+ "defaultStoryId": "core-form--default",
+ "description": "Validation wrapper for composing labels, descriptions, controls, and messages.",
+ "name": "form",
+ "stories": [
+ {
+ "id": "core-form--default",
+ "name": "Default"
+ },
+ {
+ "id": "core-form--invalid",
+ "name": "Invalid"
+ }
+ ],
+ "title": "Form"
+ },
+ "group-hull": {
+ "category": "utility",
+ "defaultStoryId": "canvas-grouphull--default",
+ "description": "Durable boundary wrapper for related runtime objects sharing context or ownership.",
+ "name": "group-hull",
+ "stories": [
+ {
+ "id": "canvas-grouphull--default",
+ "name": "Default"
+ }
+ ],
+ "title": "Group Hull"
+ },
"horizontal-scroll-row": {
"category": "navigation",
"defaultStoryId": "navigation-horizontalscrollrow--default",
@@ -924,6 +1059,19 @@
],
"title": "Learning Objectives"
},
+ "left-rail": {
+ "category": "navigation",
+ "defaultStoryId": "layout-leftrail--default",
+ "description": "Compact vertical rail for canvas modes, tool actions, and secondary navigation controls.",
+ "name": "left-rail",
+ "stories": [
+ {
+ "id": "layout-leftrail--default",
+ "name": "Default"
+ }
+ ],
+ "title": "Left Rail"
+ },
"line-chart": {
"category": "data",
"defaultStoryId": "",
@@ -932,23 +1080,6 @@
"stories": [],
"title": "Line Chart"
},
- "marquee": {
- "category": "utility",
- "defaultStoryId": "utility-marquee--default",
- "description": "Continuously scrolling content lane for badges, logos, and status chips.",
- "name": "marquee",
- "stories": [
- {
- "id": "utility-marquee--default",
- "name": "Default"
- },
- {
- "id": "utility-marquee--reverse",
- "name": "Reverse"
- }
- ],
- "title": "Marquee"
- },
"live-feed": {
"category": "data",
"defaultStoryId": "data-livefeed--default",
@@ -979,6 +1110,35 @@
],
"title": "Market Treemap"
},
+ "marquee": {
+ "category": "utility",
+ "defaultStoryId": "utility-marquee--default",
+ "description": "Continuously scrolling content lane for badges, logos, and status chips.",
+ "name": "marquee",
+ "stories": [
+ {
+ "id": "utility-marquee--default",
+ "name": "Default"
+ },
+ {
+ "id": "utility-marquee--slow",
+ "name": "Slow"
+ },
+ {
+ "id": "utility-marquee--fast",
+ "name": "Fast"
+ },
+ {
+ "id": "utility-marquee--custom-duration",
+ "name": "Custom Duration"
+ },
+ {
+ "id": "utility-marquee--reverse",
+ "name": "Reverse"
+ }
+ ],
+ "title": "Marquee"
+ },
"mdx-content": {
"category": "content",
"defaultStoryId": "content-mdxcontent--default",
@@ -1022,6 +1182,19 @@
],
"title": "Metric Gauge"
},
+ "mini-map-panel": {
+ "category": "data",
+ "defaultStoryId": "panels-minimappanel--default",
+ "description": "Viewport overview panel showing canvas bounds, markers, and the current zoom window.",
+ "name": "mini-map-panel",
+ "stories": [
+ {
+ "id": "panels-minimappanel--default",
+ "name": "Default"
+ }
+ ],
+ "title": "Mini Map Panel"
+ },
"model-selector": {
"category": "form",
"defaultStoryId": "form-modelselector--default",
@@ -1035,6 +1208,27 @@
],
"title": "Model Selector"
},
+ "multi-select": {
+ "category": "form",
+ "defaultStoryId": "form-multiselect--default",
+ "description": "Popover-based multi-selection input with selected-value badges and optional search.",
+ "name": "multi-select",
+ "stories": [
+ {
+ "id": "form-multiselect--default",
+ "name": "Default"
+ },
+ {
+ "id": "form-multiselect--searchable",
+ "name": "Searchable"
+ },
+ {
+ "id": "form-multiselect--controlled",
+ "name": "Controlled"
+ }
+ ],
+ "title": "Multi Select"
+ },
"navbar-saas": {
"category": "navigation",
"defaultStoryId": "navigation-navbarsaas--default",
@@ -1091,6 +1285,32 @@
],
"title": "Number Ticker"
},
+ "object-card": {
+ "category": "content",
+ "defaultStoryId": "canvas-objectcard--default",
+ "description": "Durable object view for agents, runs, artifacts, and tasks inside the canvas.",
+ "name": "object-card",
+ "stories": [
+ {
+ "id": "canvas-objectcard--default",
+ "name": "Default"
+ }
+ ],
+ "title": "Object Card"
+ },
+ "object-handle": {
+ "category": "utility",
+ "defaultStoryId": "canvas-objecthandle--default",
+ "description": "Drag/reposition affordance for spatial objects that need a calm grab target.",
+ "name": "object-handle",
+ "stories": [
+ {
+ "id": "canvas-objecthandle--default",
+ "name": "Default"
+ }
+ ],
+ "title": "Object Handle"
+ },
"order-book": {
"category": "data",
"defaultStoryId": "data-orderbook--default",
@@ -1276,6 +1496,19 @@
],
"title": "Resizable"
},
+ "right-dock": {
+ "category": "navigation",
+ "defaultStoryId": "layout-rightdock--default",
+ "description": "Context dock for inspectors, summaries, and secondary canvas panels.",
+ "name": "right-dock",
+ "stories": [
+ {
+ "id": "layout-rightdock--default",
+ "name": "Default"
+ }
+ ],
+ "title": "Right Dock"
+ },
"role-badge": {
"category": "content",
"defaultStoryId": "account-billing-rolebadge--default",
@@ -1349,6 +1582,27 @@
],
"title": "Search Dialog"
},
+ "segmented-control": {
+ "category": "form",
+ "defaultStoryId": "form-segmentedcontrol--default",
+ "description": "Single-choice segmented selector for switching modes, views, or filters.",
+ "name": "segmented-control",
+ "stories": [
+ {
+ "id": "form-segmentedcontrol--default",
+ "name": "Default"
+ },
+ {
+ "id": "form-segmentedcontrol--disabled",
+ "name": "Disabled"
+ },
+ {
+ "id": "form-segmentedcontrol--controlled",
+ "name": "Controlled"
+ }
+ ],
+ "title": "Segmented Control"
+ },
"select": {
"category": "form",
"defaultStoryId": "form-select--default",
@@ -1542,6 +1796,246 @@
{
"id": "utility-spinner--default",
"name": "Default"
+ },
+ {
+ "id": "utility-unicodespinner--all-animations",
+ "name": "All Animations"
+ },
+ {
+ "id": "utility-unicodespinner--arc",
+ "name": "Arc"
+ },
+ {
+ "id": "utility-unicodespinner--arrow",
+ "name": "Arrow"
+ },
+ {
+ "id": "utility-unicodespinner--balloon",
+ "name": "Balloon"
+ },
+ {
+ "id": "utility-unicodespinner--bounce",
+ "name": "Bounce"
+ },
+ {
+ "id": "utility-unicodespinner--braille",
+ "name": "Braille"
+ },
+ {
+ "id": "utility-unicodespinner--braillewave",
+ "name": "Braillewave"
+ },
+ {
+ "id": "utility-unicodespinner--breathe",
+ "name": "Breathe"
+ },
+ {
+ "id": "utility-unicodespinner--cascade",
+ "name": "Cascade"
+ },
+ {
+ "id": "utility-unicodespinner--checkerboard",
+ "name": "Checkerboard"
+ },
+ {
+ "id": "utility-unicodespinner--circle-halves",
+ "name": "Circle Halves"
+ },
+ {
+ "id": "utility-unicodespinner--circle-quarters",
+ "name": "Circle Quarters"
+ },
+ {
+ "id": "utility-unicodespinner--clock",
+ "name": "Clock"
+ },
+ {
+ "id": "utility-unicodespinner--columns",
+ "name": "Columns"
+ },
+ {
+ "id": "utility-unicodespinner--diagswipe",
+ "name": "Diagswipe"
+ },
+ {
+ "id": "utility-unicodespinner--dna",
+ "name": "Dna"
+ },
+ {
+ "id": "utility-unicodespinner--dots",
+ "name": "Dots"
+ },
+ {
+ "id": "utility-unicodespinner--dots-2",
+ "name": "Dots 2"
+ },
+ {
+ "id": "utility-unicodespinner--dots-3",
+ "name": "Dots 3"
+ },
+ {
+ "id": "utility-unicodespinner--dots-4",
+ "name": "Dots 4"
+ },
+ {
+ "id": "utility-unicodespinner--dots-5",
+ "name": "Dots 5"
+ },
+ {
+ "id": "utility-unicodespinner--dots-6",
+ "name": "Dots 6"
+ },
+ {
+ "id": "utility-unicodespinner--dots-7",
+ "name": "Dots 7"
+ },
+ {
+ "id": "utility-unicodespinner--dots-8",
+ "name": "Dots 8"
+ },
+ {
+ "id": "utility-unicodespinner--dots-9",
+ "name": "Dots 9"
+ },
+ {
+ "id": "utility-unicodespinner--dots-10",
+ "name": "Dots 10"
+ },
+ {
+ "id": "utility-unicodespinner--dots-11",
+ "name": "Dots 11"
+ },
+ {
+ "id": "utility-unicodespinner--dots-12",
+ "name": "Dots 12"
+ },
+ {
+ "id": "utility-unicodespinner--dots-13",
+ "name": "Dots 13"
+ },
+ {
+ "id": "utility-unicodespinner--dots-14",
+ "name": "Dots 14"
+ },
+ {
+ "id": "utility-unicodespinner--dots-circle",
+ "name": "Dots Circle"
+ },
+ {
+ "id": "utility-unicodespinner--double-arrow",
+ "name": "Double Arrow"
+ },
+ {
+ "id": "utility-unicodespinner--dqpb",
+ "name": "Dqpb"
+ },
+ {
+ "id": "utility-unicodespinner--earth",
+ "name": "Earth"
+ },
+ {
+ "id": "utility-unicodespinner--fillsweep",
+ "name": "Fillsweep"
+ },
+ {
+ "id": "utility-unicodespinner--grow-horizontal",
+ "name": "Grow Horizontal"
+ },
+ {
+ "id": "utility-unicodespinner--grow-vertical",
+ "name": "Grow Vertical"
+ },
+ {
+ "id": "utility-unicodespinner--hearts",
+ "name": "Hearts"
+ },
+ {
+ "id": "utility-unicodespinner--helix",
+ "name": "Helix"
+ },
+ {
+ "id": "utility-unicodespinner--moon",
+ "name": "Moon"
+ },
+ {
+ "id": "utility-unicodespinner--noise",
+ "name": "Noise"
+ },
+ {
+ "id": "utility-unicodespinner--orbit",
+ "name": "Orbit"
+ },
+ {
+ "id": "utility-unicodespinner--point",
+ "name": "Point"
+ },
+ {
+ "id": "utility-unicodespinner--pulse",
+ "name": "Pulse"
+ },
+ {
+ "id": "utility-unicodespinner--rain",
+ "name": "Rain"
+ },
+ {
+ "id": "utility-unicodespinner--rolling-line",
+ "name": "Rolling Line"
+ },
+ {
+ "id": "utility-unicodespinner--sand",
+ "name": "Sand"
+ },
+ {
+ "id": "utility-unicodespinner--scan",
+ "name": "Scan"
+ },
+ {
+ "id": "utility-unicodespinner--scanline",
+ "name": "Scanline"
+ },
+ {
+ "id": "utility-unicodespinner--simple-dots",
+ "name": "Simple Dots"
+ },
+ {
+ "id": "utility-unicodespinner--simple-dots-scrolling",
+ "name": "Simple Dots Scrolling"
+ },
+ {
+ "id": "utility-unicodespinner--snake",
+ "name": "Snake"
+ },
+ {
+ "id": "utility-unicodespinner--sparkle",
+ "name": "Sparkle"
+ },
+ {
+ "id": "utility-unicodespinner--speaker",
+ "name": "Speaker"
+ },
+ {
+ "id": "utility-unicodespinner--square-corners",
+ "name": "Square Corners"
+ },
+ {
+ "id": "utility-unicodespinner--toggle",
+ "name": "Toggle"
+ },
+ {
+ "id": "utility-unicodespinner--triangle",
+ "name": "Triangle"
+ },
+ {
+ "id": "utility-unicodespinner--wave",
+ "name": "Wave"
+ },
+ {
+ "id": "utility-unicodespinner--waverows",
+ "name": "Waverows"
+ },
+ {
+ "id": "utility-unicodespinner--weather",
+ "name": "Weather"
}
],
"title": "Spinner"
@@ -1709,6 +2203,27 @@
],
"title": "Tabs"
},
+ "tags-input": {
+ "category": "form",
+ "defaultStoryId": "form-tagsinput--default",
+ "description": "Keyboard-friendly tag editor for adding and removing string values.",
+ "name": "tags-input",
+ "stories": [
+ {
+ "id": "form-tagsinput--default",
+ "name": "Default"
+ },
+ {
+ "id": "form-tagsinput--empty",
+ "name": "Empty"
+ },
+ {
+ "id": "form-tagsinput--controlled",
+ "name": "Controlled"
+ }
+ ],
+ "title": "Tags Input"
+ },
"terminal": {
"category": "content",
"defaultStoryId": "content-terminal--default",
@@ -1876,6 +2391,19 @@
],
"title": "Tooltip"
},
+ "top-bar": {
+ "category": "navigation",
+ "defaultStoryId": "layout-topbar--default",
+ "description": "Workspace header bar for titles, leading controls, centered navigation, and trailing actions.",
+ "name": "top-bar",
+ "stories": [
+ {
+ "id": "layout-topbar--default",
+ "name": "Default"
+ }
+ ],
+ "title": "Top Bar"
+ },
"tour": {
"category": "learning",
"defaultStoryId": "learning-tour--default",
@@ -2035,6 +2563,19 @@
],
"title": "Watchlist"
},
+ "workspace-switcher": {
+ "category": "navigation",
+ "defaultStoryId": "navigation-workspaceswitcher--default",
+ "description": "Segmented workspace picker for switching between canvas views and operational contexts.",
+ "name": "workspace-switcher",
+ "stories": [
+ {
+ "id": "navigation-workspaceswitcher--default",
+ "name": "Default"
+ }
+ ],
+ "title": "Workspace Switcher"
+ },
"world-clock-bar": {
"category": "data",
"defaultStoryId": "data-worldclockbar--default",
@@ -2051,5 +2592,18 @@
}
],
"title": "World Clock Bar"
+ },
+ "zoom-hud": {
+ "category": "overlay",
+ "defaultStoryId": "overlay-zoomhud--default",
+ "description": "Zoom controls with current percentage, increment buttons, and reset action for canvas views.",
+ "name": "zoom-hud",
+ "stories": [
+ {
+ "id": "overlay-zoomhud--default",
+ "name": "Default"
+ }
+ ],
+ "title": "Zoom HUD"
}
-}
+}
\ No newline at end of file
diff --git a/apps/registry/registry.json b/apps/registry/registry.json
index 73f3808..c2b70d3 100644
--- a/apps/registry/registry.json
+++ b/apps/registry/registry.json
@@ -162,6 +162,21 @@
"dependencies": [],
"category": "overlay"
},
+ {
+ "name": "anchor-port",
+ "type": "registry:component",
+ "title": "Anchor Port",
+ "description": "Port marker for object inputs, outputs, and bidirectional links on the canvas.",
+ "files": [
+ {
+ "path": "registry/default/anchor-port/anchor-port.tsx",
+ "type": "registry:component"
+ }
+ ],
+ "registryDependencies": [],
+ "dependencies": [],
+ "category": "utility"
+ },
{
"name": "animated-text",
"type": "registry:component",
@@ -387,6 +402,36 @@
"dependencies": [],
"category": "data"
},
+ {
+ "name": "canvas-shell",
+ "type": "registry:component",
+ "title": "Canvas Shell",
+ "description": "Layout shell for canvas workspaces with top bar, left rail, right dock, and bottom slot regions.",
+ "files": [
+ {
+ "path": "registry/default/canvas-shell/canvas-shell.tsx",
+ "type": "registry:component"
+ }
+ ],
+ "registryDependencies": [],
+ "dependencies": [],
+ "category": "utility"
+ },
+ {
+ "name": "canvas-view",
+ "type": "registry:component",
+ "title": "Canvas View",
+ "description": "Interactive pan-and-zoom viewport for spatial surfaces with keyboard, wheel, and overlay support.",
+ "files": [
+ {
+ "path": "registry/default/canvas-view/canvas-view.tsx",
+ "type": "registry:component"
+ }
+ ],
+ "registryDependencies": [],
+ "dependencies": [],
+ "category": "utility"
+ },
{
"name": "card",
"type": "registry:component",
@@ -567,6 +612,21 @@
"dependencies": [],
"category": "learning"
},
+ {
+ "name": "connector-edge",
+ "type": "registry:component",
+ "title": "Connector Edge",
+ "description": "Curved edge between canvas objects with optional inline label state.",
+ "files": [
+ {
+ "path": "registry/default/connector-edge/connector-edge.tsx",
+ "type": "registry:component"
+ }
+ ],
+ "registryDependencies": [],
+ "dependencies": [],
+ "category": "utility"
+ },
{
"name": "content-intro",
"type": "registry:component",
@@ -719,6 +779,21 @@
"dependencies": [],
"category": "overlay"
},
+ {
+ "name": "edge-label",
+ "type": "registry:component",
+ "title": "Edge Label",
+ "description": "Inline edge label for relationship semantics such as streams, handoffs, or policies.",
+ "files": [
+ {
+ "path": "registry/default/edge-label/edge-label.tsx",
+ "type": "registry:component"
+ }
+ ],
+ "registryDependencies": [],
+ "dependencies": [],
+ "category": "overlay"
+ },
{
"name": "exercise",
"type": "registry:component",
@@ -824,6 +899,39 @@
"dependencies": [],
"category": "data"
},
+ {
+ "name": "form",
+ "type": "registry:component",
+ "title": "Form",
+ "description": "Validation wrapper for composing labels, descriptions, controls, and messages.",
+ "files": [
+ {
+ "path": "registry/default/form/form.tsx",
+ "type": "registry:component"
+ }
+ ],
+ "registryDependencies": [
+ "input",
+ "label"
+ ],
+ "dependencies": [],
+ "category": "form"
+ },
+ {
+ "name": "group-hull",
+ "type": "registry:component",
+ "title": "Group Hull",
+ "description": "Durable boundary wrapper for related runtime objects sharing context or ownership.",
+ "files": [
+ {
+ "path": "registry/default/group-hull/group-hull.tsx",
+ "type": "registry:component"
+ }
+ ],
+ "registryDependencies": [],
+ "dependencies": [],
+ "category": "utility"
+ },
{
"name": "horizontal-scroll-row",
"type": "registry:component",
@@ -964,34 +1072,34 @@
"category": "learning"
},
{
- "name": "line-chart",
+ "name": "left-rail",
"type": "registry:component",
- "title": "Line Chart",
- "description": "Renders a line chart for data visualization.",
+ "title": "Left Rail",
+ "description": "Compact vertical rail for canvas modes, tool actions, and secondary navigation controls.",
"files": [
{
- "path": "registry/default/line-chart/line-chart.tsx",
+ "path": "registry/default/left-rail/left-rail.tsx",
"type": "registry:component"
}
],
"registryDependencies": [],
"dependencies": [],
- "category": "data"
+ "category": "navigation"
},
{
- "name": "marquee",
+ "name": "line-chart",
"type": "registry:component",
- "title": "Marquee",
- "description": "Continuously scrolling content lane for badges, logos, and status chips.",
+ "title": "Line Chart",
+ "description": "Renders a line chart for data visualization.",
"files": [
{
- "path": "registry/default/marquee/marquee.tsx",
+ "path": "registry/default/line-chart/line-chart.tsx",
"type": "registry:component"
}
],
"registryDependencies": [],
"dependencies": [],
- "category": "utility"
+ "category": "data"
},
{
"name": "live-feed",
@@ -1023,6 +1131,21 @@
"dependencies": [],
"category": "data"
},
+ {
+ "name": "marquee",
+ "type": "registry:component",
+ "title": "Marquee",
+ "description": "Continuously scrolling content lane for badges, logos, and status chips.",
+ "files": [
+ {
+ "path": "registry/default/marquee/marquee.tsx",
+ "type": "registry:component"
+ }
+ ],
+ "registryDependencies": [],
+ "dependencies": [],
+ "category": "utility"
+ },
{
"name": "mdx-content",
"type": "registry:component",
@@ -1068,6 +1191,21 @@
"dependencies": [],
"category": "data"
},
+ {
+ "name": "mini-map-panel",
+ "type": "registry:component",
+ "title": "Mini Map Panel",
+ "description": "Viewport overview panel showing canvas bounds, markers, and the current zoom window.",
+ "files": [
+ {
+ "path": "registry/default/mini-map-panel/mini-map-panel.tsx",
+ "type": "registry:component"
+ }
+ ],
+ "registryDependencies": [],
+ "dependencies": [],
+ "category": "data"
+ },
{
"name": "model-selector",
"type": "registry:component",
@@ -1083,6 +1221,26 @@
"dependencies": [],
"category": "form"
},
+ {
+ "name": "multi-select",
+ "type": "registry:component",
+ "title": "Multi Select",
+ "description": "Popover-based multi-selection input with selected-value badges and optional search.",
+ "files": [
+ {
+ "path": "registry/default/multi-select/multi-select.tsx",
+ "type": "registry:component"
+ }
+ ],
+ "registryDependencies": [
+ "badge",
+ "button",
+ "command",
+ "popover"
+ ],
+ "dependencies": [],
+ "category": "form"
+ },
{
"name": "navbar-saas",
"type": "registry:component",
@@ -1143,6 +1301,36 @@
"dependencies": [],
"category": "utility"
},
+ {
+ "name": "object-card",
+ "type": "registry:component",
+ "title": "Object Card",
+ "description": "Durable object view for agents, runs, artifacts, and tasks inside the canvas.",
+ "files": [
+ {
+ "path": "registry/default/object-card/object-card.tsx",
+ "type": "registry:component"
+ }
+ ],
+ "registryDependencies": [],
+ "dependencies": [],
+ "category": "content"
+ },
+ {
+ "name": "object-handle",
+ "type": "registry:component",
+ "title": "Object Handle",
+ "description": "Drag/reposition affordance for spatial objects that need a calm grab target.",
+ "files": [
+ {
+ "path": "registry/default/object-handle/object-handle.tsx",
+ "type": "registry:component"
+ }
+ ],
+ "registryDependencies": [],
+ "dependencies": [],
+ "category": "utility"
+ },
{
"name": "order-book",
"type": "registry:component",
@@ -1338,6 +1526,21 @@
"dependencies": [],
"category": "utility"
},
+ {
+ "name": "right-dock",
+ "type": "registry:component",
+ "title": "Right Dock",
+ "description": "Context dock for inspectors, summaries, and secondary canvas panels.",
+ "files": [
+ {
+ "path": "registry/default/right-dock/right-dock.tsx",
+ "type": "registry:component"
+ }
+ ],
+ "registryDependencies": [],
+ "dependencies": [],
+ "category": "navigation"
+ },
{
"name": "role-badge",
"type": "registry:component",
@@ -1413,6 +1616,23 @@
"dependencies": [],
"category": "learning"
},
+ {
+ "name": "segmented-control",
+ "type": "registry:component",
+ "title": "Segmented Control",
+ "description": "Single-choice segmented selector for switching modes, views, or filters.",
+ "files": [
+ {
+ "path": "registry/default/segmented-control/segmented-control.tsx",
+ "type": "registry:component"
+ }
+ ],
+ "registryDependencies": [
+ "toggle-group"
+ ],
+ "dependencies": [],
+ "category": "form"
+ },
{
"name": "select",
"type": "registry:component",
@@ -1775,6 +1995,23 @@
"dependencies": [],
"category": "navigation"
},
+ {
+ "name": "tags-input",
+ "type": "registry:component",
+ "title": "Tags Input",
+ "description": "Keyboard-friendly tag editor for adding and removing string values.",
+ "files": [
+ {
+ "path": "registry/default/tags-input/tags-input.tsx",
+ "type": "registry:component"
+ }
+ ],
+ "registryDependencies": [
+ "badge"
+ ],
+ "dependencies": [],
+ "category": "form"
+ },
{
"name": "terminal",
"type": "registry:component",
@@ -1942,6 +2179,21 @@
"dependencies": [],
"category": "overlay"
},
+ {
+ "name": "top-bar",
+ "type": "registry:component",
+ "title": "Top Bar",
+ "description": "Workspace header bar for titles, leading controls, centered navigation, and trailing actions.",
+ "files": [
+ {
+ "path": "registry/default/top-bar/top-bar.tsx",
+ "type": "registry:component"
+ }
+ ],
+ "registryDependencies": [],
+ "dependencies": [],
+ "category": "navigation"
+ },
{
"name": "tour",
"type": "registry:component",
@@ -2107,6 +2359,21 @@
"dependencies": [],
"category": "data"
},
+ {
+ "name": "workspace-switcher",
+ "type": "registry:component",
+ "title": "Workspace Switcher",
+ "description": "Segmented workspace picker for switching between canvas views and operational contexts.",
+ "files": [
+ {
+ "path": "registry/default/workspace-switcher/workspace-switcher.tsx",
+ "type": "registry:component"
+ }
+ ],
+ "registryDependencies": [],
+ "dependencies": [],
+ "category": "navigation"
+ },
{
"name": "world-clock-bar",
"type": "registry:component",
@@ -2121,6 +2388,23 @@
"registryDependencies": [],
"dependencies": [],
"category": "data"
+ },
+ {
+ "name": "zoom-hud",
+ "type": "registry:component",
+ "title": "Zoom HUD",
+ "description": "Zoom controls with current percentage, increment buttons, and reset action for canvas views.",
+ "files": [
+ {
+ "path": "registry/default/zoom-hud/zoom-hud.tsx",
+ "type": "registry:component"
+ }
+ ],
+ "registryDependencies": [],
+ "dependencies": [
+ "lucide-react"
+ ],
+ "category": "overlay"
}
]
}
diff --git a/apps/registry/registry/default/anchor-port/anchor-port.tsx b/apps/registry/registry/default/anchor-port/anchor-port.tsx
new file mode 100644
index 0000000..303805e
--- /dev/null
+++ b/apps/registry/registry/default/anchor-port/anchor-port.tsx
@@ -0,0 +1 @@
+export { AnchorPort, type AnchorPortProps } from '@vllnt/ui'
diff --git a/apps/registry/registry/default/canvas-shell/canvas-shell.tsx b/apps/registry/registry/default/canvas-shell/canvas-shell.tsx
new file mode 100644
index 0000000..ebaac76
--- /dev/null
+++ b/apps/registry/registry/default/canvas-shell/canvas-shell.tsx
@@ -0,0 +1 @@
+export { CanvasShell, type CanvasShellProps } from '@vllnt/ui'
diff --git a/apps/registry/registry/default/canvas-view/canvas-view.tsx b/apps/registry/registry/default/canvas-view/canvas-view.tsx
new file mode 100644
index 0000000..3c3c7a8
--- /dev/null
+++ b/apps/registry/registry/default/canvas-view/canvas-view.tsx
@@ -0,0 +1 @@
+export { CanvasView, type CanvasViewProps } from '@vllnt/ui'
diff --git a/apps/registry/registry/default/connector-edge/connector-edge.tsx b/apps/registry/registry/default/connector-edge/connector-edge.tsx
new file mode 100644
index 0000000..36e755c
--- /dev/null
+++ b/apps/registry/registry/default/connector-edge/connector-edge.tsx
@@ -0,0 +1 @@
+export { ConnectorEdge, type ConnectorEdgeProps } from '@vllnt/ui'
diff --git a/apps/registry/registry/default/edge-label/edge-label.tsx b/apps/registry/registry/default/edge-label/edge-label.tsx
new file mode 100644
index 0000000..c26bb5e
--- /dev/null
+++ b/apps/registry/registry/default/edge-label/edge-label.tsx
@@ -0,0 +1 @@
+export { EdgeLabel, type EdgeLabelProps } from '@vllnt/ui'
diff --git a/apps/registry/registry/default/form/form.tsx b/apps/registry/registry/default/form/form.tsx
new file mode 100644
index 0000000..dc8cf19
--- /dev/null
+++ b/apps/registry/registry/default/form/form.tsx
@@ -0,0 +1,9 @@
+// Re-export from @vllnt/ui package
+export {
+ Form,
+ FormControl,
+ FormDescription,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from '@vllnt/ui'
diff --git a/apps/registry/registry/default/group-hull/group-hull.tsx b/apps/registry/registry/default/group-hull/group-hull.tsx
new file mode 100644
index 0000000..dcfc6d8
--- /dev/null
+++ b/apps/registry/registry/default/group-hull/group-hull.tsx
@@ -0,0 +1 @@
+export { GroupHull, type GroupHullProps } from '@vllnt/ui'
diff --git a/apps/registry/registry/default/left-rail/left-rail.tsx b/apps/registry/registry/default/left-rail/left-rail.tsx
new file mode 100644
index 0000000..51a7aaf
--- /dev/null
+++ b/apps/registry/registry/default/left-rail/left-rail.tsx
@@ -0,0 +1 @@
+export { LeftRail, type LeftRailProps } from '@vllnt/ui'
diff --git a/apps/registry/registry/default/mini-map-panel/mini-map-panel.tsx b/apps/registry/registry/default/mini-map-panel/mini-map-panel.tsx
new file mode 100644
index 0000000..bc77837
--- /dev/null
+++ b/apps/registry/registry/default/mini-map-panel/mini-map-panel.tsx
@@ -0,0 +1 @@
+export { MiniMapPanel, type MiniMapPanelProps } from '@vllnt/ui'
diff --git a/apps/registry/registry/default/multi-select/multi-select.tsx b/apps/registry/registry/default/multi-select/multi-select.tsx
new file mode 100644
index 0000000..e64cb73
--- /dev/null
+++ b/apps/registry/registry/default/multi-select/multi-select.tsx
@@ -0,0 +1,2 @@
+// Re-export from @vllnt/ui package
+export { MultiSelect } from "@vllnt/ui";
diff --git a/apps/registry/registry/default/object-card/object-card.tsx b/apps/registry/registry/default/object-card/object-card.tsx
new file mode 100644
index 0000000..2f4b2bd
--- /dev/null
+++ b/apps/registry/registry/default/object-card/object-card.tsx
@@ -0,0 +1 @@
+export { ObjectCard, type ObjectCardProps } from '@vllnt/ui'
diff --git a/apps/registry/registry/default/object-handle/object-handle.tsx b/apps/registry/registry/default/object-handle/object-handle.tsx
new file mode 100644
index 0000000..a103d92
--- /dev/null
+++ b/apps/registry/registry/default/object-handle/object-handle.tsx
@@ -0,0 +1 @@
+export { ObjectHandle, type ObjectHandleProps } from '@vllnt/ui'
diff --git a/apps/registry/registry/default/right-dock/right-dock.tsx b/apps/registry/registry/default/right-dock/right-dock.tsx
new file mode 100644
index 0000000..f861401
--- /dev/null
+++ b/apps/registry/registry/default/right-dock/right-dock.tsx
@@ -0,0 +1 @@
+export { RightDock, type RightDockProps } from '@vllnt/ui'
diff --git a/apps/registry/registry/default/segmented-control/segmented-control.tsx b/apps/registry/registry/default/segmented-control/segmented-control.tsx
new file mode 100644
index 0000000..610244b
--- /dev/null
+++ b/apps/registry/registry/default/segmented-control/segmented-control.tsx
@@ -0,0 +1,8 @@
+export {
+ SegmentedControl,
+ SegmentedControlItem,
+ type SegmentedControlItemProps,
+ segmentedControlItemVariants,
+ type SegmentedControlProps,
+ segmentedControlVariants,
+} from '@vllnt/ui'
diff --git a/apps/registry/registry/default/tags-input/tags-input.tsx b/apps/registry/registry/default/tags-input/tags-input.tsx
new file mode 100644
index 0000000..7a715b7
--- /dev/null
+++ b/apps/registry/registry/default/tags-input/tags-input.tsx
@@ -0,0 +1,2 @@
+// Re-export from @vllnt/ui package
+export { TagsInput } from "@vllnt/ui";
diff --git a/apps/registry/registry/default/top-bar/top-bar.tsx b/apps/registry/registry/default/top-bar/top-bar.tsx
new file mode 100644
index 0000000..8c02770
--- /dev/null
+++ b/apps/registry/registry/default/top-bar/top-bar.tsx
@@ -0,0 +1 @@
+export { TopBar, type TopBarProps } from '@vllnt/ui'
diff --git a/apps/registry/registry/default/workspace-switcher/workspace-switcher.tsx b/apps/registry/registry/default/workspace-switcher/workspace-switcher.tsx
new file mode 100644
index 0000000..5860d22
--- /dev/null
+++ b/apps/registry/registry/default/workspace-switcher/workspace-switcher.tsx
@@ -0,0 +1 @@
+export { WorkspaceSwitcher, type WorkspaceSwitcherProps } from '@vllnt/ui'
diff --git a/apps/registry/registry/default/zoom-hud/zoom-hud.tsx b/apps/registry/registry/default/zoom-hud/zoom-hud.tsx
new file mode 100644
index 0000000..abbea74
--- /dev/null
+++ b/apps/registry/registry/default/zoom-hud/zoom-hud.tsx
@@ -0,0 +1 @@
+export { ZoomHUD, type ZoomHUDProps } from '@vllnt/ui'
diff --git a/packages/ui/.snapshots/anchor-port/anchor-port.visual.tsx-chromium/anchor-port-default.png b/packages/ui/.snapshots/anchor-port/anchor-port.visual.tsx-chromium/anchor-port-default.png
new file mode 100644
index 0000000..2eab916
Binary files /dev/null and b/packages/ui/.snapshots/anchor-port/anchor-port.visual.tsx-chromium/anchor-port-default.png differ
diff --git a/packages/ui/.snapshots/canvas-shell/canvas-shell.visual.tsx-chromium/canvas-shell-default.png b/packages/ui/.snapshots/canvas-shell/canvas-shell.visual.tsx-chromium/canvas-shell-default.png
new file mode 100644
index 0000000..e539623
Binary files /dev/null and b/packages/ui/.snapshots/canvas-shell/canvas-shell.visual.tsx-chromium/canvas-shell-default.png differ
diff --git a/packages/ui/.snapshots/connector-edge/connector-edge.visual.tsx-chromium/connector-edge-default.png b/packages/ui/.snapshots/connector-edge/connector-edge.visual.tsx-chromium/connector-edge-default.png
new file mode 100644
index 0000000..728aee7
Binary files /dev/null and b/packages/ui/.snapshots/connector-edge/connector-edge.visual.tsx-chromium/connector-edge-default.png differ
diff --git a/packages/ui/.snapshots/edge-label/edge-label.visual.tsx-chromium/edge-label-default.png b/packages/ui/.snapshots/edge-label/edge-label.visual.tsx-chromium/edge-label-default.png
new file mode 100644
index 0000000..cb518b3
Binary files /dev/null and b/packages/ui/.snapshots/edge-label/edge-label.visual.tsx-chromium/edge-label-default.png differ
diff --git a/packages/ui/.snapshots/form/form.visual.tsx-chromium/form-invalid.png b/packages/ui/.snapshots/form/form.visual.tsx-chromium/form-invalid.png
new file mode 100644
index 0000000..d9a75e3
Binary files /dev/null and b/packages/ui/.snapshots/form/form.visual.tsx-chromium/form-invalid.png differ
diff --git a/packages/ui/.snapshots/group-hull/group-hull.visual.tsx-chromium/group-hull-default.png b/packages/ui/.snapshots/group-hull/group-hull.visual.tsx-chromium/group-hull-default.png
new file mode 100644
index 0000000..5db3f26
Binary files /dev/null and b/packages/ui/.snapshots/group-hull/group-hull.visual.tsx-chromium/group-hull-default.png differ
diff --git a/packages/ui/.snapshots/mini-map-panel/mini-map-panel.visual.tsx-chromium/mini-map-panel-default.png b/packages/ui/.snapshots/mini-map-panel/mini-map-panel.visual.tsx-chromium/mini-map-panel-default.png
new file mode 100644
index 0000000..07fbf99
Binary files /dev/null and b/packages/ui/.snapshots/mini-map-panel/mini-map-panel.visual.tsx-chromium/mini-map-panel-default.png differ
diff --git a/packages/ui/.snapshots/multi-select/multi-select.visual.tsx-chromium/multi-select-selected-values.png b/packages/ui/.snapshots/multi-select/multi-select.visual.tsx-chromium/multi-select-selected-values.png
new file mode 100644
index 0000000..6c88361
Binary files /dev/null and b/packages/ui/.snapshots/multi-select/multi-select.visual.tsx-chromium/multi-select-selected-values.png differ
diff --git a/packages/ui/.snapshots/object-card/object-card.visual.tsx-chromium/object-card-default.png b/packages/ui/.snapshots/object-card/object-card.visual.tsx-chromium/object-card-default.png
new file mode 100644
index 0000000..9f355a7
Binary files /dev/null and b/packages/ui/.snapshots/object-card/object-card.visual.tsx-chromium/object-card-default.png differ
diff --git a/packages/ui/.snapshots/object-handle/object-handle.visual.tsx-chromium/object-handle-default.png b/packages/ui/.snapshots/object-handle/object-handle.visual.tsx-chromium/object-handle-default.png
new file mode 100644
index 0000000..914ffe1
Binary files /dev/null and b/packages/ui/.snapshots/object-handle/object-handle.visual.tsx-chromium/object-handle-default.png differ
diff --git a/packages/ui/.snapshots/segmented-control/segmented-control.visual.tsx-chromium/segmented-control-controlled-selection.png b/packages/ui/.snapshots/segmented-control/segmented-control.visual.tsx-chromium/segmented-control-controlled-selection.png
new file mode 100644
index 0000000..aca4a2f
Binary files /dev/null and b/packages/ui/.snapshots/segmented-control/segmented-control.visual.tsx-chromium/segmented-control-controlled-selection.png differ
diff --git a/packages/ui/.snapshots/tags-input/tags-input.visual.tsx-chromium/tags-input-default-tags.png b/packages/ui/.snapshots/tags-input/tags-input.visual.tsx-chromium/tags-input-default-tags.png
new file mode 100644
index 0000000..3305284
Binary files /dev/null and b/packages/ui/.snapshots/tags-input/tags-input.visual.tsx-chromium/tags-input-default-tags.png differ
diff --git a/packages/ui/src/components/activity-log/activity-log.tsx b/packages/ui/src/components/activity-log/activity-log.tsx
index 9c6ecdb..6f68980 100644
--- a/packages/ui/src/components/activity-log/activity-log.tsx
+++ b/packages/ui/src/components/activity-log/activity-log.tsx
@@ -1,3 +1,5 @@
+"use client";
+
import { forwardRef, useMemo, useState } from "react";
import { ArrowRight, ChevronLeft, ChevronRight } from "lucide-react";
diff --git a/packages/ui/src/components/anchor-port/anchor-port.stories.tsx b/packages/ui/src/components/anchor-port/anchor-port.stories.tsx
new file mode 100644
index 0000000..495f068
--- /dev/null
+++ b/packages/ui/src/components/anchor-port/anchor-port.stories.tsx
@@ -0,0 +1,19 @@
+import type { Meta, StoryObj } from "@storybook/react-vite";
+
+import { AnchorPort } from "./anchor-port";
+
+const meta = {
+ component: AnchorPort,
+ render: () => (
+
+ ),
+ title: "Canvas/AnchorPort",
+} satisfies Meta;
+
+export default meta;
+type Story = StoryObj;
+export const Default: Story = {};
diff --git a/packages/ui/src/components/anchor-port/anchor-port.test.tsx b/packages/ui/src/components/anchor-port/anchor-port.test.tsx
new file mode 100644
index 0000000..0cfda83
--- /dev/null
+++ b/packages/ui/src/components/anchor-port/anchor-port.test.tsx
@@ -0,0 +1,22 @@
+import { render, screen } from "@testing-library/react";
+import { describe, expect, it } from "vitest";
+
+import { AnchorPort } from "./anchor-port";
+
+describe("AnchorPort", () => {
+ it("exposes side, state, and tone data attributes", () => {
+ render(
+ ,
+ );
+
+ const port = screen.getByLabelText("Input port");
+ expect(port).toHaveAttribute("data-side", "left");
+ expect(port).toHaveAttribute("data-state", "active");
+ expect(port).toHaveAttribute("data-tone", "input");
+ });
+});
diff --git a/packages/ui/src/components/anchor-port/anchor-port.tsx b/packages/ui/src/components/anchor-port/anchor-port.tsx
new file mode 100644
index 0000000..8f91183
--- /dev/null
+++ b/packages/ui/src/components/anchor-port/anchor-port.tsx
@@ -0,0 +1,66 @@
+import { forwardRef } from "react";
+
+import { cn } from "../../lib/utils";
+
+export type AnchorPortProps = React.ComponentPropsWithoutRef<"span"> & {
+ side?: "bottom" | "left" | "right" | "top";
+ state?: "active" | "blocked" | "idle";
+ tone?: "bidirectional" | "input" | "output";
+};
+
+const toneClasses: Record, string> = {
+ bidirectional:
+ "border-violet-500/40 bg-violet-500/15 text-violet-700 dark:text-violet-300",
+ input: "border-sky-500/40 bg-sky-500/15 text-sky-700 dark:text-sky-300",
+ output:
+ "border-emerald-500/40 bg-emerald-500/15 text-emerald-700 dark:text-emerald-300",
+};
+
+const stateClasses: Record, string> = {
+ active: "scale-100 opacity-100",
+ blocked: "opacity-60 saturate-50",
+ idle: "opacity-80",
+};
+
+const sideClasses: Record, string> = {
+ bottom: "self-end",
+ left: "self-start",
+ right: "self-end",
+ top: "self-start",
+};
+
+const AnchorPort = forwardRef(
+ (
+ {
+ className,
+ side = "right",
+ state = "idle",
+ tone = "bidirectional",
+ ...props
+ },
+ ref,
+ ) => (
+
+
+
+ ),
+);
+
+AnchorPort.displayName = "AnchorPort";
+
+export { AnchorPort };
diff --git a/packages/ui/src/components/anchor-port/anchor-port.visual.tsx b/packages/ui/src/components/anchor-port/anchor-port.visual.tsx
new file mode 100644
index 0000000..9b0af02
--- /dev/null
+++ b/packages/ui/src/components/anchor-port/anchor-port.visual.tsx
@@ -0,0 +1,17 @@
+import { expect, test } from "@playwright/experimental-ct-react";
+
+import { AnchorPort } from "./anchor-port";
+
+test.describe("AnchorPort Visual", () => {
+ test("default", async ({ mount, page }) => {
+ await mount(
+ ,
+ );
+
+ await expect(page).toHaveScreenshot("anchor-port-default.png");
+ });
+});
\ No newline at end of file
diff --git a/packages/ui/src/components/anchor-port/index.ts b/packages/ui/src/components/anchor-port/index.ts
new file mode 100644
index 0000000..b66af95
--- /dev/null
+++ b/packages/ui/src/components/anchor-port/index.ts
@@ -0,0 +1 @@
+export { AnchorPort, type AnchorPortProps } from "./anchor-port";
diff --git a/packages/ui/src/components/animated-text/animated-text.tsx b/packages/ui/src/components/animated-text/animated-text.tsx
index db34399..94c196d 100644
--- a/packages/ui/src/components/animated-text/animated-text.tsx
+++ b/packages/ui/src/components/animated-text/animated-text.tsx
@@ -1,3 +1,5 @@
+"use client";
+
import * as React from "react";
import { cn } from "../../lib/utils";
diff --git a/packages/ui/src/components/bottom-bar/bottom-bar.stories.tsx b/packages/ui/src/components/bottom-bar/bottom-bar.stories.tsx
new file mode 100644
index 0000000..aa65abc
--- /dev/null
+++ b/packages/ui/src/components/bottom-bar/bottom-bar.stories.tsx
@@ -0,0 +1,23 @@
+import type { Meta, StoryObj } from "@storybook/react-vite";
+
+import { Button } from "../button";
+import { BottomBar } from "./bottom-bar";
+
+const meta = {
+ component: BottomBar,
+ render: () => (
+
+ 7 awaiting action
}
+ leading={System healthy enough to proceed
}
+ trailing={Open inbox }
+ />
+
+ ),
+ title: "Layout/BottomBar",
+} satisfies Meta;
+
+export default meta;
+type Story = StoryObj;
+
+export const Default: Story = {};
diff --git a/packages/ui/src/components/bottom-bar/bottom-bar.tsx b/packages/ui/src/components/bottom-bar/bottom-bar.tsx
new file mode 100644
index 0000000..bd6679c
--- /dev/null
+++ b/packages/ui/src/components/bottom-bar/bottom-bar.tsx
@@ -0,0 +1,38 @@
+import { forwardRef } from "react";
+
+import type { ReactNode } from "react";
+
+import { cn } from "../../lib/utils";
+
+export type BottomBarProps = React.ComponentPropsWithoutRef<"div"> & {
+ center?: ReactNode;
+ leading?: ReactNode;
+ trailing?: ReactNode;
+};
+
+const BottomBar = forwardRef(
+ ({ center, className, leading, trailing, ...props }, ref) => (
+
+
{leading}
+ {center ? (
+
+ {center}
+
+ ) : null}
+
+ {trailing}
+
+
+ ),
+);
+
+BottomBar.displayName = "BottomBar";
+
+export { BottomBar };
diff --git a/packages/ui/src/components/bottom-bar/index.ts b/packages/ui/src/components/bottom-bar/index.ts
new file mode 100644
index 0000000..9e85168
--- /dev/null
+++ b/packages/ui/src/components/bottom-bar/index.ts
@@ -0,0 +1 @@
+export { BottomBar, type BottomBarProps } from "./bottom-bar";
diff --git a/packages/ui/src/components/canvas-shell/canvas-foundation-demo.tsx b/packages/ui/src/components/canvas-shell/canvas-foundation-demo.tsx
new file mode 100644
index 0000000..f4c4b39
--- /dev/null
+++ b/packages/ui/src/components/canvas-shell/canvas-foundation-demo.tsx
@@ -0,0 +1,237 @@
+import { Activity, Bot, Compass, Layers3, Sparkles } from "lucide-react";
+
+import { BottomBar } from "../bottom-bar";
+import { Button } from "../button";
+import { CanvasView } from "../canvas-view";
+import { ChatDockSection } from "../chat-dock-section";
+import { GlassPanel } from "../glass-panel";
+import { LeftRail } from "../left-rail";
+import { RightDock } from "../right-dock";
+import { TopBar } from "../top-bar";
+import { WorkspaceSwitcher } from "../workspace-switcher";
+
+import { CanvasShell } from "./canvas-shell";
+
+function DemoLeftBar() {
+ return (
+
+
+
+
+ }
+ title="Mode"
+ >
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+function DemoTopBar() {
+ return (
+
+
+
+
+ }
+ subtitle="Calm operating surface"
+ title="Operator workspace"
+ trailing={
+
+ Open command
+
+ }
+ >
+
+
+
+ );
+}
+
+function DemoRightBar() {
+ return (
+
+
+ Chat stays contextual and secondary to the center workspace.
+
+ }
+ header={
+
+ Context shifts by route while the shell stays stable.
+
+ }
+ title="Context"
+ >
+
+
+
+
+ Selected context
+
+
+ Inbox triage
+
+
+ Landing route keeps the assistant close to the operational queue
+ instead of taking over the center canvas.
+
+
+
+
+
+ );
+}
+
+function DemoBottomBar() {
+ return (
+
+
+
+ 3 errors
+
+
+ 7 awaiting action
+
+ >
+ }
+ leading={
+
+
+ System healthy enough to proceed
+
+ }
+ trailing={
+
+ Open inbox
+
+ }
+ />
+
+ );
+}
+
+function DemoCanvasObjects() {
+ return (
+
+ );
+}
+
+function CanvasFoundationDemo() {
+ return (
+ }
+ className="h-[100dvh] min-h-[720px]"
+ contentPadding={{ bottom: 120, left: 112, right: 392, top: 112 }}
+ leftBar={ }
+ rightBar={ }
+ topBar={ }
+ >
+
+
+
+
+ );
+}
+
+export { CanvasFoundationDemo };
diff --git a/packages/ui/src/components/canvas-shell/canvas-shell-route-config.ts b/packages/ui/src/components/canvas-shell/canvas-shell-route-config.ts
new file mode 100644
index 0000000..df9b140
--- /dev/null
+++ b/packages/ui/src/components/canvas-shell/canvas-shell-route-config.ts
@@ -0,0 +1,17 @@
+import type { ReactNode } from "react";
+
+export type CanvasShellInsets = {
+ bottom?: number | string;
+ left?: number | string;
+ right?: number | string;
+ top?: number | string;
+};
+
+export type CanvasShellRouteConfig = {
+ bottomBar?: ReactNode;
+ center: ReactNode;
+ contentPadding?: CanvasShellInsets;
+ leftBar?: ReactNode;
+ rightBar?: ReactNode;
+ topBar?: ReactNode;
+};
diff --git a/packages/ui/src/components/canvas-shell/canvas-shell.stories.tsx b/packages/ui/src/components/canvas-shell/canvas-shell.stories.tsx
new file mode 100644
index 0000000..23700cf
--- /dev/null
+++ b/packages/ui/src/components/canvas-shell/canvas-shell.stories.tsx
@@ -0,0 +1,15 @@
+import type { Meta, StoryObj } from "@storybook/react-vite";
+
+import { CanvasShell } from "./canvas-shell";
+import { CanvasFoundationDemo } from "./canvas-foundation-demo";
+
+const meta = {
+ component: CanvasShell,
+ render: () => ,
+ title: "Layout/CanvasShell",
+} satisfies Meta;
+
+export default meta;
+type Story = StoryObj;
+
+export const Default: Story = {};
diff --git a/packages/ui/src/components/canvas-shell/canvas-shell.test.tsx b/packages/ui/src/components/canvas-shell/canvas-shell.test.tsx
new file mode 100644
index 0000000..40dbfd8
--- /dev/null
+++ b/packages/ui/src/components/canvas-shell/canvas-shell.test.tsx
@@ -0,0 +1,271 @@
+import { render, screen } from "@testing-library/react";
+import { describe, expect, it } from "vitest";
+
+import { CanvasShell } from "./canvas-shell";
+
+function getElement(node: Element | null, label: string) {
+ expect(node).toBeInstanceOf(HTMLElement);
+
+ if (!(node instanceof HTMLElement)) {
+ throw new TypeError(`${label} not found`);
+ }
+
+ return node;
+}
+
+function getShellElements(container: HTMLElement) {
+ const shell = getElement(container.firstElementChild, "CanvasShell shell");
+ const contentHost = getElement(
+ shell.querySelector('[data-slot="canvas-shell-content"]'),
+ "CanvasShell content host",
+ );
+
+ return { contentHost, shell };
+}
+
+describe("CanvasShell", () => {
+ it("renders top, left, main, right, and bottom regions", () => {
+ render(
+ bottom host}
+ leftBar={left rail
}
+ rightBar={right dock
}
+ topBar={top bar
}
+ >
+ main view
+ ,
+ );
+
+ expect(screen.getByText("top bar")).toBeInTheDocument();
+ expect(screen.getByText("left rail")).toBeInTheDocument();
+ expect(screen.getByText("main view")).toBeInTheDocument();
+ expect(screen.getByText("right dock")).toBeInTheDocument();
+ expect(screen.getByText("bottom host")).toBeInTheDocument();
+ });
+
+ it("exposes safe-area CSS vars for content spacing", () => {
+ const { container } = render(
+
+ spaced main
+ ,
+ );
+
+ const { contentHost, shell } = getShellElements(container);
+
+ expect(shell.getAttribute("style")).toContain(
+ "--canvas-shell-safe-top: 77px",
+ );
+ expect(shell.getAttribute("style")).toContain(
+ "--canvas-shell-safe-right: 55px",
+ );
+ expect(shell.getAttribute("style")).toContain(
+ "--canvas-shell-safe-bottom: 88px",
+ );
+ expect(shell.getAttribute("style")).toContain(
+ "--canvas-shell-safe-left: 44px",
+ );
+ expect(contentHost.getAttribute("style")).toContain(
+ "padding-top: var(--canvas-shell-safe-top)",
+ );
+ expect(contentHost.getAttribute("style")).toContain(
+ "padding-right: var(--canvas-shell-safe-right)",
+ );
+ expect(contentHost.getAttribute("style")).toContain(
+ "padding-bottom: var(--canvas-shell-safe-bottom)",
+ );
+ expect(contentHost.getAttribute("style")).toContain(
+ "padding-left: var(--canvas-shell-safe-left)",
+ );
+ });
+
+ it("reserves default chrome footprint in floating mode instead of only the inset", () => {
+ const { container } = render(
+ bottom host}
+ leftBar={left rail
}
+ rightBar={right dock
}
+ topBar={top bar
}
+ >
+ floating main
+ ,
+ );
+
+ const { shell } = getShellElements(container);
+ const shellStyle = shell.getAttribute("style");
+
+ expect(shellStyle).toContain(
+ "--canvas-shell-safe-top: calc(16px + 3.5rem)",
+ );
+ expect(shellStyle).toContain(
+ "--canvas-shell-safe-right: calc(16px + 18rem)",
+ );
+ expect(shellStyle).toContain(
+ "--canvas-shell-safe-bottom: calc(16px + 3.5rem)",
+ );
+ expect(shellStyle).toContain(
+ "--canvas-shell-safe-left: calc(16px + 4.5rem)",
+ );
+ expect(shellStyle).not.toContain("112px");
+ expect(shellStyle).not.toContain("392px");
+ });
+
+ it("keeps legacy slot props on the legacy layout path", () => {
+ const { container } = render(
+ Legacy bottom}
+ leftRail={Legacy left
}
+ rightDock={Legacy right
}
+ topBar={Legacy top
}
+ >
+ Legacy main
+ ,
+ );
+
+ const shell = getElement(container.firstElementChild, "legacy shell");
+ const grid = getElement(shell.children.item(1), "legacy grid");
+ const bottomHost = getElement(shell.children.item(2), "legacy bottom host");
+
+ expect(shell.className).toContain("flex-col");
+ expect(shell.className).not.toContain("relative isolate");
+ expect(grid.className).toContain("grid-cols-[auto_minmax(0,1fr)_auto]");
+ expect(bottomHost.className).toContain("border-t");
+ expect(screen.getByText("Legacy top")).toBeInTheDocument();
+ expect(screen.getByText("Legacy left")).toBeInTheDocument();
+ expect(screen.getByText("Legacy right")).toBeInTheDocument();
+ expect(screen.getByText("Legacy bottom")).toBeInTheDocument();
+ expect(screen.getByText("Legacy main")).toBeInTheDocument();
+ });
+
+ it("keeps the legacy layout when new chrome props are null", () => {
+ const { container } = render(
+ Legacy top}
+ >
+ Legacy main
+ ,
+ );
+
+ const shell = getElement(container.firstElementChild, "legacy shell");
+
+ expect(shell.className).toContain("flex-col");
+ expect(shell.className).not.toContain("relative isolate");
+ expect(screen.getByText("Legacy top")).toBeInTheDocument();
+ expect(screen.getByText("Legacy main")).toBeInTheDocument();
+ });
+
+ it("keeps an explicit undefined chromeInset on the legacy layout path", () => {
+ const { container } = render(
+ Legacy top}>
+ Legacy main
+ ,
+ );
+
+ const shell = getElement(container.firstElementChild, "legacy shell");
+
+ expect(shell.className).toContain("flex-col");
+ expect(shell.className).not.toContain("relative isolate");
+ expect(screen.getByText("Legacy top")).toBeInTheDocument();
+ expect(screen.getByText("Legacy main")).toBeInTheDocument();
+ });
+
+ it("treats an explicit chromeInset as floating-mode intent", () => {
+ const { container } = render(
+
+ Inset main
+ ,
+ );
+
+ const { shell } = getShellElements(container);
+
+ expect(shell.className).toContain("relative isolate flex");
+ expect(shell.getAttribute("style")).toContain(
+ "--canvas-shell-safe-top: 16px",
+ );
+ expect(screen.getByText("Inset main")).toBeInTheDocument();
+ });
+
+ it("keeps falsey chrome props on the legacy layout path", () => {
+ const { container } = render(
+
+ Falsey main
+ ,
+ );
+
+ const shell = getElement(container.firstElementChild, "legacy shell");
+
+ expect(shell.className).toContain("flex-col");
+ expect(shell.className).not.toContain("relative isolate");
+ expect(screen.getByText("Falsey main")).toBeInTheDocument();
+ expect(screen.queryByText("0")).not.toBeInTheDocument();
+ });
+
+ it("keeps floating chrome aligned with legacy document order", () => {
+ render(
+ Bottom action}
+ leftBar={Left action }
+ rightBar={Right action }
+ topBar={Top action }
+ >
+ Main action
+ ,
+ );
+
+ const topAction = screen.getByRole("button", { name: "Top action" });
+ const mainAction = screen.getByRole("button", { name: "Main action" });
+ const rightAction = screen.getByRole("button", { name: "Right action" });
+ const bottomAction = screen.getByRole("button", { name: "Bottom action" });
+
+ expect(
+ topAction.compareDocumentPosition(mainAction) &
+ Node.DOCUMENT_POSITION_FOLLOWING,
+ ).toBeTruthy();
+ expect(
+ mainAction.compareDocumentPosition(rightAction) &
+ Node.DOCUMENT_POSITION_FOLLOWING,
+ ).toBeTruthy();
+ expect(
+ mainAction.compareDocumentPosition(bottomAction) &
+ Node.DOCUMENT_POSITION_FOLLOWING,
+ ).toBeTruthy();
+ });
+
+ it("preserves deprecated side slots during floating migrations", () => {
+ const { container } = render(
+ legacy bottom}
+ contentPadding={{ top: 24 }}
+ leftRail={legacy left
}
+ rightDock={legacy right
}
+ topBar={legacy top
}
+ >
+ legacy main
+ ,
+ );
+
+ const { shell } = getShellElements(container);
+ const shellStyle = shell.getAttribute("style");
+
+ expect(shell.className).toContain("relative isolate flex");
+ expect(shellStyle).toContain("--canvas-shell-safe-top: 24px");
+ expect(shellStyle).toContain(
+ "--canvas-shell-safe-left: calc(16px + 4.5rem)",
+ );
+ expect(shellStyle).toContain(
+ "--canvas-shell-safe-right: calc(16px + 18rem)",
+ );
+ expect(shellStyle).toContain(
+ "--canvas-shell-safe-bottom: calc(16px + 3.5rem)",
+ );
+ expect(screen.getByText("legacy top")).toBeInTheDocument();
+ expect(screen.getByText("legacy left")).toBeInTheDocument();
+ expect(screen.getByText("legacy right")).toBeInTheDocument();
+ expect(screen.getByText("legacy bottom")).toBeInTheDocument();
+ expect(screen.getByText("legacy main")).toBeInTheDocument();
+ });
+});
diff --git a/packages/ui/src/components/canvas-shell/canvas-shell.tsx b/packages/ui/src/components/canvas-shell/canvas-shell.tsx
new file mode 100644
index 0000000..15f08ed
--- /dev/null
+++ b/packages/ui/src/components/canvas-shell/canvas-shell.tsx
@@ -0,0 +1,349 @@
+import { forwardRef } from "react";
+
+import type { CSSProperties, ReactNode } from "react";
+
+import { cn } from "../../lib/utils";
+
+import type { CanvasShellInsets } from "./canvas-shell-route-config";
+
+export type CanvasShellProps = React.ComponentPropsWithoutRef<"section"> & {
+ bottomBar?: ReactNode;
+ bottomSlot?: ReactNode;
+ children?: ReactNode;
+ chromeInset?: number | string;
+ contentPadding?: CanvasShellInsets;
+ leftBar?: ReactNode;
+ leftRail?: ReactNode;
+ rightBar?: ReactNode;
+ rightDock?: ReactNode;
+ topBar?: ReactNode;
+};
+
+type CanvasShellChromeProps = {
+ bottomBar?: ReactNode;
+ inset: string;
+ leftBar?: ReactNode;
+ rightBar?: ReactNode;
+ topBar?: ReactNode;
+};
+
+type CanvasShellSafeAreaStyle = CSSProperties & {
+ "--canvas-shell-safe-bottom": string;
+ "--canvas-shell-safe-left": string;
+ "--canvas-shell-safe-right": string;
+ "--canvas-shell-safe-top": string;
+};
+
+function toInsetValue(value: number | string | undefined) {
+ if (typeof value === "number") {
+ return `${value}px`;
+ }
+
+ return value;
+}
+
+const FLOATING_BOTTOM_BAR_FOOTPRINT = "3.5rem";
+const FLOATING_LEFT_BAR_FOOTPRINT = "4.5rem";
+const FLOATING_RIGHT_BAR_FOOTPRINT = "18rem";
+const FLOATING_TOP_BAR_FOOTPRINT = "3.5rem";
+
+function getReservedInset(
+ inset: string,
+ footprint: string,
+ override: number | string | undefined,
+) {
+ return toInsetValue(override) ?? `calc(${inset} + ${footprint})`;
+}
+
+function getSafeAreaInsets({
+ chromeInset,
+ contentPadding,
+ hasBottomBar,
+ hasLeftBar,
+ hasRightBar,
+ hasTopBar,
+}: {
+ chromeInset: number | string;
+ contentPadding?: CanvasShellInsets;
+ hasBottomBar: boolean;
+ hasLeftBar: boolean;
+ hasRightBar: boolean;
+ hasTopBar: boolean;
+}) {
+ const inset = toInsetValue(chromeInset) ?? "16px";
+
+ return {
+ bottom: hasBottomBar
+ ? getReservedInset(
+ inset,
+ FLOATING_BOTTOM_BAR_FOOTPRINT,
+ contentPadding?.bottom,
+ )
+ : (toInsetValue(contentPadding?.bottom) ?? inset),
+ left: hasLeftBar
+ ? getReservedInset(
+ inset,
+ FLOATING_LEFT_BAR_FOOTPRINT,
+ contentPadding?.left,
+ )
+ : (toInsetValue(contentPadding?.left) ?? inset),
+ right: hasRightBar
+ ? getReservedInset(
+ inset,
+ FLOATING_RIGHT_BAR_FOOTPRINT,
+ contentPadding?.right,
+ )
+ : (toInsetValue(contentPadding?.right) ?? inset),
+ top: hasTopBar
+ ? getReservedInset(inset, FLOATING_TOP_BAR_FOOTPRINT, contentPadding?.top)
+ : (toInsetValue(contentPadding?.top) ?? inset),
+ };
+}
+
+function getSafeAreaStyle(
+ insets: ReturnType,
+): CanvasShellSafeAreaStyle {
+ return {
+ "--canvas-shell-safe-bottom": insets.bottom,
+ "--canvas-shell-safe-left": insets.left,
+ "--canvas-shell-safe-right": insets.right,
+ "--canvas-shell-safe-top": insets.top,
+ } satisfies CanvasShellSafeAreaStyle;
+}
+
+const hasChromeContent = Boolean;
+
+type CanvasShellChromeAfterProps = Pick<
+ CanvasShellChromeProps,
+ "bottomBar" | "inset" | "rightBar"
+>;
+
+function CanvasShellChromeBefore({
+ inset,
+ leftBar,
+ topBar,
+}: Pick) {
+ return (
+ <>
+ {hasChromeContent(topBar) ? (
+
+ ) : null}
+ {hasChromeContent(leftBar) ? (
+
+ ) : null}
+ >
+ );
+}
+
+function CanvasShellChromeAfter({
+ bottomBar,
+ inset,
+ rightBar,
+}: CanvasShellChromeAfterProps) {
+ return (
+ <>
+ {hasChromeContent(rightBar) ? (
+
+ ) : null}
+ {hasChromeContent(bottomBar) ? (
+
+ ) : null}
+ >
+ );
+}
+
+function renderLegacyCanvasShell(
+ {
+ bottomBar: _bottomBar,
+ bottomSlot,
+ children,
+ chromeInset: _chromeInset = 16,
+ className,
+ contentPadding: _contentPadding,
+ leftBar: _leftBar,
+ leftRail,
+ rightBar: _rightBar,
+ rightDock,
+ style,
+ topBar,
+ ...props
+ }: CanvasShellProps,
+ ref: React.ForwardedRef,
+) {
+ return (
+
+ {topBar}
+
+ {leftRail ??
}
+
+ {children}
+
+ {rightDock ??
}
+
+ {bottomSlot ? (
+
+ {bottomSlot}
+
+ ) : null}
+
+ );
+}
+
+function renderFloatingContent(
+ children: ReactNode,
+ contentStyle: CSSProperties,
+) {
+ return (
+
+ );
+}
+
+function renderFloatingCanvasShell(
+ {
+ bottomBar,
+ bottomSlot,
+ children,
+ chromeInset = 16,
+ className,
+ contentPadding,
+ leftBar,
+ leftRail,
+ rightBar,
+ rightDock,
+ style,
+ topBar,
+ ...props
+ }: CanvasShellProps,
+ ref: React.ForwardedRef,
+) {
+ const inset = toInsetValue(chromeInset) ?? "16px";
+ const resolvedBottomBar = bottomBar ?? bottomSlot;
+ const resolvedLeftBar = leftBar ?? leftRail;
+ const resolvedRightBar = rightBar ?? rightDock;
+
+ const hasTopBar = hasChromeContent(topBar);
+ const hasLeftBar = hasChromeContent(resolvedLeftBar);
+ const hasRightBar = hasChromeContent(resolvedRightBar);
+ const hasBottomBar = hasChromeContent(resolvedBottomBar);
+ const safeAreaInsets = getSafeAreaInsets({
+ chromeInset,
+ contentPadding,
+ hasBottomBar,
+ hasLeftBar,
+ hasRightBar,
+ hasTopBar,
+ });
+ const mergedStyle = {
+ ...getSafeAreaStyle(safeAreaInsets),
+ ...style,
+ } satisfies CSSProperties;
+ const contentStyle = {
+ paddingBottom: "var(--canvas-shell-safe-bottom)",
+ paddingLeft: "var(--canvas-shell-safe-left)",
+ paddingRight: "var(--canvas-shell-safe-right)",
+ paddingTop: "var(--canvas-shell-safe-top)",
+ } satisfies CSSProperties;
+
+ return (
+
+
+
+ {renderFloatingContent(children, contentStyle)}
+
+
+ );
+}
+
+const CanvasShell = forwardRef((props, ref) => {
+ const { bottomBar, chromeInset, contentPadding, leftBar, rightBar } = props;
+ const hasExplicitChromeInset = Object.prototype.hasOwnProperty.call(
+ props,
+ "chromeInset",
+ );
+ const usesFloatingChrome =
+ hasChromeContent(bottomBar) ||
+ hasChromeContent(leftBar) ||
+ hasChromeContent(rightBar) ||
+ contentPadding !== undefined ||
+ (hasExplicitChromeInset && chromeInset !== undefined);
+
+ if (!usesFloatingChrome) {
+ return renderLegacyCanvasShell(props, ref);
+ }
+
+ return renderFloatingCanvasShell(props, ref);
+});
+
+CanvasShell.displayName = "CanvasShell";
+
+export { CanvasShell };
diff --git a/packages/ui/src/components/canvas-shell/canvas-shell.visual.tsx b/packages/ui/src/components/canvas-shell/canvas-shell.visual.tsx
new file mode 100644
index 0000000..4af7066
--- /dev/null
+++ b/packages/ui/src/components/canvas-shell/canvas-shell.visual.tsx
@@ -0,0 +1,10 @@
+import { expect, test } from "@playwright/experimental-ct-react";
+
+import { CanvasFoundationDemo } from "./canvas-foundation-demo";
+
+test.describe("CanvasShell Visual", () => {
+ test("default", async ({ mount, page }) => {
+ await mount( );
+ await expect(page).toHaveScreenshot("canvas-shell-default.png");
+ });
+});
diff --git a/packages/ui/src/components/canvas-shell/index.ts b/packages/ui/src/components/canvas-shell/index.ts
new file mode 100644
index 0000000..b144a34
--- /dev/null
+++ b/packages/ui/src/components/canvas-shell/index.ts
@@ -0,0 +1,5 @@
+export { CanvasShell, type CanvasShellProps } from "./canvas-shell";
+export {
+ type CanvasShellInsets,
+ type CanvasShellRouteConfig,
+} from "./canvas-shell-route-config";
diff --git a/packages/ui/src/components/canvas-view/canvas-view.stories.tsx b/packages/ui/src/components/canvas-view/canvas-view.stories.tsx
new file mode 100644
index 0000000..368b323
--- /dev/null
+++ b/packages/ui/src/components/canvas-view/canvas-view.stories.tsx
@@ -0,0 +1,30 @@
+import type { Meta, StoryObj } from "@storybook/react-vite";
+
+import { CanvasView } from "./canvas-view";
+import { ZoomHUD } from "../zoom-hud";
+
+const meta = {
+ component: CanvasView,
+ render: () => (
+ }
+ >
+
+
+ Canvas objects can live on a calm spatial surface.
+
+
+ Pan with space-drag and zoom with modified wheel input.
+
+
+
+ ),
+ title: "Layout/CanvasView",
+} satisfies Meta;
+
+export default meta;
+type Story = StoryObj;
+
+export const Default: Story = {};
diff --git a/packages/ui/src/components/canvas-view/canvas-view.test.tsx b/packages/ui/src/components/canvas-view/canvas-view.test.tsx
new file mode 100644
index 0000000..abae322
--- /dev/null
+++ b/packages/ui/src/components/canvas-view/canvas-view.test.tsx
@@ -0,0 +1,147 @@
+import { fireEvent, render, screen } from "@testing-library/react";
+import { describe, expect, it, vi } from "vitest";
+
+import { CanvasView } from "./canvas-view";
+
+describe("CanvasView", () => {
+ it("announces interaction guidance and renders children", () => {
+ render(
+
+ scene object
+ ,
+ );
+
+ expect(screen.getByText("scene object")).toBeInTheDocument();
+ expect(screen.getByText(/hold space and drag/i)).toBeInTheDocument();
+ });
+
+ it("zooms with modified wheel input", () => {
+ const onViewportChange = vi.fn();
+
+ render( );
+
+ fireEvent.wheel(screen.getByRole("button", { name: "Canvas workspace" }), {
+ ctrlKey: true,
+ deltaY: -100,
+ });
+
+ expect(onViewportChange).toHaveBeenLastCalledWith({
+ x: 0,
+ y: 0,
+ zoom: 1.1,
+ });
+ });
+
+ it("pans with arrow keys", () => {
+ const onViewportChange = vi.fn();
+
+ render( );
+
+ const viewport = screen.getByRole("button", {
+ name: "Canvas workspace",
+ });
+ viewport.focus();
+ fireEvent.keyDown(viewport, { key: "ArrowRight" });
+
+ expect(onViewportChange).toHaveBeenLastCalledWith({
+ x: -40,
+ y: 0,
+ zoom: 1,
+ });
+ });
+
+ it("prevents text selection on the interaction layer", () => {
+ render( );
+
+ expect(
+ screen.getByRole("button", { name: "Canvas workspace" }),
+ ).toHaveClass("select-none");
+ });
+
+ it("preserves pointer access to canvas children", () => {
+ const onClick = vi.fn();
+
+ render(
+
+
+ node action
+
+ ,
+ );
+
+ fireEvent.click(screen.getByRole("button", { name: "node action" }));
+
+ expect(onClick).toHaveBeenCalledTimes(1);
+ });
+
+ it("resets the viewport with the zero key", () => {
+ const onViewportChange = vi.fn();
+
+ render(
+ ,
+ );
+
+ const viewport = screen.getByRole("button", {
+ name: "Canvas workspace",
+ });
+ viewport.focus();
+ fireEvent.keyDown(viewport, { key: "0" });
+
+ expect(onViewportChange).toHaveBeenLastCalledWith({
+ x: 24,
+ y: 32,
+ zoom: 1.2,
+ });
+ });
+
+ it("does not steal wheel events from nested scroll containers", () => {
+ const onViewportChange = vi.fn();
+
+ render(
+
+
+
Scrollable canvas node
+
+ ,
+ );
+
+ const nestedScroll = screen.getByTestId("nested-scroll");
+ Object.defineProperty(nestedScroll, "clientHeight", {
+ configurable: true,
+ value: 80,
+ });
+ Object.defineProperty(nestedScroll, "scrollHeight", {
+ configurable: true,
+ value: 240,
+ });
+
+ fireEvent.wheel(nestedScroll, { deltaY: 40 });
+
+ expect(onViewportChange).not.toHaveBeenCalled();
+ });
+
+ it("does not steal keyboard input from nested form controls", () => {
+ const onViewportChange = vi.fn();
+
+ render(
+
+
+ ,
+ );
+
+ const input = screen.getByRole("textbox", { name: "Node title" });
+ input.focus();
+
+ fireEvent.keyDown(input, { key: "ArrowRight" });
+ fireEvent.keyDown(input, { key: "0" });
+ fireEvent.keyDown(input, { key: "+" });
+
+ expect(onViewportChange).not.toHaveBeenCalled();
+ });
+});
diff --git a/packages/ui/src/components/canvas-view/canvas-view.tsx b/packages/ui/src/components/canvas-view/canvas-view.tsx
new file mode 100644
index 0000000..863b411
--- /dev/null
+++ b/packages/ui/src/components/canvas-view/canvas-view.tsx
@@ -0,0 +1,652 @@
+"use client";
+
+import {
+ forwardRef,
+ useCallback,
+ useEffect,
+ useId,
+ useImperativeHandle,
+ useRef,
+ useState,
+} from "react";
+
+import type {
+ KeyboardEvent as ReactKeyboardEvent,
+ PointerEvent as ReactPointerEvent,
+ WheelEvent as ReactWheelEvent,
+} from "react";
+
+import { cn } from "../../lib/utils";
+
+export type CanvasViewport = {
+ x: number;
+ y: number;
+ zoom: number;
+};
+
+export type CanvasViewHandle = {
+ resetViewport: () => void;
+ setViewport: (viewport: CanvasViewport) => void;
+};
+
+export type CanvasViewProps = Omit<
+ React.ComponentPropsWithoutRef<"div">,
+ "onScroll"
+> & {
+ defaultViewport?: CanvasViewport;
+ maxZoom?: number;
+ minZoom?: number;
+ onViewportChange?: (viewport: CanvasViewport) => void;
+ overlay?: React.ReactNode;
+ zoomStep?: number;
+};
+
+type DragOrigin = {
+ pointerX: number;
+ pointerY: number;
+ viewport: CanvasViewport;
+};
+
+type ViewportReference = {
+ current: CanvasViewport;
+};
+
+const DEFAULT_VIEWPORT: CanvasViewport = { x: 0, y: 0, zoom: 1 };
+
+const INTERACTIVE_ELEMENT_SELECTOR = [
+ "a[href]",
+ "button",
+ 'input:not([type="hidden"])',
+ "select",
+ "textarea",
+ "summary",
+ '[contenteditable=""]',
+ '[contenteditable="true"]',
+ '[role="button"]',
+ '[role="checkbox"]',
+ '[role="link"]',
+ '[role="menuitem"]',
+ '[role="option"]',
+ '[role="radio"]',
+ '[role="slider"]',
+ '[role="spinbutton"]',
+ '[role="switch"]',
+ '[role="tab"]',
+ '[role="textbox"]',
+].join(", ");
+
+function clampZoom(value: number, minZoom: number, maxZoom: number) {
+ return Math.min(maxZoom, Math.max(minZoom, Number(value.toFixed(2))));
+}
+
+function isHtmlElement(target: EventTarget | null): target is HTMLElement {
+ return target instanceof HTMLElement;
+}
+
+function isInteractiveDescendant(
+ element: HTMLElement,
+ container: HTMLDivElement,
+) {
+ const interactiveAncestor = element.closest(INTERACTIVE_ELEMENT_SELECTOR);
+
+ return (
+ interactiveAncestor !== null && container.contains(interactiveAncestor)
+ );
+}
+
+function supportsScrollableOverflow(value: string) {
+ return value === "auto" || value === "overlay" || value === "scroll";
+}
+
+function hasScrollableAxis(element: HTMLElement, axis: "x" | "y") {
+ const style = window.getComputedStyle(element);
+
+ if (axis === "x") {
+ return (
+ supportsScrollableOverflow(style.overflowX) &&
+ element.scrollWidth > element.clientWidth
+ );
+ }
+
+ return (
+ supportsScrollableOverflow(style.overflowY) &&
+ element.scrollHeight > element.clientHeight
+ );
+}
+
+function hasScrollableAncestor(
+ element: HTMLElement,
+ container: HTMLDivElement,
+ delta: { x: number; y: number },
+): boolean {
+ if (!container.contains(element) || element === container) {
+ return false;
+ }
+
+ if (
+ (delta.x !== 0 && hasScrollableAxis(element, "x")) ||
+ (delta.y !== 0 && hasScrollableAxis(element, "y"))
+ ) {
+ return true;
+ }
+
+ return element.parentElement === null
+ ? false
+ : hasScrollableAncestor(element.parentElement, container, delta);
+}
+
+function shouldHandleCanvasKeyboardEvent(
+ event: ReactKeyboardEvent,
+) {
+ if (
+ isHtmlElement(event.target) &&
+ event.target !== event.currentTarget &&
+ isInteractiveDescendant(event.target, event.currentTarget)
+ ) {
+ return false;
+ }
+
+ return true;
+}
+
+function shouldHandleCanvasWheelEvent(event: ReactWheelEvent) {
+ if (
+ isHtmlElement(event.target) &&
+ hasScrollableAncestor(event.target, event.currentTarget, {
+ x: event.deltaX,
+ y: event.deltaY,
+ })
+ ) {
+ return false;
+ }
+
+ return true;
+}
+
+function isPanGesture(
+ event: ReactPointerEvent,
+ isSpacePressed: boolean,
+) {
+ return event.button === 1 || (event.button === 0 && isSpacePressed);
+}
+
+function createViewportKeyHandler({
+ nudgeViewport,
+ resetViewport,
+ setViewport,
+ viewportRef,
+ zoomStep,
+}: {
+ nudgeViewport: (deltaX: number, deltaY: number) => void;
+ resetViewport: () => void;
+ setViewport: (viewport: CanvasViewport) => void;
+ viewportRef: ViewportReference;
+ zoomStep: number;
+}) {
+ return (event: ReactKeyboardEvent) => {
+ if (event.key === "+" || event.key === "=") {
+ event.preventDefault();
+ setViewport({
+ ...viewportRef.current,
+ zoom: viewportRef.current.zoom + zoomStep,
+ });
+ return;
+ }
+
+ if (event.key === "-") {
+ event.preventDefault();
+ setViewport({
+ ...viewportRef.current,
+ zoom: viewportRef.current.zoom - zoomStep,
+ });
+ return;
+ }
+
+ if (event.key === "0") {
+ event.preventDefault();
+ resetViewport();
+ return;
+ }
+
+ if (event.key === "ArrowLeft") {
+ event.preventDefault();
+ nudgeViewport(40, 0);
+ return;
+ }
+
+ if (event.key === "ArrowRight") {
+ event.preventDefault();
+ nudgeViewport(-40, 0);
+ return;
+ }
+
+ if (event.key === "ArrowUp") {
+ event.preventDefault();
+ nudgeViewport(0, 40);
+ return;
+ }
+
+ if (event.key === "ArrowDown") {
+ event.preventDefault();
+ nudgeViewport(0, -40);
+ }
+ };
+}
+
+function useViewportState({
+ defaultViewport,
+ maxZoom,
+ minZoom,
+ onViewportChange,
+}: {
+ defaultViewport: CanvasViewport;
+ maxZoom: number;
+ minZoom: number;
+ onViewportChange?: (viewport: CanvasViewport) => void;
+}) {
+ const defaultViewportRef = useRef(defaultViewport);
+ const viewportRef = useRef(defaultViewport);
+ const [viewport, setViewport] = useState(defaultViewport);
+
+ useEffect(() => {
+ defaultViewportRef.current = defaultViewport;
+ }, [defaultViewport]);
+
+ const applyViewport = useCallback(
+ (nextViewport: CanvasViewport) => {
+ const resolvedViewport = {
+ x: Math.round(nextViewport.x),
+ y: Math.round(nextViewport.y),
+ zoom: clampZoom(nextViewport.zoom, minZoom, maxZoom),
+ };
+
+ viewportRef.current = resolvedViewport;
+ setViewport(resolvedViewport);
+ onViewportChange?.(resolvedViewport);
+ },
+ [maxZoom, minZoom, onViewportChange],
+ );
+
+ const resetViewport = useCallback(() => {
+ applyViewport(defaultViewportRef.current);
+ }, [applyViewport]);
+
+ const nudgeViewport = useCallback(
+ (deltaX: number, deltaY: number) => {
+ const currentViewport = viewportRef.current;
+ applyViewport({
+ x: currentViewport.x + deltaX,
+ y: currentViewport.y + deltaY,
+ zoom: currentViewport.zoom,
+ });
+ },
+ [applyViewport],
+ );
+
+ return {
+ nudgeViewport,
+ resetViewport,
+ setViewport: applyViewport,
+ viewport,
+ viewportRef,
+ };
+}
+
+function useCanvasKeyboardInteractions({
+ nudgeViewport,
+ resetViewport,
+ setViewport,
+ viewportRef,
+ zoomStep,
+}: {
+ nudgeViewport: (deltaX: number, deltaY: number) => void;
+ resetViewport: () => void;
+ setViewport: (viewport: CanvasViewport) => void;
+ viewportRef: ViewportReference;
+ zoomStep: number;
+}) {
+ const [isSpacePressed, setIsSpacePressed] = useState(false);
+
+ const handleWheel = useCallback(
+ (event: ReactWheelEvent) => {
+ if (event.ctrlKey || event.metaKey) {
+ event.preventDefault();
+ setViewport({
+ ...viewportRef.current,
+ zoom:
+ viewportRef.current.zoom +
+ (event.deltaY > 0 ? -zoomStep : zoomStep),
+ });
+ return;
+ }
+
+ if (!shouldHandleCanvasWheelEvent(event)) {
+ return;
+ }
+
+ event.preventDefault();
+ nudgeViewport(-event.deltaX, -event.deltaY);
+ },
+ [nudgeViewport, setViewport, viewportRef, zoomStep],
+ );
+
+ const handleKeyDown = useCallback(
+ (event: ReactKeyboardEvent) => {
+ if (!shouldHandleCanvasKeyboardEvent(event)) {
+ return;
+ }
+
+ if (event.key === " ") {
+ event.preventDefault();
+ setIsSpacePressed(true);
+ return;
+ }
+
+ createViewportKeyHandler({
+ nudgeViewport,
+ resetViewport,
+ setViewport,
+ viewportRef,
+ zoomStep,
+ })(event);
+ },
+ [nudgeViewport, resetViewport, setViewport, viewportRef, zoomStep],
+ );
+
+ const handleKeyUp = useCallback(
+ (event: ReactKeyboardEvent) => {
+ if (!shouldHandleCanvasKeyboardEvent(event)) {
+ return;
+ }
+
+ if (event.key === " ") {
+ setIsSpacePressed(false);
+ }
+ },
+ [],
+ );
+
+ return { handleKeyDown, handleKeyUp, handleWheel, isSpacePressed };
+}
+
+function endCanvasDrag(
+ event: ReactPointerEvent,
+ dragOriginRef: React.RefObject,
+ setIsDragging: React.Dispatch>,
+) {
+ dragOriginRef.current = null;
+ setIsDragging(false);
+ if (event.currentTarget.hasPointerCapture(event.pointerId)) {
+ event.currentTarget.releasePointerCapture(event.pointerId);
+ }
+}
+
+function useCanvasPointerInteractions({
+ isSpacePressed,
+ setViewport,
+ viewportRef,
+}: {
+ isSpacePressed: boolean;
+ setViewport: (viewport: CanvasViewport) => void;
+ viewportRef: ViewportReference;
+}) {
+ const dragOriginRef = useRef(null);
+ const [isDragging, setIsDragging] = useState(false);
+
+ const handlePointerDown = useCallback(
+ (event: ReactPointerEvent) => {
+ if (!isPanGesture(event, isSpacePressed)) {
+ return;
+ }
+
+ if (event.button === 1) {
+ event.preventDefault();
+ }
+
+ dragOriginRef.current = {
+ pointerX: event.clientX,
+ pointerY: event.clientY,
+ viewport: viewportRef.current,
+ };
+ event.currentTarget.setPointerCapture(event.pointerId);
+ setIsDragging(true);
+ },
+ [isSpacePressed, viewportRef],
+ );
+
+ const handlePointerMove = useCallback(
+ (event: ReactPointerEvent) => {
+ const dragOrigin = dragOriginRef.current;
+ if (!dragOrigin) {
+ return;
+ }
+
+ setViewport({
+ x: dragOrigin.viewport.x + (event.clientX - dragOrigin.pointerX),
+ y: dragOrigin.viewport.y + (event.clientY - dragOrigin.pointerY),
+ zoom: dragOrigin.viewport.zoom,
+ });
+ },
+ [setViewport],
+ );
+
+ const handlePointerCancel = useCallback(
+ (event: ReactPointerEvent) => {
+ endCanvasDrag(event, dragOriginRef, setIsDragging);
+ },
+ [],
+ );
+
+ const handlePointerUp = useCallback(
+ (event: ReactPointerEvent) => {
+ endCanvasDrag(event, dragOriginRef, setIsDragging);
+ },
+ [],
+ );
+
+ return {
+ handlePointerCancel,
+ handlePointerDown,
+ handlePointerMove,
+ handlePointerUp,
+ isDragging,
+ };
+}
+
+function usePreventBodySelection(disabled: boolean) {
+ useEffect(() => {
+ if (typeof document === "undefined") {
+ return;
+ }
+
+ const { body } = document;
+ const previousUserSelect = body.style.userSelect;
+
+ if (disabled) {
+ body.style.userSelect = "none";
+ }
+
+ return () => {
+ body.style.userSelect = previousUserSelect;
+ };
+ }, [disabled]);
+}
+
+function useCanvasViewHandle(
+ ref: React.ForwardedRef,
+ viewportState: {
+ resetViewport: () => void;
+ setViewport: (viewport: CanvasViewport) => void;
+ },
+) {
+ useImperativeHandle(
+ ref,
+ () => ({
+ resetViewport: viewportState.resetViewport,
+ setViewport: viewportState.setViewport,
+ }),
+ [viewportState.resetViewport, viewportState.setViewport],
+ );
+}
+
+type CanvasInteractionLayerProps = {
+ children: React.ReactNode;
+ instructionsId: string;
+ isDragging: boolean;
+ isSpacePressed: boolean;
+ onKeyDown: (event: ReactKeyboardEvent) => void;
+ onKeyUp: (event: ReactKeyboardEvent) => void;
+ onPointerCancel: (event: ReactPointerEvent) => void;
+ onPointerDown: (event: ReactPointerEvent) => void;
+ onPointerMove: (event: ReactPointerEvent) => void;
+ onPointerUp: (event: ReactPointerEvent) => void;
+ onWheel: (event: ReactWheelEvent) => void;
+ viewport: CanvasViewport;
+};
+
+function CanvasInteractionLayer({
+ children,
+ instructionsId,
+ isDragging,
+ isSpacePressed,
+ onKeyDown,
+ onKeyUp,
+ onPointerCancel,
+ onPointerDown,
+ onPointerMove,
+ onPointerUp,
+ onWheel,
+ viewport,
+}: CanvasInteractionLayerProps) {
+ return (
+
+
+ Hold space and drag or use the middle mouse button to pan. Use plus,
+ minus, or control wheel to zoom. Press zero to reset the viewport.
+
+ {children}
+
+ );
+}
+
+function CanvasContentLayer({
+ children,
+ overlay,
+ viewport,
+}: {
+ children: React.ReactNode;
+ overlay?: React.ReactNode;
+ viewport: CanvasViewport;
+}) {
+ return (
+ <>
+
+ {children}
+
+ {overlay ? (
+
+ {overlay}
+
+ ) : null}
+ >
+ );
+}
+
+const CanvasView = forwardRef(
+ (
+ {
+ children,
+ className,
+ defaultViewport = DEFAULT_VIEWPORT,
+ maxZoom = 2,
+ minZoom = 0.5,
+ onViewportChange,
+ overlay,
+ zoomStep = 0.1,
+ ...props
+ },
+ ref,
+ ) => {
+ const instructionsId = useId();
+ const viewportState = useViewportState({
+ defaultViewport,
+ maxZoom,
+ minZoom,
+ onViewportChange,
+ });
+ const keyboard = useCanvasKeyboardInteractions({
+ nudgeViewport: viewportState.nudgeViewport,
+ resetViewport: viewportState.resetViewport,
+ setViewport: viewportState.setViewport,
+ viewportRef: viewportState.viewportRef,
+ zoomStep,
+ });
+ const pointer = useCanvasPointerInteractions({
+ isSpacePressed: keyboard.isSpacePressed,
+ setViewport: viewportState.setViewport,
+ viewportRef: viewportState.viewportRef,
+ });
+ usePreventBodySelection(pointer.isDragging);
+ useCanvasViewHandle(ref, viewportState);
+
+ return (
+
+
+
+ {children}
+
+
+
+ );
+ },
+);
+
+CanvasView.displayName = "CanvasView";
+
+export { CanvasView };
diff --git a/packages/ui/src/components/canvas-view/index.ts b/packages/ui/src/components/canvas-view/index.ts
new file mode 100644
index 0000000..c14e4f4
--- /dev/null
+++ b/packages/ui/src/components/canvas-view/index.ts
@@ -0,0 +1,6 @@
+export {
+ CanvasView,
+ type CanvasViewHandle,
+ type CanvasViewport,
+ type CanvasViewProps,
+} from "./canvas-view";
diff --git a/packages/ui/src/components/chart/area-chart.tsx b/packages/ui/src/components/chart/area-chart.tsx
index 77ee914..be6cea6 100644
--- a/packages/ui/src/components/chart/area-chart.tsx
+++ b/packages/ui/src/components/chart/area-chart.tsx
@@ -1,3 +1,5 @@
+"use client";
+
import * as React from "react";
import { cn } from "../../lib/utils";
diff --git a/packages/ui/src/components/chart/line-chart.tsx b/packages/ui/src/components/chart/line-chart.tsx
index 3f6a245..357a818 100644
--- a/packages/ui/src/components/chart/line-chart.tsx
+++ b/packages/ui/src/components/chart/line-chart.tsx
@@ -1,3 +1,5 @@
+"use client";
+
import * as React from "react";
import { cn } from "../../lib/utils";
diff --git a/packages/ui/src/components/chat-dock-section/chat-dock-section.stories.tsx b/packages/ui/src/components/chat-dock-section/chat-dock-section.stories.tsx
new file mode 100644
index 0000000..7a2db78
--- /dev/null
+++ b/packages/ui/src/components/chat-dock-section/chat-dock-section.stories.tsx
@@ -0,0 +1,33 @@
+import type { Meta, StoryObj } from "@storybook/react-vite";
+
+import { ChatDockSection } from "./chat-dock-section";
+
+const meta = {
+ component: ChatDockSection,
+ args: {
+ contextLabel: "Today · overview",
+ messages: [
+ {
+ body: "Three failed runs came in overnight. Start with the invoice retry and the security digest.",
+ id: "assistant",
+ speaker: "Assistant",
+ },
+ {
+ body: "Queue the approvals first, then review the stale automations after lunch.",
+ id: "operator",
+ speaker: "Operator",
+ },
+ ],
+ },
+ render: (args) => (
+
+
+
+ ),
+ title: "Layout/ChatDockSection",
+} satisfies Meta;
+
+export default meta;
+type Story = StoryObj;
+
+export const Default: Story = {};
diff --git a/packages/ui/src/components/chat-dock-section/chat-dock-section.tsx b/packages/ui/src/components/chat-dock-section/chat-dock-section.tsx
new file mode 100644
index 0000000..1878683
--- /dev/null
+++ b/packages/ui/src/components/chat-dock-section/chat-dock-section.tsx
@@ -0,0 +1,83 @@
+import { forwardRef } from "react";
+
+import { ArrowUpRight, MessageSquareText } from "lucide-react";
+import type { ReactNode } from "react";
+
+import { cn } from "../../lib/utils";
+import { Button } from "../button";
+
+export type ChatDockMessage = {
+ body: ReactNode;
+ id: string;
+ speaker: ReactNode;
+};
+
+export type ChatDockSectionProps = React.ComponentPropsWithoutRef<"section"> & {
+ composerPlaceholder?: string;
+ contextLabel?: ReactNode;
+ messages: ChatDockMessage[];
+ title?: ReactNode;
+};
+
+const ChatDockSection = forwardRef(
+ (
+ {
+ className,
+ composerPlaceholder = "Ask about runs, errors, or pending work…",
+ contextLabel,
+ messages,
+ title = "Assistant",
+ ...props
+ },
+ ref,
+ ) => (
+
+
+
+
+
+ {title}
+
+ {contextLabel ? (
+
+ {contextLabel}
+
+ ) : null}
+
+
+ Open thread
+
+
+
+
+ {messages.map((message) => (
+
+
+ {message.speaker}
+
+
+ {message.body}
+
+
+ ))}
+
+
+ {composerPlaceholder}
+
+
+ ),
+);
+
+ChatDockSection.displayName = "ChatDockSection";
+
+export { ChatDockSection };
diff --git a/packages/ui/src/components/chat-dock-section/index.ts b/packages/ui/src/components/chat-dock-section/index.ts
new file mode 100644
index 0000000..19960e5
--- /dev/null
+++ b/packages/ui/src/components/chat-dock-section/index.ts
@@ -0,0 +1,5 @@
+export {
+ type ChatDockMessage,
+ ChatDockSection,
+ type ChatDockSectionProps,
+} from "./chat-dock-section";
diff --git a/packages/ui/src/components/client-directives.test.ts b/packages/ui/src/components/client-directives.test.ts
new file mode 100644
index 0000000..04e7b93
--- /dev/null
+++ b/packages/ui/src/components/client-directives.test.ts
@@ -0,0 +1,74 @@
+import { readdirSync, readFileSync, statSync } from "node:fs";
+import { join } from "node:path";
+
+import { describe, expect, it } from "vitest";
+
+const COMPONENTS_ROOT = join(__dirname);
+const CLIENT_HOOKS = [
+ "useActionState",
+ "useCallback",
+ "useContext",
+ "useDeferredValue",
+ "useEffect",
+ "useId",
+ "useImperativeHandle",
+ "useInsertionEffect",
+ "useLayoutEffect",
+ "useMemo",
+ "useOptimistic",
+ "useReducer",
+ "useRef",
+ "useState",
+ "useSyncExternalStore",
+ "useTransition",
+] as const;
+const SKIPPED_SUFFIXES = [".stories.tsx", ".test.tsx", ".visual.tsx"] as const;
+
+function listTypeScriptFiles(directory: string): string[] {
+ return readdirSync(directory).flatMap((entry) => {
+ const path = join(directory, entry);
+ const stats = statSync(path);
+
+ if (stats.isDirectory()) {
+ return listTypeScriptFiles(path);
+ }
+
+ return path.endsWith(".tsx") ? [path] : [];
+ });
+}
+
+function usesClientHooks(source: string): boolean {
+ return CLIENT_HOOKS.some((hook) =>
+ new RegExp(`\\b${hook}\\b`, "u").test(source),
+ );
+}
+
+function hasUseClientDirective(source: string): boolean {
+ const firstNonEmptyLine = source
+ .split(/\r?\n/u)
+ .find((line) => line.trim().length > 0)
+ ?.trim();
+
+ return (
+ firstNonEmptyLine === '"use client";' ||
+ firstNonEmptyLine === "'use client';"
+ );
+}
+
+describe("client directives", () => {
+ it("marks hook-based shipped components as client components", () => {
+ const missingDirectiveFiles = listTypeScriptFiles(COMPONENTS_ROOT)
+ .filter((filePath) =>
+ SKIPPED_SUFFIXES.every((suffix) => !filePath.endsWith(suffix)),
+ )
+ .filter((filePath) => {
+ const source = readFileSync(filePath, "utf8");
+ return usesClientHooks(source) && !hasUseClientDirective(source);
+ })
+ .map((filePath) =>
+ filePath.replace(`${COMPONENTS_ROOT}/`, "components/"),
+ );
+
+ expect(missingDirectiveFiles).toEqual([]);
+ });
+});
diff --git a/packages/ui/src/components/connector-edge/connector-edge.stories.tsx b/packages/ui/src/components/connector-edge/connector-edge.stories.tsx
new file mode 100644
index 0000000..f4eda7d
--- /dev/null
+++ b/packages/ui/src/components/connector-edge/connector-edge.stories.tsx
@@ -0,0 +1,23 @@
+import type { Meta, StoryObj } from "@storybook/react-vite";
+
+import { ConnectorEdge } from "./connector-edge";
+
+const meta = {
+ args: {
+ end: { x: 320, y: 120 },
+ label: "artifact stream",
+ start: { x: 0, y: 0 },
+ state: "active",
+ },
+ component: ConnectorEdge,
+ render: (args) => (
+
+
+
+ ),
+ title: "Canvas/ConnectorEdge",
+} satisfies Meta;
+
+export default meta;
+type Story = StoryObj;
+export const Default: Story = {};
diff --git a/packages/ui/src/components/connector-edge/connector-edge.test.tsx b/packages/ui/src/components/connector-edge/connector-edge.test.tsx
new file mode 100644
index 0000000..87d8df7
--- /dev/null
+++ b/packages/ui/src/components/connector-edge/connector-edge.test.tsx
@@ -0,0 +1,19 @@
+import { render, screen } from "@testing-library/react";
+import { describe, expect, it } from "vitest";
+
+import { ConnectorEdge } from "./connector-edge";
+
+describe("ConnectorEdge", () => {
+ it("renders an inline edge label", () => {
+ render(
+ ,
+ );
+
+ expect(screen.getByText("artifact stream")).toBeInTheDocument();
+ });
+});
diff --git a/packages/ui/src/components/connector-edge/connector-edge.tsx b/packages/ui/src/components/connector-edge/connector-edge.tsx
new file mode 100644
index 0000000..6802f6c
--- /dev/null
+++ b/packages/ui/src/components/connector-edge/connector-edge.tsx
@@ -0,0 +1,80 @@
+import { forwardRef } from "react";
+
+import type { CSSProperties } from "react";
+
+import { cn } from "../../lib/utils";
+import { EdgeLabel } from "../edge-label";
+
+export type ConnectorEdgePoint = {
+ x: number;
+ y: number;
+};
+
+export type ConnectorEdgeProps = React.ComponentPropsWithoutRef<"div"> & {
+ end: ConnectorEdgePoint;
+ label?: string;
+ start: ConnectorEdgePoint;
+ state?: "active" | "blocked" | "idle";
+};
+
+const strokeClasses: Record<
+ NonNullable,
+ string
+> = {
+ active: "stroke-sky-500",
+ blocked: "stroke-amber-500",
+ idle: "stroke-muted-foreground/60",
+};
+
+const ConnectorEdge = forwardRef(
+ ({ className, end, label, start, state = "idle", ...props }, ref) => {
+ const width = Math.max(Math.abs(end.x - start.x), 32);
+ const height = Math.max(Math.abs(end.y - start.y), 32);
+ const midX = width / 2;
+ const startX = start.x <= end.x ? 4 : width - 4;
+ const endX = start.x <= end.x ? width - 4 : 4;
+ const startY = start.y <= end.y ? 4 : height - 4;
+ const endY = start.y <= end.y ? height - 4 : 4;
+ const path = `M ${startX} ${startY} C ${midX} ${startY}, ${midX} ${endY}, ${endX} ${endY}`;
+
+ const style = {
+ height,
+ width,
+ } satisfies CSSProperties;
+
+ return (
+
+
+
+
+ {label ? (
+
+ {label}
+
+ ) : null}
+
+ );
+ },
+);
+
+ConnectorEdge.displayName = "ConnectorEdge";
+
+export { ConnectorEdge };
diff --git a/packages/ui/src/components/connector-edge/connector-edge.visual.tsx b/packages/ui/src/components/connector-edge/connector-edge.visual.tsx
new file mode 100644
index 0000000..ff25c9c
--- /dev/null
+++ b/packages/ui/src/components/connector-edge/connector-edge.visual.tsx
@@ -0,0 +1,15 @@
+import { expect, test } from "@playwright/experimental-ct-react";
+
+import { ConnectorEdge } from "./connector-edge";
+
+test.describe("ConnectorEdge Visual", () => {
+ test("default", async ({ mount, page }) => {
+ await mount(
+
+
+
,
+ );
+
+ await expect(page).toHaveScreenshot("connector-edge-default.png");
+ });
+});
\ No newline at end of file
diff --git a/packages/ui/src/components/connector-edge/index.ts b/packages/ui/src/components/connector-edge/index.ts
new file mode 100644
index 0000000..68f4b3b
--- /dev/null
+++ b/packages/ui/src/components/connector-edge/index.ts
@@ -0,0 +1,5 @@
+export {
+ ConnectorEdge,
+ type ConnectorEdgePoint,
+ type ConnectorEdgeProps,
+} from "./connector-edge";
diff --git a/packages/ui/src/components/data-list/data-list.tsx b/packages/ui/src/components/data-list/data-list.tsx
index 05fb17f..b53cc19 100644
--- a/packages/ui/src/components/data-list/data-list.tsx
+++ b/packages/ui/src/components/data-list/data-list.tsx
@@ -1,3 +1,5 @@
+"use client";
+
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
diff --git a/packages/ui/src/components/edge-label/edge-label.stories.tsx b/packages/ui/src/components/edge-label/edge-label.stories.tsx
new file mode 100644
index 0000000..c66d7ac
--- /dev/null
+++ b/packages/ui/src/components/edge-label/edge-label.stories.tsx
@@ -0,0 +1,16 @@
+import type { Meta, StoryObj } from "@storybook/react-vite";
+
+import { EdgeLabel } from "./edge-label";
+
+const meta = {
+ component: EdgeLabel,
+ args: {
+ children: "artifact stream",
+ emphasis: "active",
+ },
+ title: "Canvas/EdgeLabel",
+} satisfies Meta;
+
+export default meta;
+type Story = StoryObj;
+export const Default: Story = {};
diff --git a/packages/ui/src/components/edge-label/edge-label.test.tsx b/packages/ui/src/components/edge-label/edge-label.test.tsx
new file mode 100644
index 0000000..aee8bca
--- /dev/null
+++ b/packages/ui/src/components/edge-label/edge-label.test.tsx
@@ -0,0 +1,13 @@
+import { render, screen } from "@testing-library/react";
+import { describe, expect, it } from "vitest";
+
+import { EdgeLabel } from "./edge-label";
+
+describe("EdgeLabel", () => {
+ it("renders children and emphasis data attribute", () => {
+ render(artifact stream );
+
+ const label = screen.getByText("artifact stream");
+ expect(label).toHaveAttribute("data-emphasis", "active");
+ });
+});
diff --git a/packages/ui/src/components/edge-label/edge-label.tsx b/packages/ui/src/components/edge-label/edge-label.tsx
new file mode 100644
index 0000000..ebc180f
--- /dev/null
+++ b/packages/ui/src/components/edge-label/edge-label.tsx
@@ -0,0 +1,34 @@
+import { forwardRef } from "react";
+
+import { cn } from "../../lib/utils";
+
+export type EdgeLabelProps = React.ComponentPropsWithoutRef<"span"> & {
+ emphasis?: "active" | "subtle";
+};
+
+const emphasisClasses: Record<
+ NonNullable,
+ string
+> = {
+ active: "border-sky-500/30 bg-sky-500/10 text-sky-700 dark:text-sky-300",
+ subtle: "border-border/60 bg-background/90 text-muted-foreground",
+};
+
+const EdgeLabel = forwardRef(
+ ({ className, emphasis = "subtle", ...props }, ref) => (
+
+ ),
+);
+
+EdgeLabel.displayName = "EdgeLabel";
+
+export { EdgeLabel };
diff --git a/packages/ui/src/components/edge-label/edge-label.visual.tsx b/packages/ui/src/components/edge-label/edge-label.visual.tsx
new file mode 100644
index 0000000..0374572
--- /dev/null
+++ b/packages/ui/src/components/edge-label/edge-label.visual.tsx
@@ -0,0 +1,11 @@
+import { expect, test } from "@playwright/experimental-ct-react";
+
+import { EdgeLabel } from "./edge-label";
+
+test.describe("EdgeLabel Visual", () => {
+ test("default", async ({ mount, page }) => {
+ await mount(artifact stream );
+
+ await expect(page).toHaveScreenshot("edge-label-default.png");
+ });
+});
\ No newline at end of file
diff --git a/packages/ui/src/components/edge-label/index.ts b/packages/ui/src/components/edge-label/index.ts
new file mode 100644
index 0000000..26d3e1d
--- /dev/null
+++ b/packages/ui/src/components/edge-label/index.ts
@@ -0,0 +1 @@
+export { EdgeLabel, type EdgeLabelProps } from "./edge-label";
diff --git a/packages/ui/src/components/form/form.mdx b/packages/ui/src/components/form/form.mdx
new file mode 100644
index 0000000..d4203e1
--- /dev/null
+++ b/packages/ui/src/components/form/form.mdx
@@ -0,0 +1,56 @@
+import { Canvas, Controls, Meta, Primary } from '@storybook/addon-docs/blocks'
+import * as Stories from './form.stories'
+
+
+
+# Form
+
+A lightweight validation wrapper for composing labels, descriptions, controls, and messages with consistent ARIA wiring.
+
+
+
+## Installation
+
+```bash
+pnpm dlx shadcn@latest add https://ui.vllnt.com/r/form.json
+```
+
+## Import
+
+```tsx
+import {
+ Form,
+ FormControl,
+ FormDescription,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from '@vllnt/ui'
+```
+
+## Usage
+
+```tsx
+
+```
+
+
+
+## Validation state
+
+Set `invalid` on `Form` to add `aria-invalid`, append the message id to `aria-describedby`, and expose the message as an alert.
+
+
+
+## API Reference
+
+
diff --git a/packages/ui/src/components/form/form.stories.tsx b/packages/ui/src/components/form/form.stories.tsx
new file mode 100644
index 0000000..ee6e306
--- /dev/null
+++ b/packages/ui/src/components/form/form.stories.tsx
@@ -0,0 +1,53 @@
+import type { Meta, StoryObj } from "@storybook/react-vite";
+
+import { Input } from "../input";
+import {
+ Form,
+ FormControl,
+ FormDescription,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from "./form";
+
+const meta = {
+ component: Form,
+ title: "Core/Form",
+} satisfies Meta;
+
+export default meta;
+type Story = StoryObj;
+
+export const Default: Story = {
+ render: () => (
+
+
+
+ ),
+};
+
+export const Invalid: Story = {
+ render: () => (
+
+
+
+ ),
+};
diff --git a/packages/ui/src/components/form/form.test.tsx b/packages/ui/src/components/form/form.test.tsx
new file mode 100644
index 0000000..210fa8c
--- /dev/null
+++ b/packages/ui/src/components/form/form.test.tsx
@@ -0,0 +1,516 @@
+import * as React from "react";
+
+import { fireEvent, render, screen } from "@testing-library/react";
+import { renderToStaticMarkup } from "react-dom/server";
+import { describe, expect, it, vi } from "vitest";
+
+import { Input } from "../input";
+
+import {
+ Form,
+ FormControl,
+ FormDescription,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from "./form";
+
+describe("Form", () => {
+ it("renders a native form element and forwards props, ref, and submit", () => {
+ const handleSubmit = vi.fn();
+ const ref = React.createRef();
+ const { container } = render(
+ ,
+ );
+
+ const form = container.querySelector("form");
+ expect(form).not.toBeNull();
+ expect(form).toBe(ref.current);
+ expect(form).toHaveClass("custom-form");
+ expect(form).toHaveClass("space-y-2");
+ expect(form).toHaveAttribute("data-testid", "login-form");
+ expect(form).toHaveAttribute("name", "login");
+
+ if (!form) {
+ throw new Error("Expected form element to be rendered.");
+ }
+
+ fireEvent.submit(form);
+ expect(handleSubmit).toHaveBeenCalledTimes(1);
+ });
+
+ it("omits aria-describedby when no description or message is rendered", () => {
+ render(
+ ,
+ );
+
+ const input = screen.getByRole("textbox");
+ expect(input).not.toHaveAttribute("aria-describedby");
+ });
+
+ it("preserves caller aria-describedby when description and message are absent", () => {
+ render(
+ ,
+ );
+
+ const input = screen.getByRole("textbox");
+ expect(input).toHaveAttribute("aria-describedby", "external-help");
+ });
+
+ it("does not link the message id when invalid but no FormMessage is rendered", () => {
+ render(
+ ,
+ );
+
+ const input = screen.getByRole("textbox");
+ const description = screen.getByText("Help text");
+ expect(input).toHaveAttribute("aria-describedby", description.id);
+ });
+
+ it("does not render or link empty message content", () => {
+ render(
+ ,
+ );
+
+ const input = screen.getByRole("textbox");
+ expect(input).not.toHaveAttribute("aria-describedby");
+ expect(screen.queryByRole("alert")).toBeNull();
+ });
+
+ it("does not link the description id when no FormDescription is rendered", () => {
+ render(
+ ,
+ );
+
+ const input = screen.getByRole("textbox");
+ const message = screen.getByRole("alert");
+ expect(input).toHaveAttribute("aria-describedby", message.id);
+ });
+
+ it("renders description and message ids in server markup on the first pass", () => {
+ const markup = renderToStaticMarkup(
+ ,
+ );
+
+ expect(markup).toContain("aria-describedby=");
+ expect(markup).toContain("-description");
+ expect(markup).toContain("-message");
+ });
+
+ it("ignores native id overrides that would break form associations", () => {
+ render(
+ ,
+ );
+
+ const input = screen.getByRole("textbox");
+ const label = screen.getByText("Email");
+ const description = screen.getByText("Help text");
+ const message = screen.getByRole("alert");
+
+ expect(input.id).not.toBe("custom-control-id");
+ expect(description.id).not.toBe("custom-description-id");
+ expect(message.id).not.toBe("custom-message-id");
+ expect(label).toHaveAttribute("for", input.id);
+ expect(input).toHaveAttribute(
+ "aria-describedby",
+ `${description.id} ${message.id}`,
+ );
+ });
+
+ it("wires the label to the generated control id", () => {
+ render(
+ ,
+ );
+
+ const input = screen.getByRole("textbox");
+ const label = screen.getByText("Email");
+
+ expect(label).toHaveAttribute("for", input.id);
+ });
+
+ it("applies invalid aria wiring to the control and message", () => {
+ render(
+ ,
+ );
+
+ const input = screen.getByRole("textbox");
+ const description = screen.getByText("Use your work email address.");
+ const message = screen.getByRole("alert");
+
+ expect(input).toHaveAttribute("aria-invalid", "true");
+ expect(input).toHaveAttribute(
+ "aria-describedby",
+ `${description.id} ${message.id}`,
+ );
+ expect(message).toHaveTextContent("Please enter a valid email.");
+ });
+
+ it("propagates disabled and required state to native controls", () => {
+ render(
+ ,
+ );
+
+ const input = screen.getByRole("textbox");
+
+ expect(input).toBeDisabled();
+ expect(input).toBeRequired();
+ expect(input).toHaveAttribute("aria-disabled", "true");
+ expect(input).toHaveAttribute("aria-required", "true");
+ });
+
+ it("preserves control-level disabled and required props when the form is not flagged", () => {
+ render(
+ ,
+ );
+
+ const input = screen.getByRole("textbox");
+
+ expect(input).toBeDisabled();
+ expect(input).toBeRequired();
+ expect(input).toHaveAttribute("aria-disabled", "true");
+ expect(input).toHaveAttribute("aria-required", "true");
+ });
+
+ it("allows a form item to override invalid state independently", () => {
+ render(
+ ,
+ );
+
+ const primaryInput = screen.getByRole("textbox", { name: "Primary email" });
+ const backupInput = screen.getByRole("textbox", { name: "Backup email" });
+ const primaryMessage = screen.getByText("All set");
+ const backupMessage = screen.getByRole("alert");
+
+ expect(primaryInput).not.toHaveAttribute("aria-invalid", "true");
+ expect(primaryInput).toHaveAttribute(
+ "aria-describedby",
+ screen.getByText("Looks good").id,
+ );
+ expect(primaryMessage).not.toHaveAttribute("role", "alert");
+ expect(backupInput).toHaveAttribute("aria-invalid", "true");
+ expect(backupMessage).toHaveTextContent("Required");
+ });
+
+ it("links wrapped description and message content", () => {
+ render(
+ ,
+ );
+
+ const input = screen.getByRole("textbox");
+ const description = screen.getByText("Wrapped help");
+ const message = screen.getByRole("alert");
+
+ expect(input).toHaveAttribute(
+ "aria-describedby",
+ `${description.id} ${message.id}`,
+ );
+ expect(message).toHaveTextContent("Wrapped error");
+ });
+
+ it("supports fragment-wrapped helper content", () => {
+ render(
+ ,
+ );
+
+ const input = screen.getByRole("textbox");
+ const description = screen.getByText("Fragment help");
+ const message = screen.getByRole("alert");
+
+ expect(input).toHaveAttribute(
+ "aria-describedby",
+ `${description.id} ${message.id}`,
+ );
+ expect(message).toHaveTextContent("Fragment error");
+ });
+
+ it("keeps helper text in aria-describedby without linking a valid message", () => {
+ render(
+ ,
+ );
+
+ const input = screen.getByRole("textbox");
+ const description = screen.getByText(
+ "We only use this for account updates.",
+ );
+ const message = screen.getByText("Looks good.");
+
+ expect(input).toHaveAttribute(
+ "aria-describedby",
+ `external-help ${description.id}`,
+ );
+ expect(input).not.toHaveAttribute("aria-invalid", "true");
+ expect(message).not.toHaveAttribute("role", "alert");
+ });
+
+ it("creates unique aria wiring for each form item", () => {
+ render(
+ ,
+ );
+
+ const firstInput = screen.getByRole("textbox", { name: "First name" });
+ const lastInput = screen.getByRole("textbox", { name: "Last name" });
+ const firstDescription = screen.getByText("Given name");
+ const lastDescription = screen.getByText("Family name");
+ const [firstMessage, secondMessage] = screen.getAllByText("Required");
+
+ expect(firstMessage).toBeDefined();
+ expect(secondMessage).toBeDefined();
+
+ if (!firstMessage || !secondMessage) {
+ throw new Error("Expected both required messages to be rendered.");
+ }
+
+ expect(firstInput.id).not.toBe(lastInput.id);
+ expect(firstDescription.id).not.toBe(lastDescription.id);
+ expect(firstMessage.id).not.toBe(secondMessage.id);
+ expect(firstInput).toHaveAttribute("aria-describedby", firstDescription.id);
+ expect(lastInput).toHaveAttribute("aria-describedby", lastDescription.id);
+ });
+
+ it("keeps root-level custom ids unique across form items", () => {
+ render(
+ ,
+ );
+
+ const primaryInput = screen.getByRole("textbox", { name: "Primary email" });
+ const backupInput = screen.getByRole("textbox", { name: "Backup email" });
+ const primaryDescription = screen.getByText("Primary contact");
+ const backupDescription = screen.getByText("Secondary contact");
+ const [primaryMessage, backupMessage] = screen.getAllByText("Required");
+
+ expect(primaryMessage).toBeDefined();
+ expect(backupMessage).toBeDefined();
+
+ if (!primaryMessage || !backupMessage) {
+ throw new Error("Expected both required messages to be rendered.");
+ }
+
+ expect(primaryInput.id).toMatch(/^field-control-/);
+ expect(backupInput.id).toMatch(/^field-control-/);
+ expect(primaryInput.id).not.toBe(backupInput.id);
+ expect(primaryDescription.id).toMatch(/^field-description-/);
+ expect(backupDescription.id).toMatch(/^field-description-/);
+ expect(primaryMessage.id).toMatch(/^field-message-/);
+ expect(backupMessage.id).toMatch(/^field-message-/);
+ expect(primaryMessage.id).not.toBe(backupMessage.id);
+ });
+
+ it("keeps partial custom id overrides scoped to their role", () => {
+ render(
+ ,
+ );
+
+ const input = screen.getByRole("textbox");
+ const description = screen.getByText("Scoped description");
+ const message = screen.getByText("Scoped message");
+
+ expect(input.id).toMatch(/^field-control-/);
+ expect(description.id).toMatch(/-description$/);
+ expect(message.id).toMatch(/-message$/);
+ expect(description.id).not.toBe(input.id);
+ expect(message.id).not.toBe(input.id);
+ });
+});
diff --git a/packages/ui/src/components/form/form.tsx b/packages/ui/src/components/form/form.tsx
new file mode 100644
index 0000000..e573939
--- /dev/null
+++ b/packages/ui/src/components/form/form.tsx
@@ -0,0 +1,386 @@
+"use client";
+
+import * as React from "react";
+
+import { Slot } from "@radix-ui/react-slot";
+
+import { cn } from "../../lib/utils";
+import { Label } from "../label";
+
+type FormRootContextValue = {
+ controlId?: string;
+ descriptionId?: string;
+ disabled: boolean;
+ invalid: boolean;
+ messageId?: string;
+ required: boolean;
+};
+
+type FormItemContextValue = {
+ controlId: string;
+ descriptionId: string;
+ disabled: boolean;
+ hasDescription: boolean;
+ hasMessage: boolean;
+ invalid: boolean;
+ messageId: string;
+ required: boolean;
+};
+
+const FormRootContext = React.createContext(
+ undefined,
+);
+
+const FormItemContext = React.createContext(
+ undefined,
+);
+
+function useFormRootContext(componentName: string) {
+ const context = React.useContext(FormRootContext);
+
+ if (context === undefined) {
+ throw new Error(`${componentName} must be used within Form.`);
+ }
+
+ return context;
+}
+
+function useFormItemContext(componentName: string) {
+ const context = React.useContext(FormItemContext);
+
+ if (context === undefined) {
+ throw new Error(`${componentName} must be used within FormItem.`);
+ }
+
+ return context;
+}
+
+function composeIds(...ids: (string | undefined)[]) {
+ const value = ids.filter((id) => id !== undefined && id.length > 0).join(" ");
+
+ return value.length > 0 ? value : undefined;
+}
+
+function resolveItemId(
+ baseId: string | undefined,
+ generatedId: string,
+ suffix: string,
+) {
+ if (baseId === undefined) {
+ return `${generatedId}-${suffix}`;
+ }
+
+ return baseId.endsWith(`-${suffix}`)
+ ? `${baseId}-${generatedId}`
+ : `${baseId}-${suffix}-${generatedId}`;
+}
+
+function isNamedFormChild(
+ child: React.ReactNode,
+ name: "FormDescription" | "FormMessage",
+): child is React.ReactElement<{ children?: React.ReactNode }> {
+ if (!React.isValidElement<{ children?: React.ReactNode }>(child)) {
+ return false;
+ }
+
+ const { type } = child;
+ if (typeof type === "string" || typeof type === "symbol") {
+ return false;
+ }
+
+ return "displayName" in type && type.displayName === name;
+}
+
+function hasVisibleContent(children: React.ReactNode): boolean {
+ return React.Children.toArray(children).some((child) => {
+ if (child === null || child === undefined || typeof child === "boolean") {
+ return false;
+ }
+
+ if (typeof child === "string") {
+ return child.length > 0;
+ }
+
+ if (typeof child === "number") {
+ return true;
+ }
+
+ if (React.isValidElement<{ children?: React.ReactNode }>(child)) {
+ const nestedChildren = child.props.children;
+
+ return nestedChildren === undefined
+ ? true
+ : hasVisibleContent(nestedChildren);
+ }
+
+ return true;
+ });
+}
+
+function hasRenderedFormChild(
+ children: React.ReactNode,
+ name: "FormDescription" | "FormMessage",
+): boolean {
+ return React.Children.toArray(children).some((child) => {
+ if (isNamedFormChild(child, name)) {
+ return name === "FormMessage"
+ ? hasVisibleContent(child.props.children)
+ : true;
+ }
+
+ if (React.isValidElement<{ children?: React.ReactNode }>(child)) {
+ return hasRenderedFormChild(child.props.children, name);
+ }
+
+ return false;
+ });
+}
+
+export type FormProps = React.ComponentPropsWithoutRef<"form"> & {
+ controlId?: string;
+ descriptionId?: string;
+ disabled?: boolean;
+ invalid?: boolean;
+ messageId?: string;
+ required?: boolean;
+};
+
+const Form = React.forwardRef(
+ (
+ {
+ className,
+ controlId,
+ descriptionId,
+ disabled = false,
+ invalid = false,
+ messageId,
+ required = false,
+ ...props
+ },
+ ref,
+ ) => {
+ const value = React.useMemo(
+ () => ({
+ controlId,
+ descriptionId,
+ disabled,
+ invalid,
+ messageId,
+ required,
+ }),
+ [controlId, descriptionId, disabled, invalid, messageId, required],
+ );
+
+ return (
+
+
+
+ );
+ },
+);
+Form.displayName = "Form";
+
+const FormItem = React.forwardRef<
+ HTMLDivElement,
+ React.ComponentPropsWithoutRef<"div"> & {
+ disabled?: boolean;
+ invalid?: boolean;
+ required?: boolean;
+ }
+>(
+ (
+ {
+ children,
+ className,
+ disabled: itemDisabled,
+ invalid: itemInvalid,
+ required: itemRequired,
+ ...props
+ },
+ ref,
+ ) => {
+ const {
+ controlId: controlIdBase,
+ descriptionId: descriptionIdBase,
+ disabled,
+ invalid,
+ messageId: messageIdBase,
+ required,
+ } = useFormRootContext("FormItem");
+ const generatedId = React.useId();
+ const hasDescription = hasRenderedFormChild(children, "FormDescription");
+ const hasMessage = hasRenderedFormChild(children, "FormMessage");
+
+ const effectiveDisabled = itemDisabled ?? disabled;
+ const effectiveInvalid = itemInvalid ?? invalid;
+ const effectiveRequired = itemRequired ?? required;
+
+ const value = React.useMemo(
+ () => ({
+ controlId: resolveItemId(controlIdBase, generatedId, "control"),
+ descriptionId: resolveItemId(
+ descriptionIdBase,
+ generatedId,
+ "description",
+ ),
+ disabled: effectiveDisabled,
+ hasDescription,
+ hasMessage,
+ invalid: effectiveInvalid,
+ messageId: resolveItemId(messageIdBase, generatedId, "message"),
+ required: effectiveRequired,
+ }),
+ [
+ controlIdBase,
+ descriptionIdBase,
+ effectiveDisabled,
+ effectiveInvalid,
+ effectiveRequired,
+ generatedId,
+ hasDescription,
+ hasMessage,
+ messageIdBase,
+ ],
+ );
+
+ return (
+
+
+ {children}
+
+
+ );
+ },
+);
+FormItem.displayName = "FormItem";
+
+const FormLabel = React.forwardRef<
+ React.ComponentRef,
+ React.ComponentPropsWithoutRef
+>(({ className, htmlFor, ...props }, ref) => {
+ const { controlId, invalid } = useFormItemContext("FormLabel");
+
+ return (
+
+ );
+});
+FormLabel.displayName = "FormLabel";
+
+const FormControl = React.forwardRef<
+ HTMLElement,
+ React.ComponentPropsWithoutRef & {
+ disabled?: boolean;
+ required?: boolean;
+ }
+>(
+ (
+ { disabled: controlDisabled, id: _id, required: controlRequired, ...props },
+ ref,
+ ) => {
+ const {
+ controlId,
+ descriptionId,
+ disabled,
+ hasDescription,
+ hasMessage,
+ invalid,
+ messageId,
+ required,
+ } = useFormItemContext("FormControl");
+
+ const describedBy = composeIds(
+ props["aria-describedby"],
+ hasDescription ? descriptionId : undefined,
+ invalid && hasMessage ? messageId : undefined,
+ );
+ const effectiveDisabled = controlDisabled ?? disabled;
+ const effectiveRequired = controlRequired ?? required;
+ const nativeConstraintProps: {
+ disabled?: boolean;
+ required?: boolean;
+ } = {
+ disabled: effectiveDisabled || undefined,
+ required: effectiveRequired || undefined,
+ };
+
+ return (
+
+ );
+ },
+);
+FormControl.displayName = "FormControl";
+
+const FormDescription = React.forwardRef<
+ HTMLParagraphElement,
+ React.ComponentPropsWithoutRef<"p">
+>(({ className, id: _id, ...props }, ref) => {
+ const { descriptionId } = useFormItemContext("FormDescription");
+
+ return (
+
+ );
+});
+FormDescription.displayName = "FormDescription";
+
+const FormMessage = React.forwardRef<
+ HTMLParagraphElement,
+ React.ComponentPropsWithoutRef<"p">
+>(({ children, className, id: _id, ...props }, ref) => {
+ const { invalid, messageId } = useFormItemContext("FormMessage");
+ const hasChildren = hasVisibleContent(children);
+
+ if (!hasChildren) {
+ return null;
+ }
+
+ return (
+
+ {children}
+
+ );
+});
+FormMessage.displayName = "FormMessage";
+
+export { Form, FormControl, FormDescription, FormItem, FormLabel, FormMessage };
diff --git a/packages/ui/src/components/form/form.visual.tsx b/packages/ui/src/components/form/form.visual.tsx
new file mode 100644
index 0000000..fc41e32
--- /dev/null
+++ b/packages/ui/src/components/form/form.visual.tsx
@@ -0,0 +1,32 @@
+import { expect, test } from "@playwright/experimental-ct-react";
+
+import { Input } from "../input";
+import {
+ Form,
+ FormControl,
+ FormDescription,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from "./form";
+
+test.describe("Form Visual", () => {
+ test("invalid state", async ({ mount, page }) => {
+ await mount(
+
+
+
,
+ );
+
+ await expect(page).toHaveScreenshot("form-invalid.png");
+ });
+});
diff --git a/packages/ui/src/components/form/index.ts b/packages/ui/src/components/form/index.ts
new file mode 100644
index 0000000..8797974
--- /dev/null
+++ b/packages/ui/src/components/form/index.ts
@@ -0,0 +1,9 @@
+export {
+ Form,
+ FormControl,
+ FormDescription,
+ FormItem,
+ FormLabel,
+ FormMessage,
+ type FormProps,
+} from "./form";
diff --git a/packages/ui/src/components/glass-panel/glass-panel.stories.tsx b/packages/ui/src/components/glass-panel/glass-panel.stories.tsx
new file mode 100644
index 0000000..70d80b7
--- /dev/null
+++ b/packages/ui/src/components/glass-panel/glass-panel.stories.tsx
@@ -0,0 +1,23 @@
+import type { Meta, StoryObj } from "@storybook/react-vite";
+
+import { GlassPanel } from "./glass-panel";
+
+const meta = {
+ component: GlassPanel,
+ render: () => (
+
+
+ Floating shell chrome
+
+ Use GlassPanel to wrap secondary chrome without taking over the main canvas surface.
+
+
+
+ ),
+ title: "Layout/GlassPanel",
+} satisfies Meta;
+
+export default meta;
+type Story = StoryObj;
+
+export const Default: Story = {};
diff --git a/packages/ui/src/components/glass-panel/glass-panel.tsx b/packages/ui/src/components/glass-panel/glass-panel.tsx
new file mode 100644
index 0000000..42e26ea
--- /dev/null
+++ b/packages/ui/src/components/glass-panel/glass-panel.tsx
@@ -0,0 +1,24 @@
+import { forwardRef } from "react";
+
+import { cn } from "../../lib/utils";
+
+export type GlassPanelProps = React.ComponentPropsWithoutRef<"div">;
+
+const GlassPanel = forwardRef(
+ ({ children, className, ...props }, ref) => (
+
+ {children}
+
+ ),
+);
+
+GlassPanel.displayName = "GlassPanel";
+
+export { GlassPanel };
diff --git a/packages/ui/src/components/glass-panel/index.ts b/packages/ui/src/components/glass-panel/index.ts
new file mode 100644
index 0000000..3b2fa0a
--- /dev/null
+++ b/packages/ui/src/components/glass-panel/index.ts
@@ -0,0 +1 @@
+export { GlassPanel, type GlassPanelProps } from "./glass-panel";
diff --git a/packages/ui/src/components/group-hull/group-hull.stories.tsx b/packages/ui/src/components/group-hull/group-hull.stories.tsx
new file mode 100644
index 0000000..5016385
--- /dev/null
+++ b/packages/ui/src/components/group-hull/group-hull.stories.tsx
@@ -0,0 +1,38 @@
+import type { Meta, StoryObj } from "@storybook/react-vite";
+
+import { AnchorPort } from "../anchor-port";
+import { ObjectCard } from "../object-card";
+import { GroupHull } from "./group-hull";
+
+const meta = {
+ component: GroupHull,
+ render: () => (
+
+ }
+ state="running"
+ summary="Generates social copy from approved article drafts."
+ title="Writer"
+ />
+ }
+ state="complete"
+ summary="Pinned draft bundle ready for channel formatting."
+ title="Launch thread"
+ />
+
+ ),
+ title: "Canvas/GroupHull",
+} satisfies Meta;
+
+export default meta;
+type Story = StoryObj;
+export const Default: Story = {};
diff --git a/packages/ui/src/components/group-hull/group-hull.test.tsx b/packages/ui/src/components/group-hull/group-hull.test.tsx
new file mode 100644
index 0000000..774fcd8
--- /dev/null
+++ b/packages/ui/src/components/group-hull/group-hull.test.tsx
@@ -0,0 +1,23 @@
+import { render, screen } from "@testing-library/react";
+import { describe, expect, it } from "vitest";
+
+import { GroupHull } from "./group-hull";
+
+describe("GroupHull", () => {
+ it("renders title and description", () => {
+ render(
+
+ Child object
+ ,
+ );
+
+ expect(screen.getByText("Publishing lane")).toBeInTheDocument();
+ expect(
+ screen.getByText("A durable object neighborhood."),
+ ).toBeInTheDocument();
+ expect(screen.getByText("Child object")).toBeInTheDocument();
+ });
+});
diff --git a/packages/ui/src/components/group-hull/group-hull.tsx b/packages/ui/src/components/group-hull/group-hull.tsx
new file mode 100644
index 0000000..9029acb
--- /dev/null
+++ b/packages/ui/src/components/group-hull/group-hull.tsx
@@ -0,0 +1,48 @@
+import { forwardRef } from "react";
+
+import type { ReactNode } from "react";
+
+import { cn } from "../../lib/utils";
+
+export type GroupHullProps = React.ComponentPropsWithoutRef<"section"> & {
+ description?: string;
+ eyebrow?: ReactNode;
+ title: string;
+};
+
+const GroupHull = forwardRef(
+ ({ children, className, description, eyebrow, title, ...props }, ref) => (
+
+
+
+ {eyebrow ? (
+
+ {eyebrow}
+
+ ) : null}
+
+ {title}
+
+ {description ? (
+
+ {description}
+
+ ) : null}
+
+
+ {children}
+
+
+ ),
+);
+
+GroupHull.displayName = "GroupHull";
+
+export { GroupHull };
diff --git a/packages/ui/src/components/group-hull/group-hull.visual.tsx b/packages/ui/src/components/group-hull/group-hull.visual.tsx
new file mode 100644
index 0000000..35d20e9
--- /dev/null
+++ b/packages/ui/src/components/group-hull/group-hull.visual.tsx
@@ -0,0 +1,17 @@
+import { expect, test } from "@playwright/experimental-ct-react";
+
+import { ObjectCard } from "../object-card";
+import { GroupHull } from "./group-hull";
+
+test.describe("GroupHull Visual", () => {
+ test("default", async ({ mount, page }) => {
+ await mount(
+
+
+
+ ,
+ );
+
+ await expect(page).toHaveScreenshot("group-hull-default.png");
+ });
+});
diff --git a/packages/ui/src/components/group-hull/index.ts b/packages/ui/src/components/group-hull/index.ts
new file mode 100644
index 0000000..187bfdc
--- /dev/null
+++ b/packages/ui/src/components/group-hull/index.ts
@@ -0,0 +1 @@
+export { GroupHull, type GroupHullProps } from "./group-hull";
diff --git a/packages/ui/src/components/index.ts b/packages/ui/src/components/index.ts
index de3f98b..8dad56e 100644
--- a/packages/ui/src/components/index.ts
+++ b/packages/ui/src/components/index.ts
@@ -64,6 +64,29 @@ export { Label } from "./label";
export { NumberInput, type NumberInputProps } from "./number-input";
export { PasswordInput, type PasswordInputProps } from "./password-input";
export { Switch } from "./switch";
+export {
+ Form,
+ FormControl,
+ FormDescription,
+ FormItem,
+ FormLabel,
+ FormMessage,
+ type FormProps,
+} from "./form";
+export {
+ MultiSelect,
+ type MultiSelectOption,
+ type MultiSelectProps,
+} from "./multi-select";
+export { TagsInput, type TagsInputProps } from "./tags-input";
+export {
+ SegmentedControl,
+ SegmentedControlItem,
+ type SegmentedControlItemProps,
+ segmentedControlItemVariants,
+ type SegmentedControlProps,
+ segmentedControlVariants,
+} from "./segmented-control";
export {
toast,
Toast,
@@ -315,16 +338,52 @@ export { CodeBlock } from "./code-block";
export { MDXContent } from "./mdx-content";
// Layout components
+export {
+ CanvasShell,
+ type CanvasShellInsets,
+ type CanvasShellProps,
+ type CanvasShellRouteConfig,
+} from "./canvas-shell";
+export {
+ CanvasView,
+ type CanvasViewHandle,
+ type CanvasViewport,
+ type CanvasViewProps,
+} from "./canvas-view";
+export { BottomBar, type BottomBarProps } from "./bottom-bar";
+export {
+ type ChatDockMessage,
+ ChatDockSection,
+ type ChatDockSectionProps,
+} from "./chat-dock-section";
+export { GlassPanel, type GlassPanelProps } from "./glass-panel";
+export { LeftRail, type LeftRailProps } from "./left-rail";
+export {
+ type MiniMapMarker,
+ MiniMapPanel,
+ type MiniMapPanelProps,
+} from "./mini-map-panel";
+export {
+ OverviewBoard,
+ type OverviewBoardItem,
+ type OverviewBoardProps,
+ OverviewCard,
+ type OverviewCardProps,
+ type OverviewCardTone,
+} from "./overview-board";
export {
NavbarSaas,
type NavbarSaasProps,
type NavItem,
useMobile,
} from "./navbar-saas";
+export { RightDock, type RightDockProps } from "./right-dock";
export { Sidebar } from "./sidebar";
export type { SidebarItem, SidebarSection } from "./sidebar";
export { SidebarProvider, useSidebar } from "./sidebar-provider";
export { TableOfContents } from "./table-of-contents";
+export { TopBar, type TopBarProps } from "./top-bar";
+export { ZoomHUD, type ZoomHUDProps } from "./zoom-hud";
// Blog components
export {
@@ -631,6 +690,11 @@ export {
ViewSwitcher,
type ViewSwitcherProps,
} from "./view-switcher";
+export {
+ type WorkspaceOption,
+ WorkspaceSwitcher,
+ type WorkspaceSwitcherProps,
+} from "./workspace-switcher";
// Flow/Diagram components
export {
@@ -649,6 +713,23 @@ export {
type UseFlowDiagramReturn,
} from "./flow-diagram";
+// Canvas/Object components
+export { AnchorPort, type AnchorPortProps } from "./anchor-port";
+export {
+ ConnectorEdge,
+ type ConnectorEdgePoint,
+ type ConnectorEdgeProps,
+} from "./connector-edge";
+export { EdgeLabel, type EdgeLabelProps } from "./edge-label";
+export { GroupHull, type GroupHullProps } from "./group-hull";
+export {
+ ObjectCard,
+ type ObjectCardAction,
+ type ObjectCardMetric,
+ type ObjectCardProps,
+} from "./object-card";
+export { ObjectHandle, type ObjectHandleProps } from "./object-handle";
+
// AI/Chat components
export { InlineInput, type InlineInputProps } from "./inline-input";
export {
diff --git a/packages/ui/src/components/left-rail/index.ts b/packages/ui/src/components/left-rail/index.ts
new file mode 100644
index 0000000..8a75ad0
--- /dev/null
+++ b/packages/ui/src/components/left-rail/index.ts
@@ -0,0 +1 @@
+export { LeftRail, type LeftRailProps } from "./left-rail";
diff --git a/packages/ui/src/components/left-rail/left-rail.stories.tsx b/packages/ui/src/components/left-rail/left-rail.stories.tsx
new file mode 100644
index 0000000..697dbd9
--- /dev/null
+++ b/packages/ui/src/components/left-rail/left-rail.stories.tsx
@@ -0,0 +1,27 @@
+import type { Meta, StoryObj } from "@storybook/react-vite";
+
+import { Compass, Layers3, Sparkles } from "lucide-react";
+
+import { Button } from "../button";
+import { LeftRail } from "./left-rail";
+
+const meta = {
+ component: LeftRail,
+ render: () => (
+
+ }
+ title="Mode"
+ >
+
+
+
+
+ ),
+ title: "Layout/LeftRail",
+} satisfies Meta;
+
+export default meta;
+type Story = StoryObj;
+
+export const Default: Story = {};
diff --git a/packages/ui/src/components/left-rail/left-rail.tsx b/packages/ui/src/components/left-rail/left-rail.tsx
new file mode 100644
index 0000000..17f3400
--- /dev/null
+++ b/packages/ui/src/components/left-rail/left-rail.tsx
@@ -0,0 +1,39 @@
+import { forwardRef } from "react";
+
+import type { ReactNode } from "react";
+
+import { cn } from "../../lib/utils";
+
+export type LeftRailProps = React.ComponentPropsWithoutRef<"aside"> & {
+ footer?: ReactNode;
+ title?: ReactNode;
+};
+
+const LeftRail = forwardRef(
+ ({ children, className, footer, title, ...props }, ref) => (
+
+ {title ? (
+
+ {title}
+
+ ) : null}
+
+ {children}
+
+ {footer ? (
+ {footer}
+ ) : null}
+
+ ),
+);
+
+LeftRail.displayName = "LeftRail";
+
+export { LeftRail };
diff --git a/packages/ui/src/components/mini-map-panel/index.ts b/packages/ui/src/components/mini-map-panel/index.ts
new file mode 100644
index 0000000..fe61476
--- /dev/null
+++ b/packages/ui/src/components/mini-map-panel/index.ts
@@ -0,0 +1,5 @@
+export {
+ type MiniMapMarker,
+ MiniMapPanel,
+ type MiniMapPanelProps,
+} from "./mini-map-panel";
diff --git a/packages/ui/src/components/mini-map-panel/mini-map-panel.stories.tsx b/packages/ui/src/components/mini-map-panel/mini-map-panel.stories.tsx
new file mode 100644
index 0000000..ecb19b0
--- /dev/null
+++ b/packages/ui/src/components/mini-map-panel/mini-map-panel.stories.tsx
@@ -0,0 +1,22 @@
+import type { Meta, StoryObj } from "@storybook/react-vite";
+
+import { MiniMapPanel } from "./mini-map-panel";
+
+const meta = {
+ args: {
+ markers: [
+ { id: "run", label: "Run stream", x: 320, y: 240 },
+ { id: "knowledge", label: "Knowledge cluster", x: 820, y: 420 },
+ { id: "agent", label: "Agent loop", x: 1120, y: 760 },
+ ],
+ viewport: { height: 360, width: 520, x: 300, y: 180, zoom: 1 },
+ world: { height: 1200, width: 1600 },
+ },
+ component: MiniMapPanel,
+ title: "Panels/MiniMapPanel",
+} satisfies Meta;
+
+export default meta;
+type Story = StoryObj;
+
+export const Default: Story = {};
diff --git a/packages/ui/src/components/mini-map-panel/mini-map-panel.tsx b/packages/ui/src/components/mini-map-panel/mini-map-panel.tsx
new file mode 100644
index 0000000..753d7d8
--- /dev/null
+++ b/packages/ui/src/components/mini-map-panel/mini-map-panel.tsx
@@ -0,0 +1,98 @@
+import { forwardRef } from "react";
+
+import { cn } from "../../lib/utils";
+
+export type MiniMapMarker = {
+ id: string;
+ label?: string;
+ x: number;
+ y: number;
+};
+
+export type MiniMapPanelProps = React.ComponentPropsWithoutRef<"div"> & {
+ markers?: MiniMapMarker[];
+ title?: string;
+ viewport: {
+ height: number;
+ width: number;
+ x: number;
+ y: number;
+ zoom: number;
+ };
+ world: {
+ height: number;
+ width: number;
+ };
+};
+
+const MiniMapPanel = forwardRef(
+ (
+ { className, markers = [], title = "Overview", viewport, world, ...props },
+ ref,
+ ) => {
+ const viewportWidth = Math.max(
+ (viewport.width / viewport.zoom / world.width) * 100,
+ 8,
+ );
+ const viewportHeight = Math.max(
+ (viewport.height / viewport.zoom / world.height) * 100,
+ 8,
+ );
+ const viewportLeft = Math.min(
+ Math.max((viewport.x / world.width) * 100, 0),
+ 100 - viewportWidth,
+ );
+ const viewportTop = Math.min(
+ Math.max((viewport.y / world.height) * 100, 0),
+ 100 - viewportHeight,
+ );
+
+ return (
+
+
+
+
+ {title}
+
+
+ Zoom {Math.round(viewport.zoom * 100)}%
+
+
+
+
+ {markers.map((marker) => (
+
+ ))}
+
+
+
+ );
+ },
+);
+
+MiniMapPanel.displayName = "MiniMapPanel";
+
+export { MiniMapPanel };
diff --git a/packages/ui/src/components/mini-map-panel/mini-map-panel.visual.tsx b/packages/ui/src/components/mini-map-panel/mini-map-panel.visual.tsx
new file mode 100644
index 0000000..2e576d7
--- /dev/null
+++ b/packages/ui/src/components/mini-map-panel/mini-map-panel.visual.tsx
@@ -0,0 +1,20 @@
+import { expect, test } from "@playwright/experimental-ct-react";
+
+import { MiniMapPanel } from "./mini-map-panel";
+
+test.describe("MiniMapPanel Visual", () => {
+ test("default", async ({ mount, page }) => {
+ await mount(
+ ,
+ );
+ await expect(page).toHaveScreenshot("mini-map-panel-default.png");
+ });
+});
diff --git a/packages/ui/src/components/multi-select/index.ts b/packages/ui/src/components/multi-select/index.ts
new file mode 100644
index 0000000..da35d62
--- /dev/null
+++ b/packages/ui/src/components/multi-select/index.ts
@@ -0,0 +1,5 @@
+export {
+ MultiSelect,
+ type MultiSelectOption,
+ type MultiSelectProps,
+} from "./multi-select";
diff --git a/packages/ui/src/components/multi-select/multi-select.mdx b/packages/ui/src/components/multi-select/multi-select.mdx
new file mode 100644
index 0000000..afd0123
--- /dev/null
+++ b/packages/ui/src/components/multi-select/multi-select.mdx
@@ -0,0 +1,53 @@
+import { Canvas, Controls, Meta, Primary } from '@storybook/addon-docs/blocks'
+import * as Stories from './multi-select.stories'
+
+
+
+# MultiSelect
+
+A form control for choosing multiple options from a popover-backed list using existing button, popover, and command primitives.
+
+
+
+## Installation
+
+```bash
+pnpm dlx shadcn@latest add https://ui.vllnt.com/r/multi-select.json
+```
+
+## Import
+
+```tsx
+import { MultiSelect } from '@vllnt/ui'
+```
+
+## Usage
+
+```tsx
+
+```
+
+
+
+## Searchable options
+
+Enable `searchable` to add a command input for filtering larger option sets.
+
+
+
+## Controlled selection
+
+Pass `value` and `onValueChange` when the selected values should be driven by parent state.
+
+
+
+## API Reference
+
+
diff --git a/packages/ui/src/components/multi-select/multi-select.stories.tsx b/packages/ui/src/components/multi-select/multi-select.stories.tsx
new file mode 100644
index 0000000..ec6b711
--- /dev/null
+++ b/packages/ui/src/components/multi-select/multi-select.stories.tsx
@@ -0,0 +1,47 @@
+import * as React from "react";
+
+import type { Meta, StoryObj } from "@storybook/react-vite";
+
+import { MultiSelect } from "./multi-select";
+
+const options = [
+ { label: "React", value: "react" },
+ { label: "Vue", value: "vue" },
+ { label: "Svelte", value: "svelte" },
+ { label: "Solid", value: "solid" },
+];
+
+const meta = {
+ args: {
+ options: options,
+ placeholder: "Select frameworks",
+ },
+ component: MultiSelect,
+ title: "Form/MultiSelect",
+} satisfies Meta;
+
+export default meta;
+type Story = StoryObj;
+
+export const Default: Story = {};
+
+export const Searchable: Story = {
+ args: {
+ searchable: true,
+ },
+};
+
+export const Controlled: Story = {
+ args: {
+ searchable: true,
+ },
+ render: (args) => {
+ const [value, setValue] = React.useState(["react", "vue"]);
+
+ return (
+
+
+
+ );
+ },
+};
diff --git a/packages/ui/src/components/multi-select/multi-select.test.tsx b/packages/ui/src/components/multi-select/multi-select.test.tsx
new file mode 100644
index 0000000..644e364
--- /dev/null
+++ b/packages/ui/src/components/multi-select/multi-select.test.tsx
@@ -0,0 +1,115 @@
+import { fireEvent, render, screen } from "@testing-library/react";
+import { beforeAll, describe, expect, it, vi } from "vitest";
+
+import { MultiSelect } from "./multi-select";
+
+const OPTIONS = [
+ { label: "React", value: "react" },
+ { label: "Vue", value: "vue" },
+ { disabled: true, label: "Svelte", value: "svelte" },
+];
+
+class ResizeObserverMock {
+ observe() {
+ return;
+ }
+
+ disconnect() {
+ return;
+ }
+
+ unobserve() {
+ return;
+ }
+}
+
+beforeAll(() => {
+ if (window.ResizeObserver === undefined) {
+ Object.defineProperty(window, "ResizeObserver", {
+ configurable: true,
+ value: ResizeObserverMock,
+ writable: true,
+ });
+ }
+
+ if (HTMLElement.prototype.scrollIntoView === undefined) {
+ Object.defineProperty(HTMLElement.prototype, "scrollIntoView", {
+ configurable: true,
+ value: vi.fn(),
+ writable: true,
+ });
+ }
+});
+
+describe("MultiSelect", () => {
+ it("supports uncontrolled selection and displays the selected value", () => {
+ render(
+ ,
+ );
+
+ expect(screen.getByRole("combobox")).toHaveTextContent("React");
+
+ fireEvent.click(screen.getByRole("combobox"));
+ fireEvent.click(screen.getByRole("option", { name: "Vue" }));
+
+ expect(screen.getByRole("combobox")).toHaveTextContent("React");
+ expect(screen.getByRole("combobox")).toHaveTextContent("Vue");
+ });
+
+ it("supports controlled selection via onValueChange", () => {
+ const handleValueChange = vi.fn();
+
+ render(
+ ,
+ );
+
+ fireEvent.click(screen.getByRole("combobox"));
+ fireEvent.click(screen.getByRole("option", { name: "Vue" }));
+
+ expect(handleValueChange).toHaveBeenCalledWith(["react", "vue"]);
+ expect(screen.getByRole("combobox")).not.toHaveTextContent("Vue");
+ });
+
+ it("prevents interaction when disabled", () => {
+ render( );
+
+ const trigger = screen.getByRole("combobox");
+
+ expect(trigger).toBeDisabled();
+
+ fireEvent.click(trigger);
+
+ expect(screen.queryByRole("listbox")).not.toBeInTheDocument();
+ });
+
+ it("opens from the keyboard and exposes multi-select accessibility state", () => {
+ render( );
+
+ const trigger = screen.getByRole("combobox");
+
+ trigger.focus();
+ fireEvent.keyDown(trigger, { key: "ArrowDown" });
+
+ expect(trigger).toHaveAttribute("aria-expanded", "true");
+ expect(screen.getByRole("listbox")).toHaveAttribute(
+ "aria-multiselectable",
+ "true",
+ );
+
+ const selectedOption = screen.getByRole("option", { name: "React" });
+ const disabledOption = screen.getByRole("option", { name: "Svelte" });
+
+ fireEvent.click(selectedOption);
+
+ expect(selectedOption).toHaveAttribute("aria-selected", "true");
+ expect(disabledOption).toHaveAttribute("aria-disabled", "true");
+ });
+});
diff --git a/packages/ui/src/components/multi-select/multi-select.tsx b/packages/ui/src/components/multi-select/multi-select.tsx
new file mode 100644
index 0000000..5d8abe6
--- /dev/null
+++ b/packages/ui/src/components/multi-select/multi-select.tsx
@@ -0,0 +1,364 @@
+"use client";
+
+import * as React from "react";
+
+import { Check, ChevronDown } from "lucide-react";
+
+import { cn } from "../../lib/utils";
+import { Badge } from "../badge";
+import { Button } from "../button";
+import {
+ Command,
+ CommandEmpty,
+ CommandGroup,
+ CommandInput,
+ CommandItem,
+ CommandList,
+} from "../command";
+import { Popover, PopoverContent, PopoverTrigger } from "../popover";
+
+export type MultiSelectOption = {
+ disabled?: boolean;
+ label: string;
+ value: string;
+};
+
+export type MultiSelectProps = Omit<
+ React.ButtonHTMLAttributes,
+ "defaultValue" | "onChange" | "value"
+> & {
+ defaultValue?: string[];
+ emptyText?: string;
+ onOpenChange?: (open: boolean) => void;
+ onValueChange?: (value: string[]) => void;
+ options: MultiSelectOption[];
+ placeholder?: string;
+ searchable?: boolean;
+ searchPlaceholder?: string;
+ value?: string[];
+};
+
+type TriggerContentProps = {
+ placeholder: string;
+ selectedOptions: MultiSelectOption[];
+};
+
+type OptionListProps = {
+ disabled: boolean;
+ emptyText: string;
+ onSelect: (value: string) => void;
+ options: MultiSelectOption[];
+ searchable: boolean;
+ searchPlaceholder: string;
+ selectedValues: string[];
+};
+
+type MultiSelectStateOptions = {
+ defaultValue: string[];
+ onOpenChange?: (open: boolean) => void;
+ onValueChange?: (value: string[]) => void;
+ value?: string[];
+};
+
+type MultiSelectTriggerProps = Omit<
+ MultiSelectProps,
+ | "defaultValue"
+ | "emptyText"
+ | "onOpenChange"
+ | "onValueChange"
+ | "options"
+ | "searchable"
+ | "searchPlaceholder"
+ | "value"
+> & {
+ contentId: string;
+ open: boolean;
+ selectedOptions: MultiSelectOption[];
+};
+
+type MultiSelectContentProps = {
+ contentId: string;
+ disabled: boolean;
+ emptyText: string;
+ onSelect: (value: string) => void;
+ options: MultiSelectOption[];
+ searchable: boolean;
+ searchPlaceholder: string;
+ selectedValues: string[];
+};
+
+function getUniqueValues(values: string[]) {
+ return values.filter((value, index) => values.indexOf(value) === index);
+}
+
+function shouldOpenFromKey(key: string) {
+ return key === " " || key === "ArrowDown" || key === "Enter";
+}
+
+function TriggerContent({ placeholder, selectedOptions }: TriggerContentProps) {
+ if (selectedOptions.length === 0) {
+ return {placeholder} ;
+ }
+
+ return (
+ <>
+ {selectedOptions.map((option) => (
+
+ {option.label}
+
+ ))}
+ >
+ );
+}
+
+function OptionList({
+ disabled,
+ emptyText,
+ onSelect,
+ options,
+ searchable,
+ searchPlaceholder,
+ selectedValues,
+}: OptionListProps) {
+ return (
+
+ {searchable ? : null}
+
+ {emptyText}
+
+
+ {options.map((option) => {
+ const isSelected = selectedValues.includes(option.value);
+
+ return (
+ {
+ onSelect(option.value);
+ }}
+ role="option"
+ value={option.label}
+ >
+
+ {isSelected ? : null}
+
+ {option.label}
+
+ );
+ })}
+
+
+
+
+ );
+}
+
+function MultiSelectContent({
+ contentId,
+ disabled,
+ emptyText,
+ onSelect,
+ options,
+ searchable,
+ searchPlaceholder,
+ selectedValues,
+}: MultiSelectContentProps) {
+ return (
+
+
+
+ );
+}
+
+function useMultiSelectState({
+ defaultValue,
+ onOpenChange,
+ onValueChange,
+ value,
+}: MultiSelectStateOptions) {
+ const [open, setOpen] = React.useState(false);
+ const [uncontrolledValue, setUncontrolledValue] = React.useState(() =>
+ getUniqueValues(defaultValue),
+ );
+ const isControlled = value !== undefined;
+ const selectedValues = React.useMemo(
+ () => getUniqueValues(value ?? uncontrolledValue),
+ [uncontrolledValue, value],
+ );
+
+ const setSelectedValues = React.useCallback(
+ (nextValue: string[]) => {
+ const uniqueValues = getUniqueValues(nextValue);
+
+ if (!isControlled) {
+ setUncontrolledValue(uniqueValues);
+ }
+
+ onValueChange?.(uniqueValues);
+ },
+ [isControlled, onValueChange],
+ );
+
+ const handleOpenChange = React.useCallback(
+ (nextOpen: boolean) => {
+ setOpen(nextOpen);
+ onOpenChange?.(nextOpen);
+ },
+ [onOpenChange],
+ );
+
+ return {
+ handleOpenChange,
+ open,
+ selectedValues,
+ setSelectedValues,
+ };
+}
+
+const MultiSelectTrigger = React.forwardRef<
+ HTMLButtonElement,
+ MultiSelectTriggerProps
+>(
+ (
+ {
+ className,
+ contentId,
+ disabled = false,
+ onKeyDown,
+ open,
+ placeholder = "Select options",
+ selectedOptions,
+ ...props
+ },
+ ref,
+ ) => (
+
+
+
+
+
+
+ ),
+);
+MultiSelectTrigger.displayName = "MultiSelectTrigger";
+
+const MultiSelect = React.forwardRef(
+ (
+ {
+ defaultValue = [],
+ emptyText = "No options found.",
+ onKeyDown,
+ onOpenChange,
+ onValueChange,
+ options,
+ searchable = false,
+ searchPlaceholder = "Search options...",
+ value,
+ ...props
+ },
+ ref,
+ ) => {
+ const contentId = React.useId();
+ const { handleOpenChange, open, selectedValues, setSelectedValues } =
+ useMultiSelectState({ defaultValue, onOpenChange, onValueChange, value });
+ const selectedOptions = React.useMemo(
+ () => options.filter((option) => selectedValues.includes(option.value)),
+ [options, selectedValues],
+ );
+
+ const handleSelect = React.useCallback(
+ (nextValue: string) => {
+ const nextSelectedValues = selectedValues.includes(nextValue)
+ ? selectedValues.filter((valueItem) => valueItem !== nextValue)
+ : [...selectedValues, nextValue];
+
+ setSelectedValues(nextSelectedValues);
+ },
+ [selectedValues, setSelectedValues],
+ );
+
+ const handleTriggerKeyDown = React.useCallback(
+ (event: React.KeyboardEvent) => {
+ onKeyDown?.(event);
+
+ if (event.defaultPrevented || props.disabled) {
+ return;
+ }
+
+ if (shouldOpenFromKey(event.key)) {
+ event.preventDefault();
+ handleOpenChange(true);
+ }
+ },
+ [handleOpenChange, onKeyDown, props.disabled],
+ );
+
+ return (
+
+
+
+
+
+
+ );
+ },
+);
+MultiSelect.displayName = "MultiSelect";
+
+export { MultiSelect };
diff --git a/packages/ui/src/components/multi-select/multi-select.visual.tsx b/packages/ui/src/components/multi-select/multi-select.visual.tsx
new file mode 100644
index 0000000..6f26b5b
--- /dev/null
+++ b/packages/ui/src/components/multi-select/multi-select.visual.tsx
@@ -0,0 +1,21 @@
+import { expect, test } from "@playwright/experimental-ct-react";
+
+import { MultiSelect } from "./multi-select";
+
+const options = [
+ { label: "React", value: "react" },
+ { label: "Vue", value: "vue" },
+ { label: "Svelte", value: "svelte" },
+];
+
+test.describe("MultiSelect Visual", () => {
+ test("selected values", async ({ mount, page }) => {
+ await mount(
+
+
+
,
+ );
+
+ await expect(page).toHaveScreenshot("multi-select-selected-values.png");
+ });
+});
diff --git a/packages/ui/src/components/object-card/index.ts b/packages/ui/src/components/object-card/index.ts
new file mode 100644
index 0000000..2480a58
--- /dev/null
+++ b/packages/ui/src/components/object-card/index.ts
@@ -0,0 +1,6 @@
+export {
+ ObjectCard,
+ type ObjectCardAction,
+ type ObjectCardMetric,
+ type ObjectCardProps,
+} from "./object-card";
diff --git a/packages/ui/src/components/object-card/object-card.stories.tsx b/packages/ui/src/components/object-card/object-card.stories.tsx
new file mode 100644
index 0000000..4b359ed
--- /dev/null
+++ b/packages/ui/src/components/object-card/object-card.stories.tsx
@@ -0,0 +1,32 @@
+import type { Meta, StoryObj } from "@storybook/react-vite";
+
+import { AnchorPort } from "../anchor-port";
+import { ObjectCard } from "./object-card";
+
+const meta = {
+ component: ObjectCard,
+ render: () => (
+ }
+ state="running"
+ summary="Durable runtime object tracking prompts, tools, handoffs, and artifact outputs."
+ title="Jarvis orchestration agent"
+ />
+ ),
+ title: "Canvas/ObjectCard",
+} satisfies Meta;
+
+export default meta;
+
+type Story = StoryObj;
+
+export const Default: Story = {};
diff --git a/packages/ui/src/components/object-card/object-card.test.tsx b/packages/ui/src/components/object-card/object-card.test.tsx
new file mode 100644
index 0000000..fa359fe
--- /dev/null
+++ b/packages/ui/src/components/object-card/object-card.test.tsx
@@ -0,0 +1,25 @@
+import { render, screen } from "@testing-library/react";
+import { describe, expect, it } from "vitest";
+
+import { ObjectCard } from "./object-card";
+
+describe("ObjectCard", () => {
+ it("renders object metadata and state", () => {
+ render(
+ ,
+ );
+
+ expect(screen.getByText("Nightly content pipeline")).toBeInTheDocument();
+ expect(screen.getByText("Run")).toBeInTheDocument();
+ expect(screen.getByText("running")).toBeInTheDocument();
+ expect(screen.getByText("Artifacts")).toBeInTheDocument();
+ expect(screen.getByText("7")).toBeInTheDocument();
+ });
+});
diff --git a/packages/ui/src/components/object-card/object-card.tsx b/packages/ui/src/components/object-card/object-card.tsx
new file mode 100644
index 0000000..2633097
--- /dev/null
+++ b/packages/ui/src/components/object-card/object-card.tsx
@@ -0,0 +1,185 @@
+import { forwardRef } from "react";
+
+import type { ReactNode } from "react";
+
+import { cn } from "../../lib/utils";
+import { Badge } from "../badge";
+import { Button } from "../button";
+
+export type ObjectCardMetric = {
+ label: string;
+ value: ReactNode;
+};
+
+export type ObjectCardAction = {
+ label: string;
+ onClick?: () => void;
+};
+
+export type ObjectCardProps = React.ComponentPropsWithoutRef<"article"> & {
+ actions?: ObjectCardAction[];
+ footer?: ReactNode;
+ kind?: string;
+ metrics?: ObjectCardMetric[];
+ ports?: ReactNode;
+ state?: "blocked" | "complete" | "idle" | "running";
+ summary?: string;
+ title: string;
+};
+
+const stateClasses: Record, string> = {
+ blocked:
+ "border-amber-500/30 bg-amber-500/10 text-amber-700 dark:text-amber-300",
+ complete:
+ "border-emerald-500/30 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300",
+ idle: "border-border/70 bg-muted/60 text-muted-foreground",
+ running: "border-sky-500/30 bg-sky-500/10 text-sky-700 dark:text-sky-300",
+};
+
+function ObjectCardHeader({
+ kind,
+ ports,
+ state,
+ summary,
+ title,
+}: {
+ kind: string;
+ ports?: ReactNode;
+ state: NonNullable;
+ summary?: string;
+ title: string;
+}) {
+ return (
+
+
+
+
+ {kind}
+
+
+ {state}
+
+
+
+
+ {title}
+
+ {summary ? (
+
+ {summary}
+
+ ) : null}
+
+
+ {ports ?
{ports}
: null}
+
+ );
+}
+
+function ObjectCardMetrics({ metrics }: Pick) {
+ if (!metrics?.length) {
+ return null;
+ }
+
+ return (
+
+ {metrics.map((metric) => (
+
+
+ {metric.label}
+
+
+ {metric.value}
+
+
+ ))}
+
+ );
+}
+
+function ObjectCardActions({ actions }: Pick) {
+ if (!actions?.length) {
+ return null;
+ }
+
+ return (
+
+ {actions.map((action) => {
+ const handleActionClick = () => {
+ action.onClick?.();
+ };
+
+ return (
+
+ {action.label}
+
+ );
+ })}
+
+ );
+}
+
+const ObjectCard = forwardRef(
+ (
+ {
+ actions,
+ children,
+ className,
+ footer,
+ kind = "Object",
+ metrics = [],
+ ports,
+ state = "idle",
+ summary,
+ title,
+ ...props
+ },
+ ref,
+ ) => (
+
+
+
+
+ {children ? {children}
: null}
+
+ {footer ? (
+
+ {footer}
+
+ ) : null}
+
+ ),
+);
+
+ObjectCard.displayName = "ObjectCard";
+
+export { ObjectCard };
diff --git a/packages/ui/src/components/object-card/object-card.visual.tsx b/packages/ui/src/components/object-card/object-card.visual.tsx
new file mode 100644
index 0000000..763a266
--- /dev/null
+++ b/packages/ui/src/components/object-card/object-card.visual.tsx
@@ -0,0 +1,25 @@
+import { expect, test } from "@playwright/experimental-ct-react";
+
+import { AnchorPort } from "../anchor-port";
+import { ObjectCard } from "./object-card";
+
+test.describe("ObjectCard Visual", () => {
+ test("default", async ({ mount, page }) => {
+ await mount(
+ }
+ state="complete"
+ summary="Pinned output object with lineage, score, and delivery state."
+ title="Research summary bundle"
+ />,
+ );
+
+ await expect(page).toHaveScreenshot("object-card-default.png");
+ });
+});
diff --git a/packages/ui/src/components/object-handle/index.ts b/packages/ui/src/components/object-handle/index.ts
new file mode 100644
index 0000000..576b12c
--- /dev/null
+++ b/packages/ui/src/components/object-handle/index.ts
@@ -0,0 +1 @@
+export { ObjectHandle, type ObjectHandleProps } from "./object-handle";
diff --git a/packages/ui/src/components/object-handle/object-handle.stories.tsx b/packages/ui/src/components/object-handle/object-handle.stories.tsx
new file mode 100644
index 0000000..fecfebb
--- /dev/null
+++ b/packages/ui/src/components/object-handle/object-handle.stories.tsx
@@ -0,0 +1,16 @@
+import type { Meta, StoryObj } from "@storybook/react-vite";
+
+import { ObjectHandle } from "./object-handle";
+
+const meta = {
+ component: ObjectHandle,
+ args: {
+ hint: "⌘ drag",
+ label: "Reposition",
+ },
+ title: "Canvas/ObjectHandle",
+} satisfies Meta;
+
+export default meta;
+type Story = StoryObj;
+export const Default: Story = {};
diff --git a/packages/ui/src/components/object-handle/object-handle.test.tsx b/packages/ui/src/components/object-handle/object-handle.test.tsx
new file mode 100644
index 0000000..0d1d2d3
--- /dev/null
+++ b/packages/ui/src/components/object-handle/object-handle.test.tsx
@@ -0,0 +1,15 @@
+import { render, screen } from "@testing-library/react";
+import { describe, expect, it } from "vitest";
+
+import { ObjectHandle } from "./object-handle";
+
+describe("ObjectHandle", () => {
+ it("renders label and hint", () => {
+ render( );
+
+ expect(
+ screen.getByRole("button", { name: /reposition/i }),
+ ).toBeInTheDocument();
+ expect(screen.getByText("⌘ drag")).toBeInTheDocument();
+ });
+});
diff --git a/packages/ui/src/components/object-handle/object-handle.tsx b/packages/ui/src/components/object-handle/object-handle.tsx
new file mode 100644
index 0000000..04ed2f2
--- /dev/null
+++ b/packages/ui/src/components/object-handle/object-handle.tsx
@@ -0,0 +1,43 @@
+import { forwardRef } from "react";
+
+import type { ReactNode } from "react";
+
+import { cn } from "../../lib/utils";
+
+export type ObjectHandleProps = Omit<
+ React.ComponentPropsWithoutRef<"button">,
+ "type"
+> & {
+ hint?: ReactNode;
+ label?: ReactNode;
+};
+
+const ObjectHandle = forwardRef(
+ ({ className, hint, label = "Move", ...props }, ref) => (
+
+
+ •
+ •
+ •
+ •
+
+ {label}
+ {hint ? {hint} : null}
+
+ ),
+);
+
+ObjectHandle.displayName = "ObjectHandle";
+
+export { ObjectHandle };
diff --git a/packages/ui/src/components/object-handle/object-handle.visual.tsx b/packages/ui/src/components/object-handle/object-handle.visual.tsx
new file mode 100644
index 0000000..42ed0d6
--- /dev/null
+++ b/packages/ui/src/components/object-handle/object-handle.visual.tsx
@@ -0,0 +1,11 @@
+import { expect, test } from "@playwright/experimental-ct-react";
+
+import { ObjectHandle } from "./object-handle";
+
+test.describe("ObjectHandle Visual", () => {
+ test("default", async ({ mount, page }) => {
+ await mount( );
+
+ await expect(page).toHaveScreenshot("object-handle-default.png");
+ });
+});
\ No newline at end of file
diff --git a/packages/ui/src/components/overview-board/index.ts b/packages/ui/src/components/overview-board/index.ts
new file mode 100644
index 0000000..f8c3f32
--- /dev/null
+++ b/packages/ui/src/components/overview-board/index.ts
@@ -0,0 +1,8 @@
+export {
+ OverviewBoard,
+ type OverviewBoardItem,
+ type OverviewBoardProps,
+ OverviewCard,
+ type OverviewCardProps,
+ type OverviewCardTone,
+} from "./overview-board";
diff --git a/packages/ui/src/components/overview-board/overview-board.stories.tsx b/packages/ui/src/components/overview-board/overview-board.stories.tsx
new file mode 100644
index 0000000..e5ac354
--- /dev/null
+++ b/packages/ui/src/components/overview-board/overview-board.stories.tsx
@@ -0,0 +1,42 @@
+import type { Meta, StoryObj } from "@storybook/react-vite";
+
+import { OverviewBoard } from "./overview-board";
+
+const meta = {
+ component: OverviewBoard,
+ args: {
+ eyebrow: "Operator overview",
+ heading: "Morning priorities",
+ items: [
+ {
+ description: "Retries that still need intervention after the overnight automation sweep.",
+ heading: "Errors",
+ id: "errors",
+ metric: "3",
+ tone: "danger",
+ },
+ {
+ ctaLabel: "Open queue",
+ description: "Actionable approvals and follow-ups that should land before lunch.",
+ heading: "Actions",
+ id: "actions",
+ metric: "7",
+ tone: "warning",
+ },
+ {
+ description: "Background automations that already recovered and only need a quick skim.",
+ heading: "Runs",
+ id: "runs",
+ metric: "14",
+ },
+ ],
+ subtitle: "A compact summary board for the floating canvas shell right rail or overview route.",
+ },
+ render: (args) => ,
+ title: "Layout/OverviewBoard",
+} satisfies Meta;
+
+export default meta;
+type Story = StoryObj;
+
+export const Default: Story = {};
diff --git a/packages/ui/src/components/overview-board/overview-board.tsx b/packages/ui/src/components/overview-board/overview-board.tsx
new file mode 100644
index 0000000..cd764f1
--- /dev/null
+++ b/packages/ui/src/components/overview-board/overview-board.tsx
@@ -0,0 +1,181 @@
+import { forwardRef } from "react";
+
+import { AlertCircle, ArrowRight, Inbox, ListTodo, Siren } from "lucide-react";
+import type { ReactNode } from "react";
+
+import { cn } from "../../lib/utils";
+import { Button } from "../button";
+
+export type OverviewCardTone = "danger" | "default" | "warning";
+
+export type OverviewCardProps = React.ComponentPropsWithoutRef<"section"> & {
+ ctaLabel?: ReactNode;
+ description: ReactNode;
+ handleCtaClick?: () => void;
+ heading: ReactNode;
+ icon?: ReactNode;
+ metric: ReactNode;
+ tone?: OverviewCardTone;
+};
+
+const toneClassNames: Record = {
+ danger: "border-red-500/30 bg-red-500/8",
+ default: "border-border/70 bg-background/80",
+ warning: "border-amber-500/30 bg-amber-500/8",
+};
+
+const toneAccentClassNames: Record = {
+ danger: "text-red-600 dark:text-red-300",
+ default: "text-primary",
+ warning: "text-amber-600 dark:text-amber-300",
+};
+
+const OverviewCard = forwardRef(
+ (
+ {
+ className,
+ ctaLabel,
+ description,
+ handleCtaClick,
+ heading,
+ icon,
+ metric,
+ tone = "default",
+ ...props
+ },
+ ref,
+ ) => (
+
+
+
+
+ {heading}
+
+
+ {metric}
+
+
+
+ {icon}
+
+
+ {description}
+ {ctaLabel ? (
+
+ ) : null}
+
+ ),
+);
+
+OverviewCard.displayName = "OverviewCard";
+
+export type OverviewBoardItem = {
+ ctaLabel?: ReactNode;
+ description: ReactNode;
+ handleCtaClick?: () => void;
+ heading: ReactNode;
+ icon?: ReactNode;
+ id: string;
+ metric: ReactNode;
+ tone?: OverviewCardTone;
+};
+
+export type OverviewBoardProps = React.ComponentPropsWithoutRef<"section"> & {
+ eyebrow?: ReactNode;
+ heading: ReactNode;
+ items: OverviewBoardItem[];
+ subtitle?: ReactNode;
+};
+
+function getDefaultIcon(heading: ReactNode) {
+ if (typeof heading !== "string") {
+ return ;
+ }
+
+ if (heading.toLowerCase().includes("error")) {
+ return ;
+ }
+
+ if (heading.toLowerCase().includes("action")) {
+ return ;
+ }
+
+ if (heading.toLowerCase().includes("run")) {
+ return ;
+ }
+
+ return ;
+}
+
+const OverviewBoard = forwardRef(
+ ({ className, eyebrow, heading, items, subtitle, ...props }, ref) => (
+
+
+ {eyebrow ? (
+
+ {eyebrow}
+
+ ) : null}
+
+
+ {heading}
+
+ {subtitle ? (
+
+ {subtitle}
+
+ ) : null}
+
+
+
+ {items.map((item) => {
+ const handleCtaClick = item.handleCtaClick;
+
+ return (
+
+ );
+ })}
+
+
+ ),
+);
+
+OverviewBoard.displayName = "OverviewBoard";
+
+export { OverviewBoard, OverviewCard };
diff --git a/packages/ui/src/components/right-dock/index.ts b/packages/ui/src/components/right-dock/index.ts
new file mode 100644
index 0000000..2e1e6ad
--- /dev/null
+++ b/packages/ui/src/components/right-dock/index.ts
@@ -0,0 +1 @@
+export { RightDock, type RightDockProps } from "./right-dock";
diff --git a/packages/ui/src/components/right-dock/right-dock.stories.tsx b/packages/ui/src/components/right-dock/right-dock.stories.tsx
new file mode 100644
index 0000000..642bb78
--- /dev/null
+++ b/packages/ui/src/components/right-dock/right-dock.stories.tsx
@@ -0,0 +1,27 @@
+import type { Meta, StoryObj } from "@storybook/react-vite";
+
+import { RightDock } from "./right-dock";
+
+const meta = {
+ component: RightDock,
+ render: () => (
+
+ Context stays secondary to the viewport.
}
+ header={Contextual panels and detail inspectors.
}
+ title="Context Dock"
+ >
+
+
Selected object summary
+
Recent run activity
+
+
+
+ ),
+ title: "Layout/RightDock",
+} satisfies Meta;
+
+export default meta;
+type Story = StoryObj;
+
+export const Default: Story = {};
diff --git a/packages/ui/src/components/right-dock/right-dock.tsx b/packages/ui/src/components/right-dock/right-dock.tsx
new file mode 100644
index 0000000..f7ecc29
--- /dev/null
+++ b/packages/ui/src/components/right-dock/right-dock.tsx
@@ -0,0 +1,41 @@
+import { forwardRef } from "react";
+
+import type { ReactNode } from "react";
+
+import { cn } from "../../lib/utils";
+
+export type RightDockProps = React.ComponentPropsWithoutRef<"aside"> & {
+ footer?: ReactNode;
+ header?: ReactNode;
+ title?: ReactNode;
+};
+
+const RightDock = forwardRef(
+ ({ children, className, footer, header, title, ...props }, ref) => (
+
+ {header || title ? (
+
+ {title ? (
+
{title}
+ ) : null}
+ {header ?
{header}
: null}
+
+ ) : null}
+ {children}
+ {footer ? (
+ {footer}
+ ) : null}
+
+ ),
+);
+
+RightDock.displayName = "RightDock";
+
+export { RightDock };
diff --git a/packages/ui/src/components/segmented-control/index.ts b/packages/ui/src/components/segmented-control/index.ts
new file mode 100644
index 0000000..883aa53
--- /dev/null
+++ b/packages/ui/src/components/segmented-control/index.ts
@@ -0,0 +1,8 @@
+export {
+ SegmentedControl,
+ SegmentedControlItem,
+ type SegmentedControlItemProps,
+ segmentedControlItemVariants,
+ type SegmentedControlProps,
+ segmentedControlVariants,
+} from "./segmented-control";
diff --git a/packages/ui/src/components/segmented-control/segmented-control.mdx b/packages/ui/src/components/segmented-control/segmented-control.mdx
new file mode 100644
index 0000000..3d25491
--- /dev/null
+++ b/packages/ui/src/components/segmented-control/segmented-control.mdx
@@ -0,0 +1,50 @@
+import { Canvas, Controls, Meta, Primary } from '@storybook/addon-docs/blocks'
+import * as Stories from './segmented-control.stories'
+
+
+
+# SegmentedControl
+
+A single-choice segmented selector built on the existing toggle-group primitive for compact view, filter, and mode switching.
+
+
+
+## Installation
+
+```bash
+pnpm dlx shadcn@latest add https://ui.vllnt.com/r/segmented-control.json
+```
+
+## Import
+
+```tsx
+import { SegmentedControl, SegmentedControlItem } from '@vllnt/ui'
+```
+
+## Usage
+
+```tsx
+
+ Board
+ List
+ Timeline
+
+```
+
+
+
+## Controlled selection
+
+Pass `value` and `onValueChange` to drive the selected segment from parent state.
+
+
+
+## Disabled state
+
+Set `disabled` on the root to disable the full control, or on an individual item to disable a single option.
+
+
+
+## API Reference
+
+
diff --git a/packages/ui/src/components/segmented-control/segmented-control.stories.tsx b/packages/ui/src/components/segmented-control/segmented-control.stories.tsx
new file mode 100644
index 0000000..7d549f0
--- /dev/null
+++ b/packages/ui/src/components/segmented-control/segmented-control.stories.tsx
@@ -0,0 +1,53 @@
+import * as React from "react";
+
+import type { Meta, StoryObj } from "@storybook/react-vite";
+
+import { SegmentedControl, SegmentedControlItem } from "./segmented-control";
+
+const meta = {
+ component: SegmentedControl,
+ title: "Form/SegmentedControl",
+} satisfies Meta;
+
+export default meta;
+type Story = StoryObj;
+
+export const Default: Story = {
+ render: () => (
+
+
+ Board
+ List
+ Timeline
+
+
+ ),
+};
+
+export const Disabled: Story = {
+ render: () => (
+
+
+ Monthly
+ Yearly
+
+
+ ),
+};
+
+export const Controlled: Story = {
+ render: () => {
+ const [value, setValue] = React.useState("activity");
+
+ return (
+
+
+ Activity
+ Usage
+ Members
+
+
Selected: {value}
+
+ );
+ },
+};
diff --git a/packages/ui/src/components/segmented-control/segmented-control.test.tsx b/packages/ui/src/components/segmented-control/segmented-control.test.tsx
new file mode 100644
index 0000000..d8585b7
--- /dev/null
+++ b/packages/ui/src/components/segmented-control/segmented-control.test.tsx
@@ -0,0 +1,93 @@
+import {
+ act,
+ fireEvent,
+ render,
+ screen,
+ waitFor,
+} from "@testing-library/react";
+import type * as React from "react";
+import { describe, expect, it, vi } from "vitest";
+
+import { SegmentedControl, SegmentedControlItem } from "./segmented-control";
+
+function renderSegmentedControl(
+ props: React.ComponentProps = {},
+) {
+ return render(
+
+ Overview
+ Details
+
+ Settings
+
+ ,
+ );
+}
+
+describe("SegmentedControl", () => {
+ it("supports uncontrolled single selection", () => {
+ renderSegmentedControl({ defaultValue: "overview" });
+
+ const overview = screen.getByRole("radio", { name: "Overview" });
+ const details = screen.getByRole("radio", { name: "Details" });
+
+ expect(overview).toHaveAttribute("aria-checked", "true");
+ expect(details).toHaveAttribute("aria-checked", "false");
+
+ fireEvent.click(details);
+
+ expect(overview).toHaveAttribute("aria-checked", "false");
+ expect(details).toHaveAttribute("aria-checked", "true");
+ });
+
+ it("supports controlled selection via onValueChange", () => {
+ const handleValueChange = vi.fn();
+
+ renderSegmentedControl({
+ onValueChange: handleValueChange,
+ value: "overview",
+ });
+
+ fireEvent.click(screen.getByRole("radio", { name: "Details" }));
+
+ expect(handleValueChange).toHaveBeenCalledWith("details");
+ expect(screen.getByRole("radio", { name: "Overview" })).toHaveAttribute(
+ "aria-checked",
+ "true",
+ );
+ });
+
+ it("supports keyboard navigation and exposes radio semantics", async () => {
+ renderSegmentedControl({ defaultValue: "overview" });
+
+ const group = screen.getByRole("group", { name: "View mode" });
+ const overview = screen.getByRole("radio", { name: "Overview" });
+ const details = screen.getByRole("radio", { name: "Details" });
+ const settings = screen.getByRole("radio", { name: "Settings" });
+
+ await act(async () => {
+ overview.focus();
+ fireEvent.keyDown(overview, { key: "ArrowRight" });
+ });
+
+ expect(group).toBeInTheDocument();
+ await waitFor(() => {
+ expect(details).toHaveAttribute("tabindex", "0");
+ });
+ expect(settings).toBeDisabled();
+ });
+
+ it("prevents interaction when disabled", () => {
+ renderSegmentedControl({ defaultValue: "overview", disabled: true });
+
+ const overview = screen.getByRole("radio", { name: "Overview" });
+ const details = screen.getByRole("radio", { name: "Details" });
+
+ fireEvent.click(details);
+
+ expect(overview).toBeDisabled();
+ expect(details).toBeDisabled();
+ expect(overview).toHaveAttribute("aria-checked", "true");
+ expect(details).toHaveAttribute("aria-checked", "false");
+ });
+});
diff --git a/packages/ui/src/components/segmented-control/segmented-control.tsx b/packages/ui/src/components/segmented-control/segmented-control.tsx
new file mode 100644
index 0000000..07cd765
--- /dev/null
+++ b/packages/ui/src/components/segmented-control/segmented-control.tsx
@@ -0,0 +1,87 @@
+"use client";
+
+import * as React from "react";
+
+import * as ToggleGroupPrimitive from "@radix-ui/react-toggle-group";
+import { cva, type VariantProps } from "class-variance-authority";
+
+import { cn } from "../../lib/utils";
+
+const segmentedControlVariants = cva(
+ "inline-flex w-full items-center rounded-lg bg-muted p-1 text-muted-foreground",
+ {
+ defaultVariants: {
+ size: "default",
+ },
+ variants: {
+ size: {
+ default: "min-h-10",
+ lg: "min-h-11",
+ sm: "min-h-9",
+ },
+ },
+ },
+);
+
+const segmentedControlItemVariants = cva(
+ "inline-flex flex-1 items-center justify-center rounded-md px-3 text-sm font-medium whitespace-nowrap transition-all outline-none ring-offset-background focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-background data-[state=on]:text-foreground data-[state=on]:shadow-sm",
+ {
+ defaultVariants: {
+ size: "default",
+ },
+ variants: {
+ size: {
+ default: "min-h-8",
+ lg: "min-h-9 px-4",
+ sm: "min-h-7 px-2.5 text-xs",
+ },
+ },
+ },
+);
+
+export type SegmentedControlProps = Omit<
+ React.ComponentPropsWithoutRef,
+ "defaultValue" | "onValueChange" | "type" | "value"
+> &
+ VariantProps & {
+ defaultValue?: string;
+ onValueChange?: (value: string) => void;
+ value?: string;
+ };
+
+export type SegmentedControlItemProps = React.ComponentPropsWithoutRef<
+ typeof ToggleGroupPrimitive.Item
+> &
+ VariantProps;
+
+const SegmentedControl = React.forwardRef<
+ React.ComponentRef,
+ SegmentedControlProps
+>(({ className, size, ...props }, ref) => (
+
+));
+SegmentedControl.displayName = "SegmentedControl";
+
+const SegmentedControlItem = React.forwardRef<
+ React.ComponentRef,
+ SegmentedControlItemProps
+>(({ className, size, ...props }, ref) => (
+
+));
+SegmentedControlItem.displayName = "SegmentedControlItem";
+
+export {
+ SegmentedControl,
+ SegmentedControlItem,
+ segmentedControlItemVariants,
+ segmentedControlVariants,
+};
diff --git a/packages/ui/src/components/segmented-control/segmented-control.visual.tsx b/packages/ui/src/components/segmented-control/segmented-control.visual.tsx
new file mode 100644
index 0000000..51d42f6
--- /dev/null
+++ b/packages/ui/src/components/segmented-control/segmented-control.visual.tsx
@@ -0,0 +1,19 @@
+import { expect, test } from "@playwright/experimental-ct-react";
+
+import { SegmentedControl, SegmentedControlItem } from "./segmented-control";
+
+test.describe("SegmentedControl Visual", () => {
+ test("controlled selection", async ({ mount, page }) => {
+ await mount(
+
+
+ Daily
+ Weekly
+ Monthly
+
+
,
+ );
+
+ await expect(page).toHaveScreenshot("segmented-control-controlled-selection.png");
+ });
+});
diff --git a/packages/ui/src/components/spinner/unicode-spinner.tsx b/packages/ui/src/components/spinner/unicode-spinner.tsx
index 0908390..0ac8655 100644
--- a/packages/ui/src/components/spinner/unicode-spinner.tsx
+++ b/packages/ui/src/components/spinner/unicode-spinner.tsx
@@ -1,3 +1,5 @@
+"use client";
+
import * as React from "react";
import { cn } from "../../lib/utils";
diff --git a/packages/ui/src/components/tags-input/index.ts b/packages/ui/src/components/tags-input/index.ts
new file mode 100644
index 0000000..02feb7b
--- /dev/null
+++ b/packages/ui/src/components/tags-input/index.ts
@@ -0,0 +1 @@
+export { TagsInput, type TagsInputProps } from "./tags-input";
diff --git a/packages/ui/src/components/tags-input/tags-input.mdx b/packages/ui/src/components/tags-input/tags-input.mdx
new file mode 100644
index 0000000..53abe96
--- /dev/null
+++ b/packages/ui/src/components/tags-input/tags-input.mdx
@@ -0,0 +1,46 @@
+import { Canvas, Controls, Meta, Primary } from '@storybook/addon-docs/blocks'
+import * as Stories from './tags-input.stories'
+
+
+
+# TagsInput
+
+A compact tag editor for adding and removing string values from the keyboard without leaving the input field.
+
+
+
+## Installation
+
+```bash
+pnpm dlx shadcn@latest add https://ui.vllnt.com/r/tags-input.json
+```
+
+## Import
+
+```tsx
+import { TagsInput } from '@vllnt/ui'
+```
+
+## Usage
+
+```tsx
+
+```
+
+
+
+## Keyboard flows
+
+Press `Enter` or `,` to commit the current input as a tag. Press `Backspace` when the text field is empty to remove the last tag.
+
+
+
+## Controlled values
+
+Pass `value` and `onValueChange` when tags should be driven from parent state.
+
+
+
+## API Reference
+
+
diff --git a/packages/ui/src/components/tags-input/tags-input.stories.tsx b/packages/ui/src/components/tags-input/tags-input.stories.tsx
new file mode 100644
index 0000000..599b640
--- /dev/null
+++ b/packages/ui/src/components/tags-input/tags-input.stories.tsx
@@ -0,0 +1,45 @@
+import * as React from "react";
+
+import type { Meta, StoryObj } from "@storybook/react-vite";
+
+import { TagsInput } from "./tags-input";
+
+const meta = {
+ component: TagsInput,
+ title: "Form/TagsInput",
+} satisfies Meta;
+
+export default meta;
+type Story = StoryObj;
+
+export const Default: Story = {
+ args: {
+ "aria-label": "Framework tags",
+ defaultValue: ["React", "Vue"],
+ placeholder: "Add a framework",
+ },
+};
+
+export const Empty: Story = {
+ args: {
+ "aria-label": "Empty tags",
+ placeholder: "Add a tag",
+ },
+};
+
+export const Controlled: Story = {
+ render: () => {
+ const [value, setValue] = React.useState(["Design", "Docs"]);
+
+ return (
+
+
+
+ );
+ },
+};
diff --git a/packages/ui/src/components/tags-input/tags-input.test.tsx b/packages/ui/src/components/tags-input/tags-input.test.tsx
new file mode 100644
index 0000000..bcb3b96
--- /dev/null
+++ b/packages/ui/src/components/tags-input/tags-input.test.tsx
@@ -0,0 +1,94 @@
+import * as React from "react";
+
+import { fireEvent, render, screen } from "@testing-library/react";
+import { describe, expect, it, vi } from "vitest";
+
+import { TagsInput } from "./tags-input";
+
+describe("TagsInput", () => {
+ it("supports uncontrolled keyboard add and remove flows", () => {
+ render( );
+
+ const input = screen.getByRole("textbox", { name: "Framework tags" });
+
+ fireEvent.change(input, { target: { value: "Vue" } });
+ fireEvent.keyDown(input, { key: "Enter" });
+
+ expect(screen.getByText("React")).toBeInTheDocument();
+ expect(screen.getByText("Vue")).toBeInTheDocument();
+
+ fireEvent.keyDown(input, { key: "Backspace" });
+
+ expect(screen.queryByText("Vue")).not.toBeInTheDocument();
+ expect(screen.getByText("React")).toBeInTheDocument();
+ });
+
+ it("supports controlled value updates through onValueChange", () => {
+ function ControlledTagsInput() {
+ const [value, setValue] = React.useState(["React"]);
+
+ return (
+
+ );
+ }
+
+ render( );
+
+ const input = screen.getByRole("textbox", { name: "Controlled tags" });
+
+ fireEvent.change(input, { target: { value: "Vue" } });
+ fireEvent.keyDown(input, { key: "," });
+
+ expect(screen.getByText("React")).toBeInTheDocument();
+ expect(screen.getByText("Vue")).toBeInTheDocument();
+ });
+
+ it("prevents editing and removal when disabled", () => {
+ const handleValueChange = vi.fn();
+
+ render(
+ ,
+ );
+
+ const input = screen.getByRole("textbox", { name: "Disabled tags" });
+ const removeButton = screen.getByRole("button", { name: "Remove React" });
+
+ expect(input).toBeDisabled();
+ expect(removeButton).toBeDisabled();
+ expect(screen.getByRole("group")).toHaveAttribute("aria-disabled", "true");
+
+ fireEvent.click(removeButton);
+
+ expect(handleValueChange).not.toHaveBeenCalled();
+ expect(screen.getByText("React")).toBeInTheDocument();
+ });
+
+ it("renders tags as bordered list items with accessible remove buttons", () => {
+ render(
+ ,
+ );
+
+ const list = screen.getByRole("list");
+ const items = screen.getAllByRole("listitem");
+
+ expect(list).toBeInTheDocument();
+ expect(items).toHaveLength(2);
+ expect(items[0]).toHaveClass("rounded-md", "border", "bg-muted");
+ expect(items[0]).not.toHaveClass("rounded-full");
+ expect(
+ screen.getByRole("button", { name: "Remove React" }),
+ ).toBeInTheDocument();
+ expect(
+ screen.getByRole("button", { name: "Remove Vue" }),
+ ).toBeInTheDocument();
+ });
+});
diff --git a/packages/ui/src/components/tags-input/tags-input.tsx b/packages/ui/src/components/tags-input/tags-input.tsx
new file mode 100644
index 0000000..ce4bbaa
--- /dev/null
+++ b/packages/ui/src/components/tags-input/tags-input.tsx
@@ -0,0 +1,233 @@
+"use client";
+
+import * as React from "react";
+
+import { X } from "lucide-react";
+
+import { cn } from "../../lib/utils";
+
+function normalizeTag(tag: string) {
+ return tag.trim();
+}
+
+function getNormalizedTags(tags: string[]) {
+ return tags
+ .map(normalizeTag)
+ .filter(
+ (tag, index, values) => tag.length > 0 && values.indexOf(tag) === index,
+ );
+}
+
+function shouldAddTagFromKey(key: string) {
+ return key === "Enter" || key === ",";
+}
+
+type TagsInputStateOptions = {
+ defaultValue: string[];
+ onValueChange?: (value: string[]) => void;
+ value?: string[];
+};
+
+type TagsInputHandlersOptions = {
+ disabled: boolean;
+ inputValue: string;
+ onKeyDown?: React.KeyboardEventHandler;
+ setInputValue: React.Dispatch>;
+ tags: string[];
+ updateTags: (nextTags: string[]) => void;
+};
+
+type TagListProps = {
+ disabled: boolean;
+ onRemove: (tag: string) => void;
+ tags: string[];
+};
+
+function useTagsInputState({
+ defaultValue,
+ onValueChange,
+ value,
+}: TagsInputStateOptions) {
+ const [uncontrolledValue, setUncontrolledValue] = React.useState(() =>
+ getNormalizedTags(defaultValue),
+ );
+ const isControlled = value !== undefined;
+ const tags = React.useMemo(
+ () => getNormalizedTags(value ?? uncontrolledValue),
+ [uncontrolledValue, value],
+ );
+
+ const updateTags = React.useCallback(
+ (nextTags: string[]) => {
+ const normalizedTags = getNormalizedTags(nextTags);
+
+ if (!isControlled) {
+ setUncontrolledValue(normalizedTags);
+ }
+
+ onValueChange?.(normalizedTags);
+ },
+ [isControlled, onValueChange],
+ );
+
+ return { tags, updateTags };
+}
+
+function useTagsInputHandlers({
+ disabled,
+ inputValue,
+ onKeyDown,
+ setInputValue,
+ tags,
+ updateTags,
+}: TagsInputHandlersOptions) {
+ const removeTag = React.useCallback(
+ (tagToRemove: string) => {
+ updateTags(tags.filter((tag) => tag !== tagToRemove));
+ },
+ [tags, updateTags],
+ );
+
+ const commitTag = React.useCallback(() => {
+ const nextTag = normalizeTag(inputValue);
+
+ if (nextTag.length === 0 || tags.includes(nextTag)) {
+ setInputValue("");
+ return;
+ }
+
+ updateTags([...tags, nextTag]);
+ setInputValue("");
+ }, [inputValue, setInputValue, tags, updateTags]);
+
+ const handleKeyDown = React.useCallback(
+ (event: React.KeyboardEvent) => {
+ onKeyDown?.(event);
+
+ if (event.defaultPrevented || disabled) {
+ return;
+ }
+
+ if (shouldAddTagFromKey(event.key)) {
+ event.preventDefault();
+ commitTag();
+ return;
+ }
+
+ if (
+ (event.key === "Backspace" || event.key === "Delete") &&
+ inputValue.length === 0
+ ) {
+ const lastTag = tags.at(-1);
+
+ if (lastTag) {
+ event.preventDefault();
+ removeTag(lastTag);
+ }
+ }
+ },
+ [commitTag, disabled, inputValue.length, onKeyDown, removeTag, tags],
+ );
+
+ return { handleKeyDown, removeTag };
+}
+
+function TagList({ disabled, onRemove, tags }: TagListProps) {
+ return (
+
+ {tags.map((tag) => (
+
+ {tag}
+ {
+ event.stopPropagation();
+ onRemove(tag);
+ }}
+ type="button"
+ >
+
+
+
+ ))}
+
+ );
+}
+
+export type TagsInputProps = Omit<
+ React.ComponentPropsWithoutRef<"input">,
+ "defaultValue" | "onChange" | "value"
+> & {
+ defaultValue?: string[];
+ onValueChange?: (value: string[]) => void;
+ value?: string[];
+};
+
+const TagsInput = React.forwardRef(
+ (
+ {
+ className,
+ defaultValue = [],
+ disabled = false,
+ onBlur,
+ onKeyDown,
+ onValueChange,
+ placeholder = "Add a tag",
+ value,
+ ...props
+ },
+ ref,
+ ) => {
+ const [inputValue, setInputValue] = React.useState("");
+ const { tags, updateTags } = useTagsInputState({
+ defaultValue,
+ onValueChange,
+ value,
+ });
+ const { handleKeyDown, removeTag } = useTagsInputHandlers({
+ disabled,
+ inputValue,
+ onKeyDown,
+ setInputValue,
+ tags,
+ updateTags,
+ });
+
+ return (
+
+
+ {
+ setInputValue(event.target.value);
+ }}
+ onKeyDown={handleKeyDown}
+ placeholder={placeholder}
+ ref={ref}
+ type="text"
+ value={inputValue}
+ />
+
+ );
+ },
+);
+TagsInput.displayName = "TagsInput";
+
+export { TagsInput };
diff --git a/packages/ui/src/components/tags-input/tags-input.visual.tsx b/packages/ui/src/components/tags-input/tags-input.visual.tsx
new file mode 100644
index 0000000..3549627
--- /dev/null
+++ b/packages/ui/src/components/tags-input/tags-input.visual.tsx
@@ -0,0 +1,15 @@
+import { expect, test } from "@playwright/experimental-ct-react";
+
+import { TagsInput } from "./tags-input";
+
+test.describe("TagsInput Visual", () => {
+ test("default tags", async ({ mount, page }) => {
+ await mount(
+
+
+
,
+ );
+
+ await expect(page).toHaveScreenshot("tags-input-default-tags.png");
+ });
+});
diff --git a/packages/ui/src/components/top-bar/index.ts b/packages/ui/src/components/top-bar/index.ts
new file mode 100644
index 0000000..24361d8
--- /dev/null
+++ b/packages/ui/src/components/top-bar/index.ts
@@ -0,0 +1 @@
+export { TopBar, type TopBarProps } from "./top-bar";
diff --git a/packages/ui/src/components/top-bar/top-bar.stories.tsx b/packages/ui/src/components/top-bar/top-bar.stories.tsx
new file mode 100644
index 0000000..2d967f3
--- /dev/null
+++ b/packages/ui/src/components/top-bar/top-bar.stories.tsx
@@ -0,0 +1,30 @@
+import type { Meta, StoryObj } from "@storybook/react-vite";
+
+import { TopBar } from "./top-bar";
+import { WorkspaceSwitcher } from "../workspace-switcher";
+
+const meta = {
+ component: TopBar,
+ render: () => (
+ Open command}
+ >
+
+
+ ),
+ title: "Layout/TopBar",
+} satisfies Meta;
+
+export default meta;
+type Story = StoryObj;
+
+export const Default: Story = {};
diff --git a/packages/ui/src/components/top-bar/top-bar.tsx b/packages/ui/src/components/top-bar/top-bar.tsx
new file mode 100644
index 0000000..4ec2e51
--- /dev/null
+++ b/packages/ui/src/components/top-bar/top-bar.tsx
@@ -0,0 +1,60 @@
+import { forwardRef } from "react";
+
+import type { ReactNode } from "react";
+
+import { cn } from "../../lib/utils";
+
+export type TopBarProps = React.ComponentPropsWithoutRef<"header"> & {
+ leading?: ReactNode;
+ subtitle?: ReactNode;
+ title?: ReactNode;
+ trailing?: ReactNode;
+};
+
+const TopBar = forwardRef(
+ (
+ { children, className, leading, subtitle, title, trailing, ...props },
+ ref,
+ ) => (
+
+ ),
+);
+
+TopBar.displayName = "TopBar";
+
+export { TopBar };
diff --git a/packages/ui/src/components/usage-breakdown/usage-breakdown.tsx b/packages/ui/src/components/usage-breakdown/usage-breakdown.tsx
index f1aeb91..a331d0e 100644
--- a/packages/ui/src/components/usage-breakdown/usage-breakdown.tsx
+++ b/packages/ui/src/components/usage-breakdown/usage-breakdown.tsx
@@ -1,3 +1,5 @@
+"use client";
+
import { forwardRef, type ReactNode, useMemo } from "react";
import { ArrowDownRight, ArrowUpRight } from "lucide-react";
diff --git a/packages/ui/src/components/workspace-switcher/index.ts b/packages/ui/src/components/workspace-switcher/index.ts
new file mode 100644
index 0000000..5b60864
--- /dev/null
+++ b/packages/ui/src/components/workspace-switcher/index.ts
@@ -0,0 +1,5 @@
+export {
+ type WorkspaceOption,
+ WorkspaceSwitcher,
+ type WorkspaceSwitcherProps,
+} from "./workspace-switcher";
diff --git a/packages/ui/src/components/workspace-switcher/workspace-switcher.stories.tsx b/packages/ui/src/components/workspace-switcher/workspace-switcher.stories.tsx
new file mode 100644
index 0000000..c45e0f9
--- /dev/null
+++ b/packages/ui/src/components/workspace-switcher/workspace-switcher.stories.tsx
@@ -0,0 +1,21 @@
+import type { Meta, StoryObj } from "@storybook/react-vite";
+
+import { WorkspaceSwitcher } from "./workspace-switcher";
+
+const meta = {
+ args: {
+ defaultValue: "orchestrate",
+ workspaces: [
+ { description: "Runs and outputs", id: "orchestrate", label: "Orchestrate" },
+ { description: "Object neighborhoods", id: "objects", label: "Objects" },
+ { description: "Telemetry sweep", id: "signals", label: "Signals" },
+ ],
+ },
+ component: WorkspaceSwitcher,
+ title: "Navigation/WorkspaceSwitcher",
+} satisfies Meta;
+
+export default meta;
+type Story = StoryObj;
+
+export const Default: Story = {};
diff --git a/packages/ui/src/components/workspace-switcher/workspace-switcher.test.tsx b/packages/ui/src/components/workspace-switcher/workspace-switcher.test.tsx
new file mode 100644
index 0000000..e97caaa
--- /dev/null
+++ b/packages/ui/src/components/workspace-switcher/workspace-switcher.test.tsx
@@ -0,0 +1,56 @@
+import { fireEvent, render, screen } from "@testing-library/react";
+import { describe, expect, it, vi } from "vitest";
+
+import { WorkspaceSwitcher } from "./workspace-switcher";
+
+const workspaces = [
+ { description: "Runs and outputs", id: "orchestrate", label: "Orchestrate" },
+ { description: "Object neighborhoods", id: "objects", label: "Objects" },
+ { description: "Telemetry sweep", id: "signals", label: "Signals" },
+];
+
+describe("WorkspaceSwitcher", () => {
+ it("selects the first workspace by default", () => {
+ render( );
+
+ expect(screen.getByRole("radio", { name: "Orchestrate" })).toHaveAttribute(
+ "aria-checked",
+ "true",
+ );
+ });
+
+ it("updates internal state when uncontrolled", () => {
+ render( );
+
+ fireEvent.click(screen.getByRole("radio", { name: "Objects" }));
+
+ expect(screen.getByRole("radio", { name: "Objects" })).toHaveAttribute(
+ "aria-checked",
+ "true",
+ );
+ });
+
+ it("calls onValueChange when a workspace is chosen", () => {
+ const onValueChange = vi.fn();
+
+ render(
+ ,
+ );
+
+ fireEvent.click(screen.getByRole("radio", { name: "Signals" }));
+
+ expect(onValueChange).toHaveBeenCalledWith("signals");
+ });
+
+ it("respects a controlled value", () => {
+ render( );
+
+ expect(screen.getByRole("radio", { name: "Objects" })).toHaveAttribute(
+ "aria-checked",
+ "true",
+ );
+ });
+});
diff --git a/packages/ui/src/components/workspace-switcher/workspace-switcher.tsx b/packages/ui/src/components/workspace-switcher/workspace-switcher.tsx
new file mode 100644
index 0000000..f1a2b94
--- /dev/null
+++ b/packages/ui/src/components/workspace-switcher/workspace-switcher.tsx
@@ -0,0 +1,89 @@
+"use client";
+
+import { forwardRef, useMemo, useState } from "react";
+
+import { cn } from "../../lib/utils";
+
+export type WorkspaceOption = {
+ description?: string;
+ id: string;
+ label: string;
+};
+
+export type WorkspaceSwitcherProps = Omit<
+ React.ComponentPropsWithoutRef<"div">,
+ "defaultValue" | "onChange"
+> & {
+ defaultValue?: string;
+ onValueChange?: (value: string) => void;
+ value?: string;
+ workspaces: WorkspaceOption[];
+};
+
+const WorkspaceSwitcher = forwardRef(
+ (
+ { className, defaultValue, onValueChange, value, workspaces, ...props },
+ ref,
+ ) => {
+ const fallbackValue = defaultValue ?? workspaces[0]?.id ?? "";
+ const [internalValue, setInternalValue] = useState(fallbackValue);
+ const currentValue = value ?? internalValue;
+
+ const currentWorkspace = useMemo(
+ () => workspaces.find((workspace) => workspace.id === currentValue),
+ [currentValue, workspaces],
+ );
+
+ function handleSelect(nextValue: string) {
+ if (value === undefined) {
+ setInternalValue(nextValue);
+ }
+ onValueChange?.(nextValue);
+ }
+
+ return (
+
+ {workspaces.map((workspace) => {
+ const isActive = workspace.id === currentValue;
+ return (
+ {
+ handleSelect(workspace.id);
+ }}
+ role="radio"
+ title={workspace.description}
+ type="button"
+ >
+ {workspace.label}
+
+ );
+ })}
+ {currentWorkspace?.description ? (
+
+ {currentWorkspace.description}
+
+ ) : null}
+
+ );
+ },
+);
+
+WorkspaceSwitcher.displayName = "WorkspaceSwitcher";
+
+export { WorkspaceSwitcher };
diff --git a/packages/ui/src/components/zoom-hud/index.ts b/packages/ui/src/components/zoom-hud/index.ts
new file mode 100644
index 0000000..a08c600
--- /dev/null
+++ b/packages/ui/src/components/zoom-hud/index.ts
@@ -0,0 +1 @@
+export { ZoomHUD, type ZoomHUDProps } from "./zoom-hud";
diff --git a/packages/ui/src/components/zoom-hud/zoom-hud.stories.tsx b/packages/ui/src/components/zoom-hud/zoom-hud.stories.tsx
new file mode 100644
index 0000000..a154e53
--- /dev/null
+++ b/packages/ui/src/components/zoom-hud/zoom-hud.stories.tsx
@@ -0,0 +1,16 @@
+import type { Meta, StoryObj } from "@storybook/react-vite";
+
+import { ZoomHUD } from "./zoom-hud";
+
+const meta = {
+ args: {
+ zoom: 1,
+ },
+ component: ZoomHUD,
+ title: "Overlay/ZoomHUD",
+} satisfies Meta;
+
+export default meta;
+type Story = StoryObj;
+
+export const Default: Story = {};
diff --git a/packages/ui/src/components/zoom-hud/zoom-hud.tsx b/packages/ui/src/components/zoom-hud/zoom-hud.tsx
new file mode 100644
index 0000000..e39bdf0
--- /dev/null
+++ b/packages/ui/src/components/zoom-hud/zoom-hud.tsx
@@ -0,0 +1,61 @@
+import { forwardRef } from "react";
+
+import { Minus, Plus, RotateCcw } from "lucide-react";
+
+import { cn } from "../../lib/utils";
+import { Button } from "../button";
+
+export type ZoomHUDProps = React.ComponentPropsWithoutRef<"div"> & {
+ onReset?: () => void;
+ onZoomIn?: () => void;
+ onZoomOut?: () => void;
+ zoom: number;
+};
+
+const ZoomHUD = forwardRef(
+ ({ className, onReset, onZoomIn, onZoomOut, zoom, ...props }, ref) => (
+
+
+
+
+
+ {Math.round(zoom * 100)}%
+
+
+
+
+
+
+
+
+ ),
+);
+
+ZoomHUD.displayName = "ZoomHUD";
+
+export { ZoomHUD };
diff --git a/specs/active/2026-04-21-forms-family-batch-2.md b/specs/active/2026-04-21-forms-family-batch-2.md
new file mode 100644
index 0000000..4161b42
--- /dev/null
+++ b/specs/active/2026-04-21-forms-family-batch-2.md
@@ -0,0 +1,95 @@
+# Forms family batch 2
+
+## Goal
+Implement the remaining forms-family component issues in the `forms-family-batch-2` worktree:
+- #47 Form — Validation Wrapper
+- #53 MultiSelect
+- #36 TagsInput
+- #39 SegmentedControl
+
+## Non-goals
+- No commits, pushes, or PR creation in this pass
+- No unrelated refactors outside forms-family scope
+- No Storybook infra changes beyond what the new components require
+
+## Constraints
+- Repo: `/home/ubuntu/ui`
+- Worktree: `/home/ubuntu/ui/.worktrees/forms-family-batch-2`
+- Branch: `feat/forms-family-batch-2`
+- Base: `feat/storybook`
+- Follow repo-local `CLAUDE.md`
+- Use existing component patterns in `packages/ui/src/components/`
+- Include package component, exports, unit test, visual test, Storybook story, MDX docs, registry shim, registry entry, and preview wiring for each new component
+- No `any`, `as`, `@ts-ignore`, `@ts-expect-error`, or `eslint-disable`
+- Do not add new package dependencies unless a blocker makes it unavoidable; default path is to compose existing Radix / repo primitives only
+- If Storybook sync or visual baselines generate artifacts needed for green validation, include those generated files in the worktree changes
+
+## Likely files
+- `packages/ui/src/components/form/form.tsx`
+- `packages/ui/src/components/form/form.test.tsx`
+- `packages/ui/src/components/form/form.visual.tsx`
+- `packages/ui/src/components/form/form.stories.tsx`
+- `packages/ui/src/components/form/form.mdx`
+- `packages/ui/src/components/form/index.ts`
+- `packages/ui/src/components/multi-select/multi-select.tsx`
+- `packages/ui/src/components/multi-select/multi-select.test.tsx`
+- `packages/ui/src/components/multi-select/multi-select.visual.tsx`
+- `packages/ui/src/components/multi-select/multi-select.stories.tsx`
+- `packages/ui/src/components/multi-select/multi-select.mdx`
+- `packages/ui/src/components/multi-select/index.ts`
+- `packages/ui/src/components/tags-input/tags-input.tsx`
+- `packages/ui/src/components/tags-input/tags-input.test.tsx`
+- `packages/ui/src/components/tags-input/tags-input.visual.tsx`
+- `packages/ui/src/components/tags-input/tags-input.stories.tsx`
+- `packages/ui/src/components/tags-input/tags-input.mdx`
+- `packages/ui/src/components/tags-input/index.ts`
+- `packages/ui/src/components/segmented-control/segmented-control.tsx`
+- `packages/ui/src/components/segmented-control/segmented-control.test.tsx`
+- `packages/ui/src/components/segmented-control/segmented-control.visual.tsx`
+- `packages/ui/src/components/segmented-control/segmented-control.stories.tsx`
+- `packages/ui/src/components/segmented-control/segmented-control.mdx`
+- `packages/ui/src/components/segmented-control/index.ts`
+- `packages/ui/src/components/index.ts`
+- `packages/ui/src/index.ts`
+- `apps/registry/registry/default/{component}/{component}.tsx`
+- generated docs / preview artifacts from Storybook sync if validation requires them
+- `apps/registry/registry.json`
+- `apps/registry/components/component-preview/component-preview.tsx`
+
+## Acceptance criteria
+- AC-47.1: `Form` is implemented as a lightweight presentational validation wrapper using existing repo / Radix primitives only, with no new dependency required for core use.
+- AC-47.2: `Form` exposes a clear API for label, description, control, and message composition, plus invalid state wiring via ARIA attributes.
+- AC-47.3: `Form` ships with tests, Storybook coverage, visual coverage, and MDX docs demonstrating labels, descriptions, messages, and validation states.
+- AC-53.1: `MultiSelect` supports controlled and uncontrolled multi-selection, disabled state, keyboard access, and selected-value display.
+- AC-53.2: `MultiSelect` uses the existing repo primitive stack for its minimum viable interaction model; search/filter is optional and only included if it fits without widening scope.
+- AC-53.3: `MultiSelect` ships with tests, Storybook coverage, visual coverage, and MDX docs for option rendering and selection behavior.
+- AC-36.1: `TagsInput` supports controlled and uncontrolled tag arrays, add/remove flows from keyboard input, and disabled state.
+- AC-36.2: `TagsInput` ships with tests, Storybook coverage, visual coverage, and MDX docs for add/remove flows.
+- AC-39.1: `SegmentedControl` supports controlled and uncontrolled single-choice segmented selection with accessible keyboard navigation and disabled state.
+- AC-39.2: `SegmentedControl` composes or closely follows existing toggle-group patterns instead of inventing a divergent primitive without reason.
+- AC-39.3: `SegmentedControl` ships with tests, Storybook coverage, visual coverage, and MDX docs for controlled selection.
+- AC-A11Y.1: Each component exposes accessible roles / ARIA behavior appropriate to its interaction model and includes targeted test assertions for that behavior.
+- AC-ALL.1: Each component is exported from package entrypoints and available through registry shims.
+- AC-ALL.2: Each component is added to registry metadata with an appropriate form-related category and preview wiring.
+- AC-ALL.3: Storybook sync / registry build outputs required for passing validation are updated in the worktree.
+- AC-ALL.4: Validation passes or skip reasons are explicitly documented.
+
+## Validation commands
+- `pnpm -F @vllnt/ui lint`
+- `pnpm exec tsc --noEmit -p packages/ui/tsconfig.json`
+- `pnpm -F @vllnt/ui test:once`
+- `pnpm -F @vllnt/ui build`
+- `pnpm -F @vllnt/ui build-storybook`
+- `pnpm -F @vllnt/ui test:visual` (or `test:visual:update` first if new baselines are required)
+- `pnpm -F @vllnt/ui-registry sync-storybook`
+- `pnpm -F @vllnt/ui-registry registry:build`
+- `pnpm -F @vllnt/ui-registry build`
+
+## Task slices
+- Slice 1: discover existing form-adjacent patterns and choose implementation shape for all 4 components
+- Slice 2: implement `Form`
+- Slice 3: implement `MultiSelect`
+- Slice 4: implement `TagsInput`
+- Slice 5: implement `SegmentedControl`
+- Slice 6: wire exports, registry entries, and preview support
+- Slice 7: run validation, review findings, and fix blockers
diff --git a/specs/shipped/2026-04-21-canvas-foundation-family.md b/specs/shipped/2026-04-21-canvas-foundation-family.md
new file mode 100644
index 0000000..d9a660f
--- /dev/null
+++ b/specs/shipped/2026-04-21-canvas-foundation-family.md
@@ -0,0 +1,76 @@
+# Canvas foundation family
+
+## Goal
+Implement issue #132: **Infinite canvas foundation & operator chrome — shell, rail, dock, viewport**.
+
+## Worktree context
+- Repo: `/home/ubuntu/ui`
+- Worktree: `/home/ubuntu/ui/.worktrees/canvas-foundation-family`
+- Branch: `feat/canvas-foundation-family`
+- Base: `feat/storybook`
+
+## Product direction
+This family must create a **calm operating surface** and **minimal spatial control plane**.
+
+It should feel like:
+- a calm operating surface
+- a spatial workspace for real AI objects
+- a minimal control plane for tasks, runs, and outputs
+
+It must not feel like:
+- a bloated dashboard
+- a pure whiteboard
+- a chatbot wrapper
+
+## Minimum serious v0 ownership
+- `TopBar`
+- `LeftRail`
+- `CanvasView`
+- `RightDock`
+- `WorkspaceSwitcher`
+- `CanvasShell`
+- `InfinitePlane`
+- `MiniMapPanel`
+- `ZoomHUD`
+- `ViewportBookmarks`
+- `WorldBreadcrumbs`
+
+## Explicit non-duplicates
+Do not duplicate or merely re-skin:
+- `FlowDiagram`
+- `NavbarSaas`
+- `Sidebar` / `SidebarToggle`
+- `ViewSwitcher` — extend/compose instead of fork if useful
+- `SearchBar` / `SearchDialog`
+- `TableOfContentsPanel`
+- maps family issues (`#28`, `#29`, `#30`, `#31`, `#60`, `#61`, `#62`, `#68`)
+- `AI Artifact / Canvas` issue `#56`
+- `Command / Spotlight` issue `#8` and existing `Command`
+- internal behavior of `BottomActivityStrip` — hosted here, owned by runtime family `#136`
+
+## Acceptance criteria
+- define operator chrome: top bar, left rail, right dock, and workspace switching behavior
+- define viewport model, panning/zoom, saved views, and minimap behavior
+- define keyboard + pointer interaction rules for canvas movement
+- define how command palette access fits into the shell without adding clutter
+- define how this family hosts runtime/activity surfaces from adjacent families without absorbing them
+- stay product-canvas oriented, not diagram-editor generic
+
+## Likely files
+- `packages/ui/src/components/canvas-shell/*`
+- `packages/ui/src/components/top-bar/*`
+- `packages/ui/src/components/left-rail/*`
+- `packages/ui/src/components/right-dock/*`
+- `packages/ui/src/components/workspace-switcher/*`
+- `packages/ui/src/components/mini-map-panel/*`
+- `packages/ui/src/components/zoom-hud/*`
+- registry shims and preview wiring for each shipped component
+
+## Validation gates
+- `pnpm -F @vllnt/ui lint`
+- `pnpm -F @vllnt/ui test:once`
+- `pnpm -F @vllnt/ui build`
+- `pnpm -F @vllnt/ui build-storybook`
+- `pnpm -F @vllnt/ui-registry sync-storybook`
+- `pnpm -F @vllnt/ui-registry registry:build`
+- `pnpm -F @vllnt/ui-registry build`
diff --git a/specs/shipped/2026-04-21-canvas-objects-family.md b/specs/shipped/2026-04-21-canvas-objects-family.md
new file mode 100644
index 0000000..4ef2b7d
--- /dev/null
+++ b/specs/shipped/2026-04-21-canvas-objects-family.md
@@ -0,0 +1,52 @@
+# Canvas objects family
+
+## Goal
+Implement issue #133: **Spatial objects & durable views — object card, edges, groups, ports**.
+
+## Worktree context
+- Repo: `/home/ubuntu/ui`
+- Worktree: `/home/ubuntu/ui/.worktrees/canvas-objects-family`
+- Branch: `feat/canvas-objects-family`
+- Base: `feat/storybook`
+
+## Product direction
+Build durable AI/runtime object views rather than generic dashboard cards.
+
+## Minimum serious v0 ownership
+- `ObjectCard`
+- `ConnectorEdge`
+- `EdgeLabel`
+- `GroupHull`
+- `AnchorPort`
+- `ObjectHandle`
+
+## Explicit non-duplicates
+Do not duplicate or merely re-skin:
+- existing `Card` variants (`Card`, `StatCard`, `ProgressCard`, `SubscriptionCard`, `WalletCard`, `TutorialCard`)
+- `FlowDiagram`
+- timeline family issues (`#32`, `#33`, `#34`, `#35`, `#63`, `#64`)
+- `AI Artifact / Canvas` issue `#56` — compose with it rather than absorb it
+
+## Acceptance criteria
+- define a product-object contract for cards, edges, handles, and groups
+- define how objects expose live state, metadata, and action affordances
+- define grouping and connection semantics for runs, artifacts, tasks, agents, and outputs
+- bias toward durable object views rather than generic canvas decoration
+
+## Likely files
+- `packages/ui/src/components/object-card/*`
+- `packages/ui/src/components/connector-edge/*`
+- `packages/ui/src/components/edge-label/*`
+- `packages/ui/src/components/group-hull/*`
+- `packages/ui/src/components/anchor-port/*`
+- `packages/ui/src/components/object-handle/*`
+- registry shims and preview wiring for each shipped component
+
+## Validation gates
+- `pnpm -F @vllnt/ui lint`
+- `pnpm -F @vllnt/ui test:once`
+- `pnpm -F @vllnt/ui build`
+- `pnpm -F @vllnt/ui build-storybook`
+- `pnpm -F @vllnt/ui-registry sync-storybook`
+- `pnpm -F @vllnt/ui-registry registry:build`
+- `pnpm -F @vllnt/ui-registry build`
diff --git a/specs/shipped/2026-04-21-registry-preview-theme-modes.md b/specs/shipped/2026-04-21-registry-preview-theme-modes.md
new file mode 100644
index 0000000..92aac2e
--- /dev/null
+++ b/specs/shipped/2026-04-21-registry-preview-theme-modes.md
@@ -0,0 +1,46 @@
+# Registry preview theme modes
+
+## Goal
+Add dark and light preview handling to the ui registry component preview so the embedded Storybook preview can be viewed in either theme from the registry page.
+
+## Non-goals
+- No changes to package component implementations
+- No broad Storybook theme system rewrite
+- No unrelated registry layout refactor
+
+## Constraints
+- Repo: `/home/ubuntu/ui`
+- Worktree: `/home/ubuntu/ui/.worktrees/registry-preview-theme-modes`
+- Branch: `feat/registry-preview-theme-modes`
+- Base: `feat/storybook`
+- Follow repo-local `CLAUDE.md`
+- Stay inside registry / Storybook embed scope only
+- No new dependencies unless a real blocker appears
+
+## Problem statement
+The registry component page embeds Storybook in a single iframe URL with no explicit theme control. The registry app itself supports dark/light themes, but the embedded preview does not reliably expose or follow both modes from the component page.
+
+## Acceptance criteria
+- AC-1: The registry component preview exposes clear dark/light theme controls on the component page.
+- AC-2: The embedded Storybook iframe updates to the selected theme through the iframe URL contract, using Storybook globals in the iframe URL rather than cross-origin postMessage state sync.
+- AC-3: The preview defaults to the current resolved registry theme on first useful client render and avoids obvious theme mismatch behavior.
+- AC-4: The solution works for component pages that already embed Storybook and does not widen scope to unrelated pages.
+- AC-5: The implementation remains compatible with the existing Storybook addon-themes setup and does not require a Storybook config rewrite.
+- AC-6: Validation covers the UI change with lint/build plus explicit manual verification notes if no dedicated registry UI test harness exists.
+
+## Likely files
+- `apps/registry/components/storybook-embed/storybook-embed.tsx`
+- `apps/registry/app/components/[slug]/page.tsx`
+- possible shared preview control component under `apps/registry/components/`
+- possible targeted test file under `apps/registry/components/storybook-embed/` if test infra exists there
+
+## Validation commands
+- `pnpm -F @vllnt/ui-registry lint`
+- `pnpm -F @vllnt/ui-registry build`
+- if a targeted test is added, run that test command too
+- manual verification: confirm the component page preview can switch between light and dark and the standalone Storybook link can preserve the selected preview theme if we choose to propagate it
+
+## Task slices
+- Slice 1: inspect current Storybook embed + registry theme wiring and choose the URL/global theme contract
+- Slice 2: implement theme-aware embed controls and initial theme behavior
+- Slice 3: validate lint/build and fix blockers
diff --git a/specs/shipped/2026-04-22-canvas-glass-shell-routing.md b/specs/shipped/2026-04-22-canvas-glass-shell-routing.md
new file mode 100644
index 0000000..c8f56a7
--- /dev/null
+++ b/specs/shipped/2026-04-22-canvas-glass-shell-routing.md
@@ -0,0 +1,425 @@
+# Canvas glass shell + routed bars implementation plan
+
+> **For Hermes:** Use subagent-driven-development skill to implement this plan task-by-task.
+
+**Goal:** Turn the current canvas foundation into a configurable four-sided shell with routed dynamic panels, a calm overview landing state, and floating Apple-glass-inspired chrome.
+
+**Architecture:** Keep the system simple: one `CanvasShell` owns spatial layout, each side surface is a configurable slot, and route-aware composition happens outside the primitive in a small shell config layer. The center stays the primary work surface; sidebars are independent floating surfaces above the canvas, not fused into a dashboard frame.
+
+**Tech Stack:** React 19, TypeScript, Tailwind, existing `CanvasShell` / `CanvasView` / `TopBar` / `LeftRail` / `RightDock` primitives, Storybook visual tests.
+
+---
+
+## What exists now
+
+Current family already provides:
+- `CanvasShell` with `topBar`, `leftRail`, `rightDock`, `bottomSlot`
+- `CanvasView` for viewport pan/zoom
+- `TopBar`, `LeftRail`, `RightDock`, `MiniMapPanel`, `ZoomHUD`, `WorkspaceSwitcher`
+- demo composition in `packages/ui/src/components/canvas-shell/canvas-foundation-demo.tsx`
+
+Current gaps versus the requested product:
+- shell surfaces are welded to the frame instead of floating independently
+- no formal route-aware config model for swapping bar content
+- no canonical bottom bar component, only `bottomSlot`
+- center landing state is still loose cards on an infinite field, not a clear system overview
+- no explicit glass theme tokens / wrappers for shell chrome
+- no opinionated place for chat
+
+## Product decisions
+
+### 1. Keep the shell primitive dumb
+Do not make `CanvasShell` know routing.
+
+Instead:
+- `CanvasShell` stays a layout primitive with configurable slots
+- add a small routed composition layer that maps route -> shell config
+- app/router code chooses which bars to render
+
+This keeps `@vllnt/ui` portable and understandable.
+
+### 2. Use four explicit side surfaces
+Treat the shell as:
+- top bar
+- left bar
+- right bar
+- bottom bar
+- middle canvas
+
+Replace `bottomSlot` naming with a real bottom bar API while keeping backward compatibility during migration.
+
+### 3. Make shell chrome float above the canvas
+Apple-glass direction should mean:
+- bars are visually separate surfaces
+- subtle translucency
+- soft border
+- backdrop blur
+- independent corner radii and spacing
+- visible breathing room between bars and canvas edges
+
+Do **not** make the whole app a giant glass blob.
+Use small floating surfaces, not one fused glass rectangle.
+
+### 4. Center default state should be an overview board, not a whiteboard
+When the user lands with no focused object, the center should show a structured overview cluster:
+- inbox items
+- actions awaiting review
+- errors / incidents count
+- stale runs / failing jobs
+- tasks ahead / today focus
+- quick entry points
+
+This should feel like a calm mission control overview.
+Not a dense dashboard grid.
+Not draggable cards by default.
+
+### 5. Chat belongs in the right bar
+Default recommendation:
+- chat is a right-bar section or tab
+- object context / task context / logs can sit under or alongside it
+- chat should not occupy the center by default
+- chat should not live in the top bar
+
+Reason:
+- center is for primary work and overview
+- right bar is for contextual assistance and drill-down
+
+---
+
+## Proposed component model
+
+### Keep and evolve
+- `CanvasShell`
+- `CanvasView`
+- `TopBar`
+- `LeftRail`
+- `RightDock`
+- `MiniMapPanel`
+- `ZoomHUD`
+- `WorkspaceSwitcher`
+
+### Add
+- `BottomBar`
+- `GlassPanel`
+- `OverviewBoard`
+- `OverviewCard`
+- `ChatDockSection`
+- `CanvasShellRouteConfig` types/helpers
+- `CanvasShellFrame` or `OperatorCanvasShell` composition component
+
+### Recommended ownership split
+
+#### Primitive layer in `@vllnt/ui`
+Pure presentational/layout primitives:
+- `CanvasShell`
+- `TopBar`
+- `LeftRail`
+- `RightDock`
+- `BottomBar`
+- `GlassPanel`
+- `OverviewBoard`
+- `OverviewCard`
+- `ChatDockSection` (optional if kept generic enough)
+
+#### Composition layer in app/registry or app code
+Route-aware logic:
+- map pathname -> active shell config
+- decide which left/right/bottom panels are shown
+- choose overview vs object-focused center content
+
+---
+
+## File plan
+
+### Core primitive updates
+- Modify: `packages/ui/src/components/canvas-shell/canvas-shell.tsx`
+- Create: `packages/ui/src/components/bottom-bar/bottom-bar.tsx`
+- Create: `packages/ui/src/components/glass-panel/glass-panel.tsx`
+- Modify: `packages/ui/src/components/top-bar/top-bar.tsx`
+- Modify: `packages/ui/src/components/left-rail/left-rail.tsx`
+- Modify: `packages/ui/src/components/right-dock/right-dock.tsx`
+- Modify: `packages/ui/src/components/canvas-view/canvas-view.tsx`
+
+### Overview / landing state
+- Create: `packages/ui/src/components/overview-board/overview-board.tsx`
+- Create: `packages/ui/src/components/overview-card/overview-card.tsx`
+
+### Chat
+- Create: `packages/ui/src/components/chat-dock-section/chat-dock-section.tsx`
+
+### Types / composition helpers
+- Create: `packages/ui/src/components/canvas-shell/canvas-shell-route-config.ts`
+- Possibly create: `packages/ui/src/components/canvas-shell/operator-canvas-shell.tsx`
+
+### Demo / stories / tests
+- Modify: `packages/ui/src/components/canvas-shell/canvas-foundation-demo.tsx`
+- Modify: `packages/ui/src/components/canvas-shell/canvas-shell.stories.tsx`
+- Modify: `packages/ui/src/components/canvas-shell/canvas-shell.visual.tsx`
+- Add story/test files for new components
+
+---
+
+## Design rules for the new shell
+
+### Layout rules
+- outer shell provides full available area
+- canvas remains the only element that owns the full middle plane
+- bars float inside shell padding above the canvas
+- bars do not touch each other unless intentional in a narrow layout
+- each bar can be omitted independently
+
+### Spacing rules
+Recommended default inset spacing:
+- `top`: 16px from shell edge
+- `left/right`: 16px from shell edge
+- `bottom`: 16px from shell edge
+- `top` to `left/right` should visually stack with 12–16px gap
+
+### Glass rules
+Use glass sparingly:
+- background: `bg-background/70` or `bg-background/60`
+- blur: `backdrop-blur-xl`
+- border: `border border-border/60`
+- shadow: small soft shadow only
+- radius: one consistent medium radius token
+
+Avoid:
+- heavy opacity tricks
+- saturated gradients everywhere
+- giant shadow stacks
+- nested glass inside glass unless one is clearly subordinate
+
+### Overview rules
+Overview center should be:
+- one primary board cluster centered in viewport
+- 4–6 cards max in default state
+- grouped by urgency / status, not random masonry
+- obvious click targets into actual work routes
+
+Suggested cards:
+- `Inbox`
+- `Awaiting action`
+- `Errors`
+- `Runs at risk`
+- `Today`
+- `Recent changes`
+
+### Dynamic routing rules
+Example route-to-shell config:
+- `/app` or `/workspace`:
+ - top bar: workspace + global actions
+ - left bar: mode switcher
+ - right bar: chat + context
+ - bottom bar: status / activity / command hints
+ - center: overview board
+- `/workspace/objects/:id`:
+ - center: object scene / detail workspace
+ - right bar: object context + chat
+- `/workspace/runs/:id`:
+ - center: run timeline or output
+ - right bar: logs + chat
+
+The key rule:
+- route changes shell content
+- route does not change the shell mental model
+
+---
+
+## Recommended implementation sequence
+
+### Task 1: Refactor `CanvasShell` API for four explicit sides
+**Objective:** Make the shell formally support top, left, right, and bottom bars as first-class regions.
+
+**Files:**
+- Modify: `packages/ui/src/components/canvas-shell/canvas-shell.tsx`
+- Test/story files nearby
+
+**Steps:**
+1. Add a `bottomBar` prop.
+2. Keep `bottomSlot` temporarily as deprecated compatibility input.
+3. Normalize rendering so `bottomBar ?? bottomSlot` is used.
+4. Add shell padding/inset support so floating chrome becomes possible.
+5. Keep behavior backward compatible.
+
+### Task 2: Add `GlassPanel`
+**Objective:** Create one reusable glass wrapper for all floating shell chrome.
+
+**Files:**
+- Create: `packages/ui/src/components/glass-panel/glass-panel.tsx`
+- Create stories/tests
+
+**Steps:**
+1. Build a generic panel with consistent radius, border, blur, and translucent background.
+2. Support size/className only; avoid variants until needed.
+3. Use it in demo wrappers for shell bars.
+
+### Task 3: Introduce a real `BottomBar`
+**Objective:** Replace ad hoc bottom content with a clear bottom-bar primitive.
+
+**Files:**
+- Create: `packages/ui/src/components/bottom-bar/bottom-bar.tsx`
+- Add stories/tests
+
+**Steps:**
+1. Support left, center, right content zones.
+2. Make it readable with short status items and quick actions.
+3. Keep it compact.
+
+### Task 4: Make bars float independently
+**Objective:** Move top/left/right/bottom bars visually off the shell edges and onto floating surfaces.
+
+**Files:**
+- Modify: `canvas-shell.tsx`
+- Modify: `top-bar.tsx`
+- Modify: `left-rail.tsx`
+- Modify: `right-dock.tsx`
+- Modify: `bottom-bar.tsx`
+
+**Steps:**
+1. Add an inner absolute/floating chrome layer over the canvas.
+2. Keep center canvas full-size under it.
+3. Ensure side surfaces remain pointer-interactive.
+4. Avoid overcomplicated responsive behavior in v1.
+
+### Task 5: Add `OverviewBoard` + `OverviewCard`
+**Objective:** Define the default landing state for the center canvas.
+
+**Files:**
+- Create: `overview-board.tsx`
+- Create: `overview-card.tsx`
+- Add stories/tests
+
+**Steps:**
+1. Build a centered responsive cluster.
+2. Cards accept title, count, tone, description, and CTA.
+3. Keep copy terse and operational.
+
+### Task 6: Add routed shell config types
+**Objective:** Create a simple config model for route-driven shell composition.
+
+**Files:**
+- Create: `packages/ui/src/components/canvas-shell/canvas-shell-route-config.ts`
+
+**Suggested type shape:**
+```ts
+export type CanvasShellRouteConfig = {
+ bottomBar?: React.ReactNode;
+ center: React.ReactNode;
+ leftBar?: React.ReactNode;
+ rightBar?: React.ReactNode;
+ topBar?: React.ReactNode;
+};
+```
+
+Optional helper:
+```ts
+export function resolveCanvasShellRouteConfig(route: string): CanvasShellRouteConfig
+```
+
+Do not bake router dependencies into `@vllnt/ui`.
+Pass route strings or resolved config from app code.
+
+### Task 7: Add `ChatDockSection`
+**Objective:** Establish the simplest correct chat placement.
+
+**Files:**
+- Create: `packages/ui/src/components/chat-dock-section/chat-dock-section.tsx`
+
+**Rules:**
+- header with assistant/context label
+- message list area
+- compact composer
+- optional context badge for selected object/run
+- designed for right-dock embedding
+
+### Task 8: Rebuild demo around the new model
+**Objective:** Make the demo reflect the intended product, not just primitive existence.
+
+**Files:**
+- Modify: `packages/ui/src/components/canvas-shell/canvas-foundation-demo.tsx`
+
+**New demo should show:**
+- floating top, left, right, bottom bars
+- center overview board on landing
+- right dock with chat section + context section
+- bottom bar with status / errors / quick actions
+- glass treatment on shell chrome only
+
+### Task 9: Update visual tests
+**Objective:** Lock the new shell direction with screenshot coverage.
+
+**Files:**
+- Modify: `canvas-shell.visual.tsx`
+- Add visuals for overview board and bottom bar if needed
+
+### Task 10: Registry/demo docs
+**Objective:** Make the pattern discoverable and understandable.
+
+**Files:**
+- Stories / registry files for new components
+
+**Docs should explain:**
+- shell is route-configured
+- center is overview by default
+- chat belongs in right dock
+- bars are floating glass chrome, not fused sidebars
+
+---
+
+## Simple recommendation on chat
+
+### Best default
+Put chat in the **right bar**.
+
+### Why
+- it stays contextual
+- it does not steal the center canvas
+- it stays near object/run/task detail
+- it can collapse without changing the core spatial model
+
+### Simple structure
+Right bar sections in order:
+1. `Chat`
+2. `Selected context` or `Object focus`
+3. `Actions / logs / notes`
+
+That is easier to understand than making chat a center-card or a bottom console.
+
+---
+
+## Simple recommendation on overview landing
+
+When no specific route target is selected, center content should be a board like:
+- `Inbox` — pending items
+- `Awaiting action` — approvals / reviews
+- `Errors` — failures needing attention
+- `Runs` — unhealthy or stale runs
+- `Today` — prioritized tasks
+
+Each card opens a route or narrows focus.
+
+This gives the user immediate operational orientation.
+
+---
+
+## Validation gates
+Run after implementation:
+- `pnpm -F @vllnt/ui exec eslint src/components/canvas-shell src/components/top-bar src/components/left-rail src/components/right-dock src/components/canvas-view src/components/bottom-bar src/components/glass-panel src/components/overview-board src/components/chat-dock-section`
+- `pnpm -F @vllnt/ui test:once`
+- `pnpm -F @vllnt/ui test:visual`
+- `pnpm -F @vllnt/ui build`
+
+If registry wiring is updated:
+- `pnpm -F @vllnt/ui-registry build`
+
+---
+
+## Bottom line
+The right move is **not** to make the current shell more dashboard-like.
+The right move is:
+- keep one clear shell mental model
+- make all four bars first-class and configurable
+- make those bars route-driven in composition, not in the primitive
+- use floating glass chrome around a calm center canvas
+- make the center landing state an overview board
+- place chat in the right bar