Track HiveGrid Cloud planning reference

This commit is contained in:
david committed 2026-06-26 20:48:01 +02:00
1 parent b3d5ea6764
commit e341c5938a
5784 files changed
+1104929 -3

No files matched your search

+41
View File
@@ -0,0 +1,41 @@
# @toolctrl/ui
Source-only UI adapter boundary for future primitive migration.
This package intentionally does not replace existing app imports yet. Runtime components remain in `apps/web/app` until a category migration explicitly moves them. The adapter contracts live in `@toolctrl/ui/*` so agents can target stable names before runtime imports are migrated.
## Categories
| Category | Export | Intended ownership |
| --- | --- | --- |
| `buttons` | `@toolctrl/ui/buttons` | Button-like actions, icon buttons and grouped controls. |
| `inputs` | `@toolctrl/ui/inputs` | Text fields, selects, checkboxes, switches and form-adjacent controls. |
| `dialogs` | `@toolctrl/ui/dialogs` | Modals, alert dialogs, drawers, popovers and confirmation surfaces. |
| `tables` | `@toolctrl/ui/tables` | Data tables, row actions, pagination and empty states for tabular data. |
| `layout` | `@toolctrl/ui/layout` | Page shells, panels, stacks, grids, containers and spacing primitives. |
| `navigation` | `@toolctrl/ui/navigation` | Menus, tabs, breadcrumbs, sidebars and command/navigation affordances. |
| `feedback` | `@toolctrl/ui/feedback` | Toasts, alerts, progress, skeletons, badges and status indicators. |
## Current Usage
The package is runtime-safe and still source-only. `buttons`, `layout`, and `navigation` expose available adapter metadata for the current app-level implementation:
```ts
import { uiAdapterCategories } from "@toolctrl/ui";
import { buttonAdapters } from "@toolctrl/ui/buttons";
import { layoutAdapters } from "@toolctrl/ui/layout";
import { navigationAdapters } from "@toolctrl/ui/navigation";
```
`buttonAdapters` describes `Button` and `buttonVariants`. `layoutAdapters` describes `ShadcnSidebarLayout` and `UiRegistry`. `navigationAdapters` describes `AppSidebar`, `AdminSidebar`, `DocsSidebar`, `NavMain`, `NavSecondary`, `NavQuickLinks`, `NavActions`, and `SidebarPrimitives`.
These contracts do not render components, import React or alter app runtime behavior yet. Other category exports remain empty readonly registries until their migration phase.
## Migration Guidance
1. Keep current `apps/web/app/components` imports in place until a migration phase owns that category.
2. Migrate one category at a time, starting with leaf primitives that have low app-specific coupling.
3. Add concrete component exports to the matching category subpath, then re-export from `@toolctrl/ui` only when the API is stable.
4. Keep category exports backward-compatible once app code starts importing them.
See [docs/migration.md](./docs/migration.md) for the category migration checklist.
@@ -0,0 +1,25 @@
# UI Adapter Migration
`@toolctrl/ui` is the package boundary for future UI primitive ownership. Phase 11 only establishes the boundary; it does not migrate existing app imports.
## Category Checklist
For each category:
1. Inventory current app-level primitives and third-party dependencies.
2. Pick a stable export name and props contract before moving implementation.
3. Add the implementation under the matching `packages/ui/src/<category>.ts` entrypoint or a category-owned folder.
4. Add package-level tests or type assertions when the export becomes non-trivial.
5. Migrate app imports in a separate phase after the package API is reviewed.
## Boundaries
- `buttons`
- `inputs`
- `dialogs`
- `tables`
- `layout`
- `navigation`
- `feedback`
Do not mix app route behavior, data loading or feature-specific orchestration into this package. Keep this layer focused on reusable UI primitives and adapter contracts.
+24
View File
@@ -0,0 +1,24 @@
{
"name": "@toolctrl/ui",
"version": "0.0.0",
"private": true,
"type": "module",
"sideEffects": false,
"exports": {
".": "./src/index.ts",
"./buttons": "./src/buttons.ts",
"./inputs": "./src/inputs.ts",
"./dialogs": "./src/dialogs.ts",
"./tables": "./src/tables.ts",
"./layout": "./src/layout.ts",
"./navigation": "./src/navigation.ts",
"./feedback": "./src/feedback.ts"
},
"scripts": {
"build": "pnpm run typecheck",
"typecheck": "tsc -p tsconfig.json --noEmit"
},
"devDependencies": {
"typescript": "^5.3.2"
}
}
+23
View File
@@ -0,0 +1,23 @@
import type { UiAdapterDefinition } from "./types.js";
export const buttonAdapterNames = ["Button", "buttonVariants"] as const;
export type ButtonAdapterName = (typeof buttonAdapterNames)[number];
export type ButtonAdapter = UiAdapterDefinition<"buttons"> & {
readonly status: "available";
readonly name: ButtonAdapterName;
readonly source: "apps/web/app/components/ui/button.tsx";
readonly implementation: "shadcn/radix";
readonly packageExport: "@toolctrl/ui/buttons";
};
export const buttonAdapters = buttonAdapterNames.map((name) => ({
category: "buttons",
name,
status: "available",
source: "apps/web/app/components/ui/button.tsx",
implementation: "shadcn/radix",
packageExport: "@toolctrl/ui/buttons",
description: name === "Button" ? "Primary application button primitive adapter target." : "Button variant class generator adapter target.",
})) satisfies readonly ButtonAdapter[];
@@ -0,0 +1,5 @@
import { createEmptyAdapterRegistry, type UiAdapterPlaceholder } from "./types.js";
export type DialogAdapter = UiAdapterPlaceholder<"dialogs">;
export const dialogAdapters = createEmptyAdapterRegistry("dialogs");
@@ -0,0 +1,5 @@
import { createEmptyAdapterRegistry, type UiAdapterPlaceholder } from "./types.js";
export type FeedbackAdapter = UiAdapterPlaceholder<"feedback">;
export const feedbackAdapters = createEmptyAdapterRegistry("feedback");
+17
View File
@@ -0,0 +1,17 @@
export { createEmptyAdapterRegistry, isUiAdapterCategory, uiAdapterCategories } from "./types.js";
export type { UiAdapterCategory, UiAdapterDefinition, UiAdapterLifecycle, UiAdapterPlaceholder, UiAdapterRegistry } from "./types.js";
export { buttonAdapterNames, buttonAdapters } from "./buttons.js";
export type { ButtonAdapter, ButtonAdapterName } from "./buttons.js";
export { inputAdapters } from "./inputs.js";
export type { InputAdapter } from "./inputs.js";
export { dialogAdapters } from "./dialogs.js";
export type { DialogAdapter } from "./dialogs.js";
export { tableAdapters } from "./tables.js";
export type { TableAdapter } from "./tables.js";
export { layoutAdapterNames, layoutAdapters } from "./layout.js";
export type { LayoutAdapter, LayoutAdapterName } from "./layout.js";
export { navigationAdapterNames, navigationAdapters } from "./navigation.js";
export type { NavigationAdapter, NavigationAdapterName } from "./navigation.js";
export { feedbackAdapters } from "./feedback.js";
export type { FeedbackAdapter } from "./feedback.js";
+5
View File
@@ -0,0 +1,5 @@
import { createEmptyAdapterRegistry, type UiAdapterPlaceholder } from "./types.js";
export type InputAdapter = UiAdapterPlaceholder<"inputs">;
export const inputAdapters = createEmptyAdapterRegistry("inputs");
+31
View File
@@ -0,0 +1,31 @@
import type { UiAdapterDefinition } from "./types.js";
export const layoutAdapterNames = ["ShadcnSidebarLayout", "UiRegistry"] as const;
export type LayoutAdapterName = (typeof layoutAdapterNames)[number];
const layoutAdapterSources = {
ShadcnSidebarLayout: "apps/web/app/components/layouts/sidebars/shadcn/ShadcnSidebarLayout.tsx",
UiRegistry: "apps/web/app/application/ui/uiRegistry.ts",
} as const satisfies Record<LayoutAdapterName, string>;
export type LayoutAdapter = UiAdapterDefinition<"layout"> & {
readonly status: "available";
readonly name: LayoutAdapterName;
readonly source: (typeof layoutAdapterSources)[LayoutAdapterName];
readonly implementation: "shell-layout" | "agent-registry";
readonly packageExport: "@toolctrl/ui/layout";
};
export const layoutAdapters = layoutAdapterNames.map((name) => ({
category: "layout",
name,
status: "available",
source: layoutAdapterSources[name],
implementation: name === "UiRegistry" ? "agent-registry" : "shell-layout",
packageExport: "@toolctrl/ui/layout",
description:
name === "UiRegistry"
? "Stable data-ui-id registry consumed by shell, navigation and UI inventory guards."
: "Stable authenticated shell layout adapter contract. Runtime implementation currently remains in app/.",
})) satisfies readonly LayoutAdapter[];
@@ -0,0 +1,43 @@
import type { UiAdapterDefinition } from "./types.js";
export const navigationAdapterNames = [
"AppSidebar",
"AdminSidebar",
"DocsSidebar",
"NavMain",
"NavSecondary",
"NavQuickLinks",
"NavActions",
"SidebarPrimitives",
] as const;
export type NavigationAdapterName = (typeof navigationAdapterNames)[number];
const navigationAdapterSources = {
AppSidebar: "apps/web/app/application/sidebar/AppSidebar.tsx",
AdminSidebar: "apps/web/app/application/sidebar/AdminSidebar.tsx",
DocsSidebar: "apps/web/app/application/sidebar/DocsSidebar.ts",
NavMain: "apps/web/app/components/layouts/sidebars/shadcn/nav-main.tsx",
NavSecondary: "apps/web/app/components/layouts/sidebars/shadcn/nav-secondary.tsx",
NavQuickLinks: "apps/web/app/components/layouts/sidebars/shadcn/nav-quick-links.tsx",
NavActions: "apps/web/app/components/layouts/sidebars/shadcn/nav-actions.tsx",
SidebarPrimitives: "apps/web/app/components/ui/sidebar.tsx",
} as const satisfies Record<NavigationAdapterName, string>;
export type NavigationAdapter = UiAdapterDefinition<"navigation"> & {
readonly status: "available";
readonly name: NavigationAdapterName;
readonly source: (typeof navigationAdapterSources)[NavigationAdapterName];
readonly implementation: "shadcn/radix" | "application-registry" | "custom";
readonly packageExport: "@toolctrl/ui/navigation";
};
export const navigationAdapters = navigationAdapterNames.map((name) => ({
category: "navigation",
name,
status: "available",
source: navigationAdapterSources[name],
implementation: name.endsWith("Sidebar") ? "application-registry" : name === "NavActions" ? "custom" : "shadcn/radix",
packageExport: "@toolctrl/ui/navigation",
description: `Stable navigation adapter contract for ${name}. Runtime implementation currently remains in app/.`,
})) satisfies readonly NavigationAdapter[];
@@ -0,0 +1,63 @@
import type { Meta, StoryObj } from "@storybook/react";
import { Avatar, AvatarFallback, AvatarImage } from "~/components/ui/avatar";
function initials(name: string): string {
return name
.split(/\s+/)
.map((part) => part.charAt(0))
.filter(Boolean)
.slice(0, 2)
.join("")
.toUpperCase();
}
const meta: Meta<typeof Avatar> = {
title: "UI / Feedback / Avatar",
component: Avatar,
tags: ["autodocs"],
};
export default meta;
type Story = StoryObj<typeof Avatar>;
export const Initials: Story = {
render: () => (
<Avatar>
<AvatarFallback>{initials("Ada Lovelace")}</AvatarFallback>
</Avatar>
),
};
export const Stack: Story = {
render: () => (
<div style={{ display: "flex", gap: 8 }}>
{["Ada Lovelace", "Grace Hopper", "Linus Torvalds", "N N"].map((name) => (
<Avatar key={name}>
<AvatarFallback>{initials(name)}</AvatarFallback>
</Avatar>
))}
</div>
),
};
export const SizeScale: Story = {
render: () => (
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
{[20, 28, 36, 48, 64].map((px) => (
<Avatar key={px} style={{ width: px, height: px }} className="size-auto">
<AvatarFallback className="text-[0.6rem]">AB</AvatarFallback>
</Avatar>
))}
</div>
),
};
export const WithImage: Story = {
render: () => (
<Avatar>
<AvatarImage src="https://i.pravatar.cc/64?img=64" alt="" />
<AvatarFallback>AL</AvatarFallback>
</Avatar>
),
};
@@ -0,0 +1,62 @@
import type { Meta, StoryObj } from "@storybook/react";
import { Badge } from "~/components/ui/badge";
const meta: Meta<typeof Badge> = {
title: "UI / Feedback / Badge",
component: Badge,
tags: ["autodocs"],
argTypes: {
variant: {
control: { type: "inline-radio" },
options: ["default", "secondary", "destructive", "outline"],
},
},
args: { variant: "default", children: "New" },
};
export default meta;
type Story = StoryObj<typeof Badge>;
export const Default: Story = {};
export const AllVariants: Story = {
render: () => (
<div style={{ display: "flex", gap: 8, alignItems: "center", flexWrap: "wrap" }}>
<Badge variant="default">Default</Badge>
<Badge variant="secondary">Secondary</Badge>
<Badge variant="destructive">Failed</Badge>
<Badge variant="outline">Draft</Badge>
</div>
),
};
export const SemanticColors: Story = {
render: () => (
<div style={{ display: "flex", gap: 8, alignItems: "center", flexWrap: "wrap" }}>
<Badge color="green" withColorDot>
Active
</Badge>
<Badge color="yellow" withColorDot>
Pending
</Badge>
<Badge color="red" withColorDot>
Failed
</Badge>
<Badge color="blue" withColorDot>
Info
</Badge>
<Badge color="gray" withColorDot>
Draft
</Badge>
</div>
),
};
export const InText: Story = {
render: () => (
<p style={{ fontSize: 14 }}>
Workspace status: <Badge color="green">healthy</Badge>. Latest deploy passed all gates.
</p>
),
};
@@ -0,0 +1,64 @@
import type { Meta, StoryObj } from "@storybook/react";
import { Button } from "~/components/ui/button";
const meta: Meta<typeof Button> = {
title: "UI / Buttons / Button",
component: Button,
tags: ["autodocs"],
argTypes: {
variant: {
control: { type: "select" },
options: ["default", "destructive", "outline", "secondary", "ghost", "link"],
},
size: { control: { type: "inline-radio" }, options: ["xs", "sm", "default", "lg", "icon"] },
isLoading: { control: "boolean" },
disabled: { control: "boolean" },
},
args: {
variant: "default",
size: "default",
isLoading: false,
disabled: false,
children: "Save changes",
},
};
export default meta;
type Story = StoryObj<typeof Button>;
export const Primary: Story = {};
export const Destructive: Story = { args: { variant: "destructive", children: "Delete account" } };
export const Outline: Story = { args: { variant: "outline", children: "Cancel" } };
export const Secondary: Story = { args: { variant: "secondary", children: "Secondary action" } };
export const Ghost: Story = { args: { variant: "ghost", children: "Dismiss" } };
export const LinkLike: Story = { args: { variant: "link", children: "Read the docs" } };
export const Loading: Story = { args: { isLoading: true, children: "Saving" } };
export const Disabled: Story = { args: { disabled: true, children: "Unavailable" } };
export const Sizes: Story = {
render: (args) => (
<div style={{ display: "flex", gap: 12, alignItems: "center" }}>
<Button {...args} size="xs">
Extra small
</Button>
<Button {...args} size="sm">
Small
</Button>
<Button {...args} size="default">
Default
</Button>
<Button {...args} size="lg">
Large
</Button>
</div>
),
args: { children: undefined },
};
@@ -0,0 +1,48 @@
import type { Meta, StoryObj } from "@storybook/react";
import { Button } from "~/components/ui/button";
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "~/components/ui/card";
const meta: Meta<typeof Card> = {
title: "UI / Layout / Card",
component: Card,
tags: ["autodocs"],
};
export default meta;
type Story = StoryObj<typeof Card>;
export const Basic: Story = {
render: () => (
<Card className="max-w-sm">
<CardHeader>
<CardTitle>Account</CardTitle>
</CardHeader>
<CardContent>Manage profile, security, and billing in one place.</CardContent>
</Card>
),
};
export const WithFooter: Story = {
render: () => (
<Card className="max-w-sm">
<CardHeader>
<CardTitle>Invite teammates</CardTitle>
<CardDescription>You can revoke access at any time.</CardDescription>
</CardHeader>
<CardContent>Send invitations to start collaborating.</CardContent>
<CardFooter className="justify-end gap-2 border-t">
<Button variant="ghost">Cancel</Button>
<Button>Send invites</Button>
</CardFooter>
</Card>
),
};
export const Compact: Story = {
render: () => (
<Card className="max-w-sm">
<CardContent>A bare card without a header or footer for compact layouts.</CardContent>
</Card>
),
};
@@ -0,0 +1,43 @@
import type { Meta, StoryObj } from "@storybook/react";
import { DemoCheckbox } from "./_demo-primitives.js";
const meta: Meta<typeof DemoCheckbox> = {
title: "UI / Inputs / Checkbox",
component: DemoCheckbox,
tags: ["autodocs"],
argTypes: {
checked: { control: "boolean" },
disabled: { control: "boolean" },
},
args: { label: "I accept the terms", name: "accept-terms" },
};
export default meta;
type Story = StoryObj<typeof DemoCheckbox>;
export const Unchecked: Story = {};
export const Checked: Story = { args: { defaultChecked: true } };
export const Disabled: Story = { args: { disabled: true, defaultChecked: true } };
export const Group: Story = {
render: () => (
<fieldset
style={{
border: "1px solid var(--color-border)",
borderRadius: 6,
padding: 12,
display: "flex",
flexDirection: "column",
gap: 8,
}}
>
<legend style={{ padding: "0 4px", fontWeight: 600 }}>Notifications</legend>
<DemoCheckbox name="prefs" label="Product updates" defaultChecked />
<DemoCheckbox name="prefs" label="Weekly newsletter" />
<DemoCheckbox name="prefs" label="Security alerts" defaultChecked />
</fieldset>
),
};
@@ -0,0 +1,113 @@
import type { Meta, StoryObj } from "@storybook/react";
import { useMemo, useState } from "react";
import { DemoBadge, DemoButton, DemoInput, DemoTable } from "./_demo-primitives.js";
const meta: Meta = {
title: "UI / Tables / DataTable",
};
export default meta;
type Story = StoryObj;
type Invoice = {
id: string;
customer: string;
amount: number;
status: "paid" | "open" | "void";
issuedAt: string;
};
const invoices: ReadonlyArray<Invoice> = [
{ id: "INV-0001", customer: "Acme", amount: 4200, status: "paid", issuedAt: "2026-04-12" },
{ id: "INV-0002", customer: "Globex", amount: 800, status: "open", issuedAt: "2026-04-18" },
{ id: "INV-0003", customer: "Initech", amount: 12_500, status: "paid", issuedAt: "2026-04-20" },
{ id: "INV-0004", customer: "Hooli", amount: 320, status: "void", issuedAt: "2026-04-21" },
{ id: "INV-0005", customer: "Soylent", amount: 1500, status: "open", issuedAt: "2026-04-25" },
{ id: "INV-0006", customer: "Pied Piper", amount: 9999, status: "paid", issuedAt: "2026-04-28" },
{ id: "INV-0007", customer: "Stark", amount: 50_000, status: "paid", issuedAt: "2026-05-02" },
{ id: "INV-0008", customer: "Wayne", amount: 0, status: "void", issuedAt: "2026-05-03" },
{ id: "INV-0009", customer: "Wonka", amount: 720, status: "open", issuedAt: "2026-05-05" },
{ id: "INV-0010", customer: "Cyberdyne", amount: 2200, status: "paid", issuedAt: "2026-05-07" },
];
const statusTone: Record<Invoice["status"], "success" | "warning" | "muted"> = {
paid: "success",
open: "warning",
void: "muted",
};
function Toolbar({ filter, onFilterChange }: { filter: string; onFilterChange: (next: string) => void }) {
return (
<div style={{ display: "flex", gap: 8, alignItems: "center", marginBottom: 12 }}>
<DemoInput placeholder="Filter by customer…" value={filter} onChange={(event) => onFilterChange(event.currentTarget.value)} style={{ maxWidth: 260 }} />
<DemoButton variant="outline" onClick={() => onFilterChange("")}>
Clear
</DemoButton>
</div>
);
}
function Pagination({ page, pageCount, onChange }: { page: number; pageCount: number; onChange: (next: number) => void }) {
return (
<div role="navigation" aria-label="Pagination" style={{ display: "flex", gap: 8, marginTop: 12, alignItems: "center" }}>
<DemoButton size="sm" variant="outline" disabled={page === 0} onClick={() => onChange(page - 1)}>
Prev
</DemoButton>
<span style={{ fontSize: 13 }}>
Page {page + 1} / {pageCount}
</span>
<DemoButton size="sm" variant="outline" disabled={page >= pageCount - 1} onClick={() => onChange(page + 1)}>
Next
</DemoButton>
</div>
);
}
export const FilteredAndPaginated: Story = {
name: "Filtered + paginated",
render: () => {
const [filter, setFilter] = useState("");
const [page, setPage] = useState(0);
const pageSize = 4;
const filtered = useMemo(() => {
const lc = filter.trim().toLowerCase();
return lc.length === 0 ? invoices : invoices.filter((row) => row.customer.toLowerCase().includes(lc));
}, [filter]);
const pageCount = Math.max(1, Math.ceil(filtered.length / pageSize));
const safePage = Math.min(page, pageCount - 1);
const rows = filtered.slice(safePage * pageSize, safePage * pageSize + pageSize);
return (
<div style={{ maxWidth: 720 }}>
<Toolbar
filter={filter}
onFilterChange={(value) => {
setFilter(value);
setPage(0);
}}
/>
<DemoTable<Invoice>
columns={[
{ key: "id", header: "Invoice" },
{ key: "customer", header: "Customer" },
{
key: "amount",
header: "Amount",
align: "right",
render: (row) => new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" }).format(row.amount),
},
{
key: "status",
header: "Status",
render: (row) => <DemoBadge tone={statusTone[row.status]}>{row.status}</DemoBadge>,
},
{ key: "issuedAt", header: "Issued" },
]}
rows={rows}
/>
<Pagination page={safePage} pageCount={pageCount} onChange={setPage} />
</div>
);
},
};
@@ -0,0 +1,79 @@
import type { Meta, StoryObj } from "@storybook/react";
import { useState } from "react";
import { DemoButton, DemoDialog } from "./_demo-primitives.js";
const meta: Meta<typeof DemoDialog> = {
title: "UI / Overlays / Dialog",
component: DemoDialog,
};
export default meta;
type Story = StoryObj<typeof DemoDialog>;
export const Basic: Story = {
render: () => {
const [open, setOpen] = useState(false);
return (
<div>
<DemoButton onClick={() => setOpen(true)}>Open dialog</DemoButton>
<DemoDialog
open={open}
onOpenChange={setOpen}
title="Heads up"
description="This is a description of the modal action."
footer={
<>
<DemoButton variant="ghost" onClick={() => setOpen(false)}>
Cancel
</DemoButton>
<DemoButton onClick={() => setOpen(false)}>Got it</DemoButton>
</>
}
/>
</div>
);
},
};
export const ConfirmDestructive: Story = {
render: () => {
const [open, setOpen] = useState(false);
return (
<div>
<DemoButton variant="destructive" onClick={() => setOpen(true)}>
Delete project
</DemoButton>
<DemoDialog
open={open}
onOpenChange={setOpen}
title="Delete project?"
description="This action cannot be undone. All data will be permanently removed."
footer={
<>
<DemoButton variant="outline" onClick={() => setOpen(false)}>
Keep project
</DemoButton>
<DemoButton variant="destructive" onClick={() => setOpen(false)}>
Confirm delete
</DemoButton>
</>
}
/>
</div>
);
},
};
export const AlwaysOpen: Story = {
render: () => (
<DemoDialog
open
onOpenChange={() => undefined}
title="Permanent dialog"
description="Used for layout / visual review inside Storybook."
>
The dialog body renders any React children, e.g. forms, lists, or images.
</DemoDialog>
),
};
@@ -0,0 +1,85 @@
import type { Meta, StoryObj } from "@storybook/react";
import { DemoButton } from "./_demo-primitives.js";
const meta: Meta = {
title: "UI / Feedback / EmptyState",
};
export default meta;
type Story = StoryObj;
function EmptyState({
title,
body,
action,
illustration,
}: {
title: string;
body: string;
action?: React.ReactNode;
illustration?: React.ReactNode;
}) {
return (
<div
role="status"
style={{
border: "1px dashed var(--color-border)",
borderRadius: 8,
padding: 32,
textAlign: "center",
display: "flex",
flexDirection: "column",
alignItems: "center",
gap: 12,
maxWidth: 420,
color: "var(--color-muted)",
}}
>
{illustration}
<h3 style={{ margin: 0, fontSize: 18, color: "var(--color-fg)" }}>{title}</h3>
<p style={{ margin: 0, fontSize: 14 }}>{body}</p>
{action}
</div>
);
}
const Inbox = () => (
<svg width="56" height="56" viewBox="0 0 56 56" aria-hidden focusable="false">
<rect x="6" y="14" width="44" height="32" rx="4" fill="var(--color-accent)" />
<path d="M6 28 H20 L24 34 H32 L36 28 H50" stroke="var(--color-fg)" strokeWidth="2" fill="none" />
</svg>
);
export const NoData: Story = {
name: "No data",
render: () => (
<EmptyState
illustration={<Inbox />}
title="No projects yet"
body="Create your first project to start collaborating with your team."
action={<DemoButton>Create project</DemoButton>}
/>
),
};
export const NoResults: Story = {
name: "No search results",
render: () => (
<EmptyState
title="No matches for your search"
body="Try a different keyword or clear the filters to see all items."
/>
),
};
export const ErrorState: Story = {
name: "Error state",
render: () => (
<EmptyState
title="Something went wrong"
body="We could not load the dashboard. Please try again in a moment."
action={<DemoButton variant="outline">Retry</DemoButton>}
/>
),
};
@@ -0,0 +1,86 @@
import type { Meta, StoryObj } from "@storybook/react";
import { useState } from "react";
import {
DemoButton,
DemoCheckbox,
DemoField,
DemoInput,
DemoSelect,
DemoTextarea,
} from "./_demo-primitives.js";
const meta: Meta = {
title: "UI / Forms / Form",
};
export default meta;
type Story = StoryObj;
function ContactForm() {
const [submitted, setSubmitted] = useState(false);
return (
<form
style={{ display: "flex", flexDirection: "column", gap: 14, maxWidth: 420 }}
onSubmit={(event) => {
event.preventDefault();
setSubmitted(true);
}}
>
<DemoField label="Full name" htmlFor="contact-name">
<DemoInput id="contact-name" name="name" required />
</DemoField>
<DemoField label="Email" htmlFor="contact-email">
<DemoInput id="contact-email" name="email" type="email" required />
</DemoField>
<DemoField label="Topic" htmlFor="contact-topic">
<DemoSelect
id="contact-topic"
name="topic"
options={[
{ value: "sales", label: "Sales" },
{ value: "support", label: "Support" },
{ value: "partnership", label: "Partnership" },
]}
/>
</DemoField>
<DemoField label="Message" htmlFor="contact-message" hint="A few sentences are enough.">
<DemoTextarea id="contact-message" name="message" rows={4} required />
</DemoField>
<DemoCheckbox name="newsletter" label="Subscribe me to product updates" />
<div style={{ display: "flex", justifyContent: "flex-end", gap: 8 }}>
<DemoButton variant="ghost" type="reset">
Reset
</DemoButton>
<DemoButton type="submit">Send message</DemoButton>
</div>
{submitted ? (
<p role="status" style={{ color: "var(--color-success)", fontSize: 13 }}>
Form submitted — thanks!
</p>
) : null}
</form>
);
}
export const ContactFormStory: Story = {
name: "Contact form",
render: () => <ContactForm />,
};
export const FieldList: Story = {
name: "Field list (read-only)",
render: () => (
<div style={{ display: "flex", flexDirection: "column", gap: 12, maxWidth: 360 }}>
<DemoField label="Plan" htmlFor="ro-plan">
<DemoInput id="ro-plan" value="Team" readOnly />
</DemoField>
<DemoField label="Seats used" htmlFor="ro-seats">
<DemoInput id="ro-seats" value="14 / 25" readOnly />
</DemoField>
<DemoField label="Renews on" htmlFor="ro-renew">
<DemoInput id="ro-renew" value="2026-09-12" readOnly />
</DemoField>
</div>
),
};
@@ -0,0 +1,86 @@
import type { Meta, StoryObj } from "@storybook/react";
import { Input } from "~/components/ui/input";
function Field({
label,
hint,
error,
htmlFor,
children,
}: {
label: string;
hint?: string;
error?: string;
htmlFor: string;
children: React.ReactNode;
}) {
return (
<label className="flex max-w-sm flex-col gap-1.5 text-sm" htmlFor={htmlFor}>
<span className="font-medium">{label}</span>
{children}
{hint && !error ? <span className="text-muted-foreground text-xs">{hint}</span> : null}
{error ? (
<span className="text-destructive text-xs" role="alert">
{error}
</span>
) : null}
</label>
);
}
const meta: Meta<typeof Input> = {
title: "UI / Inputs / Input",
component: Input,
tags: ["autodocs"],
argTypes: {
type: { control: { type: "select" }, options: ["text", "email", "password", "search", "tel"] },
disabled: { control: "boolean" },
},
args: { type: "text", placeholder: "you@example.com" },
};
export default meta;
type Story = StoryObj<typeof Input>;
export const Default: Story = {
render: (args) => (
<Field label="Email" htmlFor="email-default">
<Input id="email-default" {...args} />
</Field>
),
};
export const WithHint: Story = {
render: (args) => (
<Field label="Email" hint="We use this to send sign-in links." htmlFor="email-hint">
<Input id="email-hint" {...args} />
</Field>
),
};
export const WithError: Story = {
render: (args) => (
<Field label="Email" error="Enter a valid email address." htmlFor="email-error">
<Input id="email-error" {...args} aria-invalid="true" />
</Field>
),
};
export const PasswordType: Story = {
args: { type: "password", placeholder: "" },
render: (args) => (
<Field label="Password" htmlFor="password-default">
<Input id="password-default" {...args} />
</Field>
),
};
export const DisabledInput: Story = {
args: { disabled: true, value: "locked@example.com" },
render: (args) => (
<Field label="Email" htmlFor="email-disabled">
<Input id="email-disabled" {...args} readOnly />
</Field>
),
};
@@ -0,0 +1,123 @@
import type { Meta, StoryObj } from "@storybook/react";
const meta: Meta = {
title: "UI / Feedback / LoadingState",
};
export default meta;
type Story = StoryObj;
function SkeletonLine({ width }: { width: string | number }) {
return (
<span
aria-hidden
style={{
display: "block",
height: 12,
width,
borderRadius: 6,
background: "var(--color-accent)",
animation: "demo-pulse 1.2s ease-in-out infinite",
}}
/>
);
}
function injectKeyframesOnce() {
if (typeof document === "undefined") return;
if (document.getElementById("demo-pulse-keyframes")) return;
const style = document.createElement("style");
style.id = "demo-pulse-keyframes";
style.textContent = "@keyframes demo-pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.5; } }";
document.head.appendChild(style);
}
export const SkeletonCard: Story = {
name: "Skeleton card",
render: () => {
injectKeyframesOnce();
return (
<div
role="status"
aria-label="Loading"
style={{
border: "1px solid var(--color-border)",
borderRadius: 8,
padding: 16,
width: 360,
display: "flex",
flexDirection: "column",
gap: 10,
}}
>
<SkeletonLine width="40%" />
<SkeletonLine width="100%" />
<SkeletonLine width="100%" />
<SkeletonLine width="60%" />
</div>
);
},
};
export const InlineSpinner: Story = {
name: "Inline spinner",
render: () => (
<div
role="status"
aria-label="Loading"
style={{ display: "inline-flex", alignItems: "center", gap: 8 }}
>
<span
aria-hidden
style={{
width: 14,
height: 14,
borderRadius: "50%",
border: "2px solid var(--color-muted)",
borderRightColor: "transparent",
display: "inline-block",
animation: "demo-spin 600ms linear infinite",
}}
/>
<span style={{ fontSize: 13 }}>Loading workspace…</span>
</div>
),
};
export const TableLoading: Story = {
name: "Table loading",
render: () => {
injectKeyframesOnce();
return (
<div
role="status"
aria-label="Loading rows"
style={{
border: "1px solid var(--color-border)",
borderRadius: 6,
overflow: "hidden",
width: 480,
}}
>
{Array.from({ length: 4 }).map((_, index) => (
<div
key={index}
style={{
display: "grid",
gridTemplateColumns: "1fr 1fr 1fr 60px",
gap: 12,
padding: "10px 14px",
borderTop: index === 0 ? "none" : "1px solid var(--color-border)",
}}
>
<SkeletonLine width="80%" />
<SkeletonLine width="60%" />
<SkeletonLine width="40%" />
<SkeletonLine width="100%" />
</div>
))}
</div>
);
},
};
@@ -0,0 +1,104 @@
import type { Meta, StoryObj } from "@storybook/react";
import { useEffect, useRef, useState } from "react";
import { DemoButton } from "./_demo-primitives.js";
const meta: Meta = {
title: "UI / Overlays / Popover",
};
export default meta;
type Story = StoryObj;
const menuButtonStyle = {
background: "transparent",
border: 0,
color: "inherit",
cursor: "pointer",
font: "inherit",
padding: 0,
textAlign: "left",
} satisfies React.CSSProperties;
function Popover({
trigger,
children,
}: {
trigger: React.ReactNode;
children: React.ReactNode;
}) {
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open) return;
const handler = (event: MouseEvent) => {
if (ref.current && !ref.current.contains(event.target as Node)) setOpen(false);
};
document.addEventListener("mousedown", handler);
return () => document.removeEventListener("mousedown", handler);
}, [open]);
return (
<div ref={ref} style={{ position: "relative", display: "inline-block" }}>
{/* trigger is always a focusable DemoButton; its keyboard activation bubbles a
click to this span, so the span needs no separate role/key handler. */}
{/* eslint-disable-next-line jsx-a11y/no-static-element-interactions, jsx-a11y/click-events-have-key-events */}
<span onClick={() => setOpen((prev) => !prev)}>{trigger}</span>
{open ? (
<div
role="dialog"
style={{
position: "absolute",
top: "calc(100% + 6px)",
left: 0,
minWidth: 220,
background: "var(--color-card)",
color: "var(--color-card-foreground)",
border: "1px solid var(--color-border)",
borderRadius: 6,
padding: 12,
boxShadow: "0 8px 24px rgba(0,0,0,0.18)",
fontSize: 14,
zIndex: 5,
}}
>
{children}
</div>
) : null}
</div>
);
}
export const SimplePopover: Story = {
name: "Simple popover",
render: () => (
<Popover
trigger={<DemoButton variant="outline">Open menu</DemoButton>}
>
<ul style={{ margin: 0, padding: 0, listStyle: "none", display: "flex", flexDirection: "column", gap: 6 }}>
<li><button type="button" style={menuButtonStyle}>Profile</button></li>
<li><button type="button" style={menuButtonStyle}>Settings</button></li>
<li><button type="button" style={{ ...menuButtonStyle, color: "var(--color-destructive)" }}>Sign out</button></li>
</ul>
</Popover>
),
};
export const RichContent: Story = {
name: "Rich content",
render: () => (
<Popover trigger={<DemoButton>Filters</DemoButton>}>
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
<strong>Status</strong>
<label style={{ display: "flex", gap: 6 }}>
<input type="checkbox" defaultChecked /> Active
</label>
<label style={{ display: "flex", gap: 6 }}>
<input type="checkbox" /> Pending
</label>
<label style={{ display: "flex", gap: 6 }}>
<input type="checkbox" /> Archived
</label>
</div>
</Popover>
),
};
@@ -0,0 +1,26 @@
import type { Meta, StoryObj } from "@storybook/react";
import { DemoRadioGroup } from "./_demo-primitives.js";
const meta: Meta<typeof DemoRadioGroup> = {
title: "UI / Inputs / Radio",
component: DemoRadioGroup,
tags: ["autodocs"],
};
export default meta;
type Story = StoryObj<typeof DemoRadioGroup>;
const planOptions = [
{ value: "free", label: "Free — 1 project" },
{ value: "team", label: "Team — 25 projects" },
{ value: "enterprise", label: "Enterprise — custom" },
];
export const Default: Story = {
args: { name: "plan", options: planOptions, defaultValue: "team" },
};
export const Empty: Story = {
args: { name: "plan-empty", options: planOptions },
};
@@ -0,0 +1,45 @@
import type { Meta, StoryObj } from "@storybook/react";
import { DemoField, DemoSelect } from "./_demo-primitives.js";
const meta: Meta<typeof DemoSelect> = {
title: "UI / Inputs / Select",
component: DemoSelect,
tags: ["autodocs"],
};
export default meta;
type Story = StoryObj<typeof DemoSelect>;
const roleOptions = [
{ value: "viewer", label: "Viewer" },
{ value: "editor", label: "Editor" },
{ value: "admin", label: "Admin" },
];
export const Single: Story = {
args: { options: roleOptions, defaultValue: "viewer" },
render: (args) => (
<DemoField label="Role" htmlFor="role-single">
<DemoSelect id="role-single" {...args} />
</DemoField>
),
};
export const Multi: Story = {
args: { options: roleOptions, multiple: true, size: 3 },
render: (args) => (
<DemoField label="Roles" htmlFor="role-multi" hint="Hold ⌘/Ctrl to select multiple.">
<DemoSelect id="role-multi" {...args} />
</DemoField>
),
};
export const Disabled: Story = {
args: { options: roleOptions, disabled: true, defaultValue: "admin" },
render: (args) => (
<DemoField label="Role" htmlFor="role-disabled">
<DemoSelect id="role-disabled" {...args} />
</DemoField>
),
};
@@ -0,0 +1,103 @@
import type { Meta, StoryObj } from "@storybook/react";
import { useState } from "react";
import { DemoBadge } from "./_demo-primitives.js";
const meta: Meta = {
title: "UI / Navigation / Sidebar",
};
export default meta;
type Story = StoryObj;
type NavItem = {
id: string;
label: string;
href: string;
badge?: { tone: "default" | "warning" | "success"; text: string };
};
const items: ReadonlyArray<NavItem> = [
{ id: "dashboard", label: "Dashboard", href: "#dashboard" },
{ id: "projects", label: "Projects", href: "#projects", badge: { tone: "default", text: "12" } },
{ id: "members", label: "Members", href: "#members" },
{ id: "billing", label: "Billing", href: "#billing", badge: { tone: "warning", text: "due" } },
{ id: "settings", label: "Settings", href: "#settings" },
];
function Sidebar({ collapsed }: { collapsed: boolean }) {
const [active, setActive] = useState("projects");
return (
<nav
aria-label="Primary"
style={{
width: collapsed ? 56 : 220,
background: "var(--color-card)",
borderRight: "1px solid var(--color-border)",
padding: collapsed ? "12px 6px" : "16px 12px",
display: "flex",
flexDirection: "column",
gap: 2,
transition: "width 160ms",
minHeight: 320,
}}
>
<div
style={{
padding: collapsed ? 4 : "4px 8px 12px",
fontSize: 12,
letterSpacing: 1,
textTransform: "uppercase",
color: "var(--color-muted)",
}}
>
{collapsed ? "T" : "Toolctrl"}
</div>
{items.map((item) => (
<a
key={item.id}
href={item.href}
onClick={(event) => {
event.preventDefault();
setActive(item.id);
}}
style={{
display: "flex",
alignItems: "center",
justifyContent: collapsed ? "center" : "space-between",
padding: collapsed ? 8 : "8px 10px",
borderRadius: 6,
background: active === item.id ? "var(--color-accent)" : "transparent",
color: active === item.id ? "var(--color-fg)" : "var(--color-muted)",
textDecoration: "none",
fontSize: 14,
fontWeight: active === item.id ? 600 : 500,
}}
>
<span>{collapsed ? item.label.charAt(0) : item.label}</span>
{!collapsed && item.badge ? (
<DemoBadge tone={item.badge.tone}>{item.badge.text}</DemoBadge>
) : null}
</a>
))}
</nav>
);
}
export const Expanded: Story = {
render: () => <Sidebar collapsed={false} />,
};
export const Collapsed: Story = {
render: () => <Sidebar collapsed={true} />,
};
export const SideBySide: Story = {
name: "Side by side",
render: () => (
<div style={{ display: "flex", gap: 24 }}>
<Sidebar collapsed={true} />
<Sidebar collapsed={false} />
</div>
),
};
@@ -0,0 +1,33 @@
import type { Meta, StoryObj } from "@storybook/react";
import { DemoSwitch } from "./_demo-primitives.js";
const meta: Meta<typeof DemoSwitch> = {
title: "UI / Inputs / Switch",
component: DemoSwitch,
tags: ["autodocs"],
argTypes: {
checked: { control: "boolean" },
disabled: { control: "boolean" },
},
args: { label: "Enable beta features" },
};
export default meta;
type Story = StoryObj<typeof DemoSwitch>;
export const Off: Story = {};
export const On: Story = { args: { defaultChecked: true } };
export const Disabled: Story = { args: { disabled: true, defaultChecked: true } };
export const Stack: Story = {
render: () => (
<div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
<DemoSwitch label="Email notifications" defaultChecked />
<DemoSwitch label="Push notifications" />
<DemoSwitch label="Marketing emails" />
</div>
),
};
@@ -0,0 +1,78 @@
import type { Meta, StoryObj } from "@storybook/react";
import { DemoBadge, DemoTable } from "./_demo-primitives.js";
const meta: Meta = {
title: "UI / Tables / Table",
};
export default meta;
type Member = {
name: string;
email: string;
role: string;
status: "active" | "pending" | "disabled";
};
const members: ReadonlyArray<Member> = [
{ name: "Ada Lovelace", email: "ada@example.com", role: "Admin", status: "active" },
{ name: "Grace Hopper", email: "grace@example.com", role: "Editor", status: "active" },
{ name: "Linus Torvalds", email: "linus@example.com", role: "Viewer", status: "pending" },
{ name: "Dennis Ritchie", email: "dmr@example.com", role: "Viewer", status: "disabled" },
];
type Story = StoryObj;
const statusTone: Record<Member["status"], "success" | "warning" | "muted"> = {
active: "success",
pending: "warning",
disabled: "muted",
};
export const BasicTable: Story = {
name: "Basic table",
render: () => (
<DemoTable<Member>
caption="Workspace members"
columns={[
{ key: "name", header: "Name" },
{ key: "email", header: "Email" },
{ key: "role", header: "Role" },
{
key: "status",
header: "Status",
render: (row) => <DemoBadge tone={statusTone[row.status]}>{row.status}</DemoBadge>,
},
]}
rows={members}
/>
),
};
export const SortableHeader: Story = {
name: "Sortable header",
render: () => (
<DemoTable<Member>
columns={[
{ key: "name", header: "▲ Name" },
{ key: "email", header: "Email" },
{ key: "role", header: "Role" },
]}
rows={[...members].sort((a, b) => a.name.localeCompare(b.name))}
/>
),
};
export const Empty: Story = {
name: "Empty state",
render: () => (
<DemoTable<Member>
columns={[
{ key: "name", header: "Name" },
{ key: "email", header: "Email" },
{ key: "role", header: "Role" },
]}
rows={[]}
/>
),
};
@@ -0,0 +1,97 @@
import type { Meta, StoryObj } from "@storybook/react";
import { useState } from "react";
const meta: Meta = {
title: "UI / Navigation / Tabs",
};
export default meta;
type Story = StoryObj;
type Tab = { id: string; label: string; content: React.ReactNode };
function Tabs({ tabs, initial }: { tabs: ReadonlyArray<Tab>; initial?: string }) {
const [active, setActive] = useState(initial ?? tabs[0]?.id);
return (
<div style={{ maxWidth: 480 }}>
<div
role="tablist"
style={{
display: "flex",
gap: 4,
borderBottom: "1px solid var(--color-border)",
}}
>
{tabs.map((tab) => {
const selected = tab.id === active;
return (
<button
key={tab.id}
role="tab"
type="button"
aria-selected={selected}
id={`tab-${tab.id}`}
aria-controls={`panel-${tab.id}`}
onClick={() => setActive(tab.id)}
style={{
background: "transparent",
border: 0,
padding: "8px 12px",
fontSize: 14,
fontFamily: "inherit",
color: selected ? "var(--color-fg)" : "var(--color-muted)",
borderBottom: selected ? "2px solid var(--color-primary)" : "2px solid transparent",
marginBottom: -1,
cursor: "pointer",
fontWeight: selected ? 600 : 500,
}}
>
{tab.label}
</button>
);
})}
</div>
{tabs.map((tab) => (
<div
key={tab.id}
role="tabpanel"
id={`panel-${tab.id}`}
aria-labelledby={`tab-${tab.id}`}
hidden={tab.id !== active}
style={{ padding: "16px 4px", fontSize: 14 }}
>
{tab.content}
</div>
))}
</div>
);
}
export const Three: Story = {
name: "Three tabs",
render: () => (
<Tabs
tabs={[
{ id: "overview", label: "Overview", content: <p>Project health, recent activity, KPIs.</p> },
{ id: "members", label: "Members", content: <p>Add, remove, and manage team members.</p> },
{ id: "settings", label: "Settings", content: <p>Project name, integrations, danger zone.</p> },
]}
/>
),
};
export const Many: Story = {
name: "Many tabs",
render: () => (
<Tabs
tabs={[
{ id: "a", label: "General", content: <p>General settings.</p> },
{ id: "b", label: "Security", content: <p>Two-factor, sessions.</p> },
{ id: "c", label: "Integrations", content: <p>Third-party apps.</p> },
{ id: "d", label: "Billing", content: <p>Invoices and plan.</p> },
{ id: "e", label: "Audit log", content: <p>Recent admin events.</p> },
]}
/>
),
};
@@ -0,0 +1,76 @@
import type { Meta, StoryObj } from "@storybook/react";
import { Textarea } from "~/components/ui/textarea";
function Field({
label,
hint,
error,
htmlFor,
children,
}: {
label: string;
hint?: string;
error?: string;
htmlFor: string;
children: React.ReactNode;
}) {
return (
<label className="flex max-w-sm flex-col gap-1.5 text-sm" htmlFor={htmlFor}>
<span className="font-medium">{label}</span>
{children}
{hint && !error ? <span className="text-muted-foreground text-xs">{hint}</span> : null}
{error ? (
<span className="text-destructive text-xs" role="alert">
{error}
</span>
) : null}
</label>
);
}
const meta: Meta<typeof Textarea> = {
title: "UI / Inputs / Textarea",
component: Textarea,
tags: ["autodocs"],
argTypes: {
disabled: { control: "boolean" },
},
args: { placeholder: "Tell us what happened…", rows: 4 },
};
export default meta;
type Story = StoryObj<typeof Textarea>;
export const Default: Story = {
render: (args) => (
<Field label="Message" htmlFor="msg-default">
<Textarea id="msg-default" {...args} />
</Field>
),
};
export const WithCharacterHint: Story = {
render: (args) => (
<Field label="Bio" hint="Max 280 characters." htmlFor="bio-hint">
<Textarea id="bio-hint" {...args} maxLength={280} />
</Field>
),
};
export const WithError: Story = {
render: (args) => (
<Field label="Message" error="Message can not be empty." htmlFor="msg-error">
<Textarea id="msg-error" {...args} aria-invalid="true" />
</Field>
),
};
export const Disabled: Story = {
args: { disabled: true, defaultValue: "Submission already locked — read-only." },
render: (args) => (
<Field label="Message" htmlFor="msg-disabled">
<Textarea id="msg-disabled" {...args} />
</Field>
),
};
@@ -0,0 +1,74 @@
import type { Meta, StoryObj } from "@storybook/react";
import { useState } from "react";
import { DemoButton } from "./_demo-primitives.js";
const meta: Meta = {
title: "UI / Feedback / Toast",
};
export default meta;
type Story = StoryObj;
type ToastTone = "success" | "error" | "info";
function ToastView({ tone, message }: { tone: ToastTone; message: string }) {
const palette: Record<ToastTone, { bg: string; fg: string }> = {
success: { bg: "var(--color-success)", fg: "#fff" },
error: { bg: "var(--color-destructive)", fg: "var(--color-destructive-foreground)" },
info: { bg: "var(--color-primary)", fg: "var(--color-primary-foreground)" },
};
return (
<div
role="status"
style={{
padding: "10px 14px",
borderRadius: 6,
background: palette[tone].bg,
color: palette[tone].fg,
fontSize: 14,
boxShadow: "0 4px 12px rgba(0,0,0,0.18)",
minWidth: 240,
}}
>
{message}
</div>
);
}
export const Success: Story = {
render: () => <ToastView tone="success" message="Project saved." />,
};
export const ErrorToast: Story = {
name: "Error",
render: () => <ToastView tone="error" message="Failed to save — please retry." />,
};
export const Info: Story = {
render: () => <ToastView tone="info" message="A new version is available." />,
};
export const Interactive: Story = {
render: () => {
const [toast, setToast] = useState<{ tone: ToastTone; message: string } | null>(null);
return (
<div style={{ display: "flex", gap: 8, alignItems: "center", flexWrap: "wrap" }}>
<DemoButton onClick={() => setToast({ tone: "success", message: "Saved!" })}>
Trigger success
</DemoButton>
<DemoButton
variant="destructive"
onClick={() => setToast({ tone: "error", message: "Could not delete." })}
>
Trigger error
</DemoButton>
<DemoButton variant="outline" onClick={() => setToast(null)}>
Dismiss
</DemoButton>
<div style={{ flexBasis: "100%" }} />
{toast ? <ToastView {...toast} /> : null}
</div>
);
},
};
@@ -0,0 +1,66 @@
import type { Meta, StoryObj } from "@storybook/react";
import { useState } from "react";
import { DemoButton } from "./_demo-primitives.js";
const meta: Meta = {
title: "UI / Overlays / Tooltip",
};
export default meta;
type Story = StoryObj;
function Tooltip({ label, children }: { label: string; children: React.ReactNode }) {
const [open, setOpen] = useState(false);
return (
<span
style={{ position: "relative", display: "inline-block" }}
onMouseEnter={() => setOpen(true)}
onMouseLeave={() => setOpen(false)}
onFocus={() => setOpen(true)}
onBlur={() => setOpen(false)}
>
{children}
{open ? (
<span
role="tooltip"
style={{
position: "absolute",
bottom: "calc(100% + 6px)",
left: "50%",
transform: "translateX(-50%)",
background: "var(--color-fg)",
color: "var(--color-bg)",
fontSize: 12,
padding: "4px 8px",
borderRadius: 4,
whiteSpace: "nowrap",
pointerEvents: "none",
}}
>
{label}
</span>
) : null}
</span>
);
}
export const OnButton: Story = {
name: "On a button",
render: () => (
<Tooltip label="Saves the form and closes the dialog">
<DemoButton>Save and close</DemoButton>
</Tooltip>
),
};
export const OnIcon: Story = {
name: "On an icon button",
render: () => (
<Tooltip label="Copy to clipboard">
<DemoButton variant="ghost" aria-label="Copy">
⧉
</DemoButton>
</Tooltip>
),
};
@@ -0,0 +1,626 @@
/*
* Style-only demo primitives for Storybook.
*
* The `@toolctrl/ui` package itself contains only adapter *metadata* (it
* registers which components live in apps/web). To give Storybook standalone
* visual stories without dragging the entire web app into the build, we
* re-implement minimal shadcn-style primitives here. They share the same
* surface (variant/size/disabled) so the stories communicate the design
* intent without coupling to the real apps/web bundle.
*
* If you need to story the *real* apps/web components instead, import them
* directly from `apps/web/app/components/ui/*` in a story file — vite will
* resolve the path. We avoid that here so the Storybook build stays
* dependency-light.
*/
import React, {
forwardRef,
type ButtonHTMLAttributes,
type HTMLAttributes,
type InputHTMLAttributes,
type LabelHTMLAttributes,
type ReactNode,
type SelectHTMLAttributes,
type TextareaHTMLAttributes,
} from "react";
export type ButtonVariant = "default" | "destructive" | "outline" | "ghost" | "link";
export type ButtonSize = "sm" | "md" | "lg";
const buttonBase: React.CSSProperties = {
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
gap: 8,
borderRadius: 6,
fontWeight: 600,
fontFamily: "inherit",
cursor: "pointer",
border: "1px solid transparent",
transition: "background 120ms, color 120ms, border-color 120ms",
};
const buttonVariants: Record<ButtonVariant, React.CSSProperties> = {
default: {
background: "var(--color-primary)",
color: "var(--color-primary-foreground)",
},
destructive: {
background: "var(--color-destructive)",
color: "var(--color-destructive-foreground)",
},
outline: {
background: "transparent",
color: "var(--color-fg)",
borderColor: "var(--color-border)",
},
ghost: {
background: "transparent",
color: "var(--color-fg)",
},
link: {
background: "transparent",
color: "var(--color-primary)",
textDecoration: "underline",
padding: 0,
},
};
const buttonSizes: Record<ButtonSize, React.CSSProperties> = {
sm: { padding: "4px 10px", fontSize: 12 },
md: { padding: "8px 14px", fontSize: 14 },
lg: { padding: "10px 18px", fontSize: 16 },
};
export type DemoButtonProps = ButtonHTMLAttributes<HTMLButtonElement> & {
variant?: ButtonVariant;
size?: ButtonSize;
loading?: boolean;
};
export const DemoButton = forwardRef<HTMLButtonElement, DemoButtonProps>(function DemoButton(
{ variant = "default", size = "md", loading, disabled, children, style, ...rest },
ref,
) {
const merged: React.CSSProperties = {
...buttonBase,
...buttonVariants[variant],
...(variant === "link" ? {} : buttonSizes[size]),
opacity: disabled || loading ? 0.6 : 1,
cursor: disabled || loading ? "not-allowed" : buttonBase.cursor,
...style,
};
return (
<button ref={ref} disabled={disabled || loading} style={merged} {...rest}>
{loading ? <Spinner /> : null}
{children}
</button>
);
});
function Spinner() {
return (
<span
aria-hidden
style={{
width: 12,
height: 12,
borderRadius: "50%",
border: "2px solid currentColor",
borderRightColor: "transparent",
display: "inline-block",
animation: "demo-spin 600ms linear infinite",
}}
/>
);
}
const fieldShellStyle: React.CSSProperties = {
display: "flex",
flexDirection: "column",
gap: 4,
fontFamily: "inherit",
fontSize: 14,
};
const fieldLabelStyle: React.CSSProperties = {
fontWeight: 600,
color: "var(--color-fg)",
};
const fieldHintStyle: React.CSSProperties = {
color: "var(--color-muted)",
fontSize: 12,
};
const fieldErrorStyle: React.CSSProperties = {
color: "var(--color-destructive)",
fontSize: 12,
};
const fieldControlBase: React.CSSProperties = {
border: "1px solid var(--color-border)",
borderRadius: 6,
padding: "8px 10px",
fontFamily: "inherit",
fontSize: 14,
background: "var(--color-bg)",
color: "var(--color-fg)",
};
export type DemoFieldProps = HTMLAttributes<HTMLLabelElement> & {
label: string;
hint?: string;
error?: string;
htmlFor?: string;
children: ReactNode;
};
export function DemoField({ label, hint, error, htmlFor, children, style, ...rest }: DemoFieldProps) {
return (
<label style={{ ...fieldShellStyle, ...style }} htmlFor={htmlFor} {...rest}>
<span style={fieldLabelStyle}>{label}</span>
{children}
{hint && !error ? <span style={fieldHintStyle}>{hint}</span> : null}
{error ? (
<span style={fieldErrorStyle} role="alert">
{error}
</span>
) : null}
</label>
);
}
export type DemoInputProps = InputHTMLAttributes<HTMLInputElement> & {
invalid?: boolean;
};
export const DemoInput = forwardRef<HTMLInputElement, DemoInputProps>(function DemoInput(
{ invalid, style, ...rest },
ref,
) {
const merged: React.CSSProperties = {
...fieldControlBase,
borderColor: invalid ? "var(--color-destructive)" : fieldControlBase.border,
...style,
};
return <input ref={ref} style={merged} {...rest} />;
});
export type DemoTextareaProps = TextareaHTMLAttributes<HTMLTextAreaElement> & {
invalid?: boolean;
};
export const DemoTextarea = forwardRef<HTMLTextAreaElement, DemoTextareaProps>(function DemoTextarea(
{ invalid, style, ...rest },
ref,
) {
const merged: React.CSSProperties = {
...fieldControlBase,
minHeight: 80,
resize: "vertical",
borderColor: invalid ? "var(--color-destructive)" : fieldControlBase.border,
...style,
};
return <textarea ref={ref} style={merged} {...rest} />;
});
export type DemoSelectProps = SelectHTMLAttributes<HTMLSelectElement> & {
options: ReadonlyArray<{ value: string; label: string }>;
};
export const DemoSelect = forwardRef<HTMLSelectElement, DemoSelectProps>(function DemoSelect(
{ options, style, ...rest },
ref,
) {
return (
<select ref={ref} style={{ ...fieldControlBase, ...style }} {...rest}>
{options.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
);
});
export type DemoCheckboxProps = LabelHTMLAttributes<HTMLLabelElement> & {
checked?: boolean;
defaultChecked?: boolean;
disabled?: boolean;
label: string;
name?: string;
onCheckedChange?: (next: boolean) => void;
};
export function DemoCheckbox({
checked,
defaultChecked,
disabled,
label,
name,
onCheckedChange,
style,
...rest
}: DemoCheckboxProps) {
return (
<label
style={{
display: "inline-flex",
alignItems: "center",
gap: 8,
fontSize: 14,
color: "var(--color-fg)",
cursor: disabled ? "not-allowed" : "pointer",
...style,
}}
{...rest}
>
<input
type="checkbox"
checked={checked}
defaultChecked={defaultChecked}
disabled={disabled}
name={name}
onChange={(event) => onCheckedChange?.(event.currentTarget.checked)}
/>
{label}
</label>
);
}
export type DemoRadioGroupProps = {
name: string;
value?: string;
defaultValue?: string;
options: ReadonlyArray<{ value: string; label: string }>;
onValueChange?: (next: string) => void;
};
export function DemoRadioGroup({ name, value, defaultValue, options, onValueChange }: DemoRadioGroupProps) {
return (
<div role="radiogroup" style={{ display: "flex", flexDirection: "column", gap: 6 }}>
{options.map((option) => (
<label
key={option.value}
style={{ display: "inline-flex", alignItems: "center", gap: 8, fontSize: 14 }}
>
<input
type="radio"
name={name}
value={option.value}
checked={value === undefined ? undefined : value === option.value}
defaultChecked={defaultValue === option.value}
onChange={() => onValueChange?.(option.value)}
/>
{option.label}
</label>
))}
</div>
);
}
export type DemoSwitchProps = {
checked?: boolean;
defaultChecked?: boolean;
disabled?: boolean;
label: string;
onCheckedChange?: (next: boolean) => void;
};
export function DemoSwitch({ checked, defaultChecked, disabled, label, onCheckedChange }: DemoSwitchProps) {
const [internal, setInternal] = React.useState<boolean>(defaultChecked ?? false);
const isChecked = checked ?? internal;
return (
<label
style={{
display: "inline-flex",
alignItems: "center",
gap: 10,
fontSize: 14,
cursor: disabled ? "not-allowed" : "pointer",
}}
>
<span
role="switch"
aria-checked={isChecked}
aria-disabled={disabled}
tabIndex={0}
onClick={() => {
if (disabled) return;
if (checked === undefined) setInternal((prev) => !prev);
onCheckedChange?.(!isChecked);
}}
onKeyDown={(event) => {
if (event.key !== " " && event.key !== "Enter") return;
event.preventDefault();
if (disabled) return;
if (checked === undefined) setInternal((prev) => !prev);
onCheckedChange?.(!isChecked);
}}
style={{
width: 36,
height: 20,
borderRadius: 999,
background: isChecked ? "var(--color-primary)" : "var(--color-border)",
position: "relative",
transition: "background 120ms",
display: "inline-block",
flexShrink: 0,
}}
>
<span
aria-hidden
style={{
position: "absolute",
top: 2,
left: isChecked ? 18 : 2,
width: 16,
height: 16,
borderRadius: "50%",
background: "#ffffff",
transition: "left 120ms",
boxShadow: "0 1px 2px rgba(0,0,0,0.2)",
}}
/>
</span>
<span>{label}</span>
</label>
);
}
export type DemoCardProps = HTMLAttributes<HTMLDivElement> & {
header?: ReactNode;
footer?: ReactNode;
};
export function DemoCard({ header, footer, children, style, ...rest }: DemoCardProps) {
return (
<div
style={{
border: "1px solid var(--color-border)",
borderRadius: 8,
background: "var(--color-card)",
color: "var(--color-card-foreground)",
overflow: "hidden",
display: "flex",
flexDirection: "column",
maxWidth: 380,
...style,
}}
{...rest}
>
{header ? (
<div
style={{
padding: "12px 16px",
borderBottom: "1px solid var(--color-border)",
fontWeight: 600,
}}
>
{header}
</div>
) : null}
<div style={{ padding: 16, fontSize: 14 }}>{children}</div>
{footer ? (
<div
style={{
padding: "10px 16px",
borderTop: "1px solid var(--color-border)",
display: "flex",
justifyContent: "flex-end",
gap: 8,
}}
>
{footer}
</div>
) : null}
</div>
);
}
export type DemoBadgeProps = HTMLAttributes<HTMLSpanElement> & {
tone?: "default" | "success" | "warning" | "destructive" | "muted";
};
export function DemoBadge({ tone = "default", style, ...rest }: DemoBadgeProps) {
const palette: Record<NonNullable<DemoBadgeProps["tone"]>, React.CSSProperties> = {
default: { background: "var(--color-primary)", color: "var(--color-primary-foreground)" },
success: { background: "var(--color-success)", color: "#ffffff" },
warning: { background: "var(--color-warning)", color: "#ffffff" },
destructive: { background: "var(--color-destructive)", color: "var(--color-destructive-foreground)" },
muted: { background: "var(--color-accent)", color: "var(--color-accent-foreground)" },
};
return (
<span
style={{
display: "inline-flex",
alignItems: "center",
padding: "2px 8px",
borderRadius: 999,
fontSize: 12,
fontWeight: 600,
...palette[tone],
...style,
}}
{...rest}
/>
);
}
export type DemoAvatarProps = {
src?: string;
name: string;
size?: number;
};
export function DemoAvatar({ src, name, size = 36 }: DemoAvatarProps) {
const initials = name
.split(/\s+/)
.map((part) => part.charAt(0))
.filter(Boolean)
.slice(0, 2)
.join("")
.toUpperCase();
return (
<span
aria-label={name}
style={{
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
width: size,
height: size,
borderRadius: "50%",
background: "var(--color-accent)",
color: "var(--color-accent-foreground)",
fontWeight: 700,
fontSize: size * 0.4,
overflow: "hidden",
}}
>
{src ? (
<img src={src} alt="" style={{ width: "100%", height: "100%", objectFit: "cover" }} />
) : (
<span>{initials}</span>
)}
</span>
);
}
export type DemoTableColumn<T> = {
key: keyof T & string;
header: string;
align?: "left" | "right" | "center";
render?: (row: T) => ReactNode;
};
export type DemoTableProps<T> = {
columns: ReadonlyArray<DemoTableColumn<T>>;
rows: ReadonlyArray<T>;
caption?: string;
};
export function DemoTable<T extends Record<string, unknown>>({ columns, rows, caption }: DemoTableProps<T>) {
return (
<table
style={{
borderCollapse: "collapse",
width: "100%",
fontSize: 13,
background: "var(--color-card)",
color: "var(--color-card-foreground)",
border: "1px solid var(--color-border)",
borderRadius: 6,
overflow: "hidden",
}}
>
{caption ? <caption style={{ textAlign: "left", padding: 8 }}>{caption}</caption> : null}
<thead style={{ background: "var(--color-accent)", color: "var(--color-accent-foreground)" }}>
<tr>
{columns.map((col) => (
<th
key={col.key}
scope="col"
style={{
textAlign: col.align ?? "left",
padding: "8px 12px",
fontWeight: 700,
}}
>
{col.header}
</th>
))}
</tr>
</thead>
<tbody>
{rows.map((row, index) => (
<tr key={index} style={{ borderTop: "1px solid var(--color-border)" }}>
{columns.map((col) => (
<td
key={col.key}
style={{ padding: "8px 12px", textAlign: col.align ?? "left" }}
>
{col.render ? col.render(row) : String(row[col.key] ?? "")}
</td>
))}
</tr>
))}
</tbody>
</table>
);
}
export type DemoDialogProps = {
open: boolean;
onOpenChange: (next: boolean) => void;
title: string;
description?: string;
children?: ReactNode;
footer?: ReactNode;
};
export function DemoDialog({ open, onOpenChange, title, description, children, footer }: DemoDialogProps) {
if (!open) return null;
return (
// Plain backdrop element (no ARIA role): the dialog role belongs on the
// panel below. Backdrop dismiss closes only on a direct backdrop click, and
// Escape provides the keyboard-equivalent close.
// eslint-disable-next-line jsx-a11y/no-static-element-interactions, jsx-a11y/click-events-have-key-events
<div
style={{
position: "fixed",
inset: 0,
display: "flex",
alignItems: "center",
justifyContent: "center",
background: "rgba(0,0,0,0.45)",
zIndex: 50,
}}
onClick={(event) => {
if (event.target === event.currentTarget) onOpenChange(false);
}}
onKeyDown={(event) => {
if (event.key === "Escape") onOpenChange(false);
}}
>
<div
role="dialog"
aria-modal="true"
aria-labelledby="demo-dialog-title"
aria-describedby={description ? "demo-dialog-desc" : undefined}
style={{
background: "var(--color-card)",
color: "var(--color-card-foreground)",
borderRadius: 8,
padding: 20,
minWidth: 320,
maxWidth: 480,
boxShadow: "0 12px 32px rgba(0,0,0,0.35)",
display: "flex",
flexDirection: "column",
gap: 12,
}}
>
<h2 id="demo-dialog-title" style={{ margin: 0, fontSize: 18 }}>
{title}
</h2>
{description ? (
<p id="demo-dialog-desc" style={{ margin: 0, color: "var(--color-muted)", fontSize: 14 }}>
{description}
</p>
) : null}
{children ? <div style={{ fontSize: 14 }}>{children}</div> : null}
{footer ? <div style={{ display: "flex", justifyContent: "flex-end", gap: 8 }}>{footer}</div> : null}
</div>
</div>
);
}
/* keyframes injected once for spinner */
if (typeof document !== "undefined" && !document.getElementById("demo-spin-keyframes")) {
const style = document.createElement("style");
style.id = "demo-spin-keyframes";
style.textContent = "@keyframes demo-spin { to { transform: rotate(360deg); } }";
document.head.appendChild(style);
}
+5
View File
@@ -0,0 +1,5 @@
import { createEmptyAdapterRegistry, type UiAdapterPlaceholder } from "./types.js";
export type TableAdapter = UiAdapterPlaceholder<"tables">;
export const tableAdapters = createEmptyAdapterRegistry("tables");
+38
View File
@@ -0,0 +1,38 @@
export const uiAdapterCategories = [
"buttons",
"inputs",
"dialogs",
"tables",
"layout",
"navigation",
"feedback",
] as const;
export type UiAdapterCategory = (typeof uiAdapterCategories)[number];
export type UiAdapterLifecycle = "placeholder" | "available" | "deprecated";
export type UiAdapterDefinition<Category extends UiAdapterCategory = UiAdapterCategory> = {
readonly category: Category;
readonly name: string;
readonly status: UiAdapterLifecycle;
readonly description?: string;
readonly source?: string;
readonly plannedSource?: string;
readonly packageExport?: string;
};
export type UiAdapterPlaceholder<Category extends UiAdapterCategory = UiAdapterCategory> = UiAdapterDefinition<Category> & {
readonly status: "placeholder";
};
export type UiAdapterRegistry<Category extends UiAdapterCategory = UiAdapterCategory> = readonly UiAdapterDefinition<Category>[];
export function createEmptyAdapterRegistry<Category extends UiAdapterCategory>(_category: Category): UiAdapterRegistry<Category> {
void _category;
return [];
}
export function isUiAdapterCategory(value: string): value is UiAdapterCategory {
return (uiAdapterCategories as readonly string[]).includes(value);
}
+13
View File
@@ -0,0 +1,13 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"allowJs": false,
"allowImportingTsExtensions": false,
"lib": ["ES2022"],
"module": "NodeNext",
"moduleResolution": "NodeNext",
"noEmit": true,
"types": []
},
"include": ["src/**/*.ts"]
}