diff --git a/eslint.config.js b/eslint.config.js
index 76dedb6..f403d0e 100644
--- a/eslint.config.js
+++ b/eslint.config.js
@@ -18,6 +18,7 @@ export default tseslint.config(
"**/public/**",
"**/views/**",
"**/migrations/**",
+ "**/storybook-static/**",
"packages/docs/.vitepress/cache/**",
"packages/dashboard/vite.lib.config.ts",
"packages/dashboard/vitest.setup.ts",
diff --git a/packages/dashboard/.storybook/main.ts b/packages/dashboard/.storybook/main.ts
index ddc89f5..8c96dc7 100644
--- a/packages/dashboard/.storybook/main.ts
+++ b/packages/dashboard/.storybook/main.ts
@@ -1,14 +1,9 @@
import type { StorybookConfig } from "@storybook/react-vite";
-import tailwindcss from "@tailwindcss/vite";
const config: StorybookConfig = {
stories: ["../src/ui/**/*.stories.@(ts|tsx)"],
framework: { name: "@storybook/react-vite", options: {} },
core: { disableTelemetry: true },
- viteFinal: async (cfg) => {
- const { mergeConfig } = await import("vite");
- return mergeConfig(cfg, { plugins: [tailwindcss()] });
- },
};
export default config;
diff --git a/packages/dashboard/.storybook/preview.ts b/packages/dashboard/.storybook/preview.ts
deleted file mode 100644
index a39165c..0000000
--- a/packages/dashboard/.storybook/preview.ts
+++ /dev/null
@@ -1,6 +0,0 @@
-import type { Preview } from "@storybook/react-vite";
-import "../src/ui/styles.css";
-
-const preview: Preview = {};
-
-export default preview;
diff --git a/packages/dashboard/.storybook/preview.tsx b/packages/dashboard/.storybook/preview.tsx
new file mode 100644
index 0000000..12456aa
--- /dev/null
+++ b/packages/dashboard/.storybook/preview.tsx
@@ -0,0 +1,32 @@
+import type { Preview } from "@storybook/react-vite";
+import "../src/ui/styles.css";
+
+const preview: Preview = {
+ globalTypes: {
+ theme: {
+ description: "Design system theme",
+ defaultValue: "dark",
+ toolbar: {
+ title: "Theme",
+ icon: "circlehollow",
+ items: [
+ { value: "dark", title: "Dark" },
+ { value: "light", title: "Light" },
+ ],
+ dynamicTitle: true,
+ },
+ },
+ },
+ decorators: [
+ (Story, context) => {
+ const theme = context.globals.theme ?? "dark";
+ return (
+
+
+
+ );
+ },
+ ],
+};
+
+export default preview;
diff --git a/packages/dashboard/package.json b/packages/dashboard/package.json
index 57ff6ed..40134b8 100644
--- a/packages/dashboard/package.json
+++ b/packages/dashboard/package.json
@@ -36,13 +36,14 @@
"./ui": {
"types": "./dist/ui/index.d.ts",
"import": "./dist/ui/index.js"
- }
+ },
+ "./ui/styles.css": "./dist/ui/styles.css"
},
"files": [
"dist"
],
"scripts": {
- "build": "npx rollup -c && npx vite build --config vite.lib.config.ts",
+ "build": "npx rollup -c && npx vite build --config vite.lib.config.ts && npx @tailwindcss/cli -i ./src/ui/styles.css -o ./dist/ui/styles.css --minify",
"dev": "npx rollup -c -w",
"test": "yarn vitest run",
"storybook": "storybook dev -p 6006",
@@ -53,6 +54,7 @@
"@sidequest/backend": "workspace:*",
"@sidequest/core": "workspace:*",
"@sidequest/engine": "workspace:*",
+ "clsx": "^2.1.1",
"ejs": "^4.0.1",
"express": "^5.2.1",
"express-basic-auth": "^1.2.1",
@@ -62,7 +64,6 @@
"devDependencies": {
"@highlightjs/cdn-assets": "^11.11.1",
"@storybook/react-vite": "^10.4.6",
- "@tailwindcss/vite": "^4.3.2",
"@testing-library/dom": "^10.4.1",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
@@ -77,6 +78,7 @@
"feather-icons": "^4.29.2",
"htmx.org": "^2.0.8",
"jsdom": "^29.1.1",
+ "lucide-react": "^1.23.0",
"react": "^19.2.7",
"react-dom": "^19.2.7",
"storybook": "^10.4.6",
@@ -84,10 +86,14 @@
"vite-plugin-dts": "^5.0.3"
},
"peerDependencies": {
+ "lucide-react": "*",
"react": "*",
"react-dom": "*"
},
"peerDependenciesMeta": {
+ "lucide-react": {
+ "optional": true
+ },
"react": {
"optional": true
},
diff --git a/packages/dashboard/src/ui/Badge.stories.tsx b/packages/dashboard/src/ui/Badge.stories.tsx
new file mode 100644
index 0000000..eb8607f
--- /dev/null
+++ b/packages/dashboard/src/ui/Badge.stories.tsx
@@ -0,0 +1,41 @@
+import type { Meta, StoryObj } from "@storybook/react-vite";
+import { Badge, type JobState, type QueueState } from "./Badge";
+
+const meta = {
+ title: "Data display/Badge",
+ component: Badge,
+} satisfies Meta;
+
+export default meta;
+
+type Story = StoryObj;
+
+export const Completed: Story = { args: { state: "completed" } };
+
+export const Running: Story = { args: { state: "running", dot: true } };
+
+export const Failed: Story = { args: { state: "failed" } };
+
+const ALL: (JobState | QueueState | "neutral")[] = [
+ "completed",
+ "failed",
+ "running",
+ "claimed",
+ "waiting",
+ "scheduled",
+ "canceled",
+ "active",
+ "paused",
+ "disabled",
+ "neutral",
+];
+
+export const AllStates: Story = {
+ render: () => (
+
+ {ALL.map((s) => (
+
+ ))}
+
+ ),
+};
diff --git a/packages/dashboard/src/ui/Badge.test.tsx b/packages/dashboard/src/ui/Badge.test.tsx
new file mode 100644
index 0000000..b8039dd
--- /dev/null
+++ b/packages/dashboard/src/ui/Badge.test.tsx
@@ -0,0 +1,39 @@
+import { render } from "@testing-library/react";
+import { describe, expect, it } from "vitest";
+import { Badge, type JobState } from "./Badge";
+
+describe("Badge", () => {
+ it("renders a preset state with its default label and colors", () => {
+ const { container } = render();
+
+ const el = container.querySelector(".sq-badge");
+ expect(el).toHaveTextContent("Completed");
+ expect(el).toHaveClass("text-status-completed", "bg-status-completed/15");
+ // no dot by default
+ expect(el?.querySelector("span")).toBeNull();
+ });
+
+ it("uses defaults (neutral) and honors dot, children, bg/fg, className, style overrides", () => {
+ const { container } = render(
+
+ Hi
+ ,
+ );
+
+ const el = container.querySelector(".sq-badge");
+ expect(el).toHaveClass("extra");
+ expect(el).toHaveTextContent("Hi");
+ expect(el).toHaveStyle({ background: "rgb(17, 17, 17)", color: "rgb(238, 238, 238)" });
+ // dot rendered
+ expect(el?.querySelector("span")).not.toBeNull();
+ });
+
+ it("falls back to the neutral preset for an unknown state", () => {
+ const { container } = render();
+
+ const el = container.querySelector(".sq-badge");
+ // neutral label is empty
+ expect(el).toHaveTextContent("");
+ expect(el).toHaveClass("text-fg-secondary", "bg-fg-secondary/15");
+ });
+});
diff --git a/packages/dashboard/src/ui/Badge.tsx b/packages/dashboard/src/ui/Badge.tsx
new file mode 100644
index 0000000..b1dc51e
--- /dev/null
+++ b/packages/dashboard/src/ui/Badge.tsx
@@ -0,0 +1,72 @@
+import type { CSSProperties, ReactNode } from "react";
+import { cn } from "./cn";
+
+/** Job lifecycle states with a badge preset. */
+export type JobState = "completed" | "failed" | "running" | "claimed" | "waiting" | "scheduled" | "canceled";
+/** Queue states with a badge preset. */
+export type QueueState = "active" | "paused" | "disabled";
+
+/** Props for the {@link Badge} status pill. */
+export interface BadgeProps {
+ /** Job or queue state preset (drives color + default label). @default "neutral" */
+ state?: JobState | QueueState | "neutral";
+ /** Show a leading status dot. */
+ dot?: boolean;
+ /** Override background (caller escape hatch, applied inline). */
+ bg?: string;
+ /** Override foreground (caller escape hatch, applied inline). */
+ fg?: string;
+ children?: ReactNode;
+ className?: string;
+ style?: CSSProperties;
+}
+
+interface StatePreset {
+ /** Foreground text utility (theme-aware status token). */
+ text: string;
+ /** Soft-tint fill utility (same token at low alpha). */
+ fill: string;
+ label: string;
+}
+
+const STATE: Record = {
+ completed: { text: "text-status-completed", fill: "bg-status-completed/15", label: "Completed" },
+ failed: { text: "text-status-failed", fill: "bg-status-failed/15", label: "Failed" },
+ running: { text: "text-status-running", fill: "bg-status-running/15", label: "Running" },
+ claimed: { text: "text-status-running", fill: "bg-status-running/15", label: "Claimed" },
+ waiting: { text: "text-status-scheduled", fill: "bg-status-scheduled/15", label: "Waiting" },
+ scheduled: { text: "text-status-scheduled", fill: "bg-status-scheduled/15", label: "Scheduled" },
+ canceled: { text: "text-fg-secondary", fill: "bg-fg-secondary/15", label: "Canceled" },
+ active: { text: "text-status-completed", fill: "bg-status-completed/15", label: "Active" },
+ paused: { text: "text-status-scheduled", fill: "bg-status-scheduled/15", label: "Paused" },
+ disabled: { text: "text-status-failed", fill: "bg-status-failed/15", label: "Disabled" },
+ neutral: { text: "text-fg-secondary", fill: "bg-fg-secondary/15", label: "" },
+};
+
+/**
+ * Badge — soft-filled status pill. Presets cover every Sidequest job state
+ * (completed/failed/running/claimed/waiting/scheduled/canceled) and queue state
+ * (active/paused/disabled). Colors come from the theme-aware status tokens, so the
+ * pill adapts to the light and dark themes; pass `bg`/`fg` to override.
+ */
+export function Badge({ state = "neutral", children, dot = false, className = "", style = {}, bg, fg }: BadgeProps) {
+ const preset = STATE[state] ?? STATE.neutral;
+ const override: CSSProperties = {};
+ if (bg) override.background = bg;
+ if (fg) override.color = fg;
+
+ return (
+
+ {dot && }
+ {children ?? preset.label}
+
+ );
+}
diff --git a/packages/dashboard/src/ui/Button.stories.tsx b/packages/dashboard/src/ui/Button.stories.tsx
new file mode 100644
index 0000000..81f3fce
--- /dev/null
+++ b/packages/dashboard/src/ui/Button.stories.tsx
@@ -0,0 +1,39 @@
+import type { Meta, StoryObj } from "@storybook/react-vite";
+import { Button } from "./Button";
+
+const meta = {
+ title: "Actions/Button",
+ component: Button,
+} satisfies Meta;
+
+export default meta;
+
+type Story = StoryObj;
+
+export const Primary: Story = { args: { variant: "primary", children: "Enqueue job" } };
+
+export const WithIcon: Story = { args: { variant: "default", icon: "Play", children: "Run" } };
+
+export const Danger: Story = { args: { variant: "danger", icon: "Trash2", children: "Delete" } };
+
+export const Variants: Story = {
+ render: () => (
+
+
+
+
+
+
+
+ ),
+};
+
+export const Sizes: Story = {
+ render: () => (
+
+
+
+
+
+ ),
+};
diff --git a/packages/dashboard/src/ui/Button.test.tsx b/packages/dashboard/src/ui/Button.test.tsx
new file mode 100644
index 0000000..919a7b5
--- /dev/null
+++ b/packages/dashboard/src/ui/Button.test.tsx
@@ -0,0 +1,59 @@
+import { render, screen } from "@testing-library/react";
+import { describe, expect, it } from "vitest";
+import { Button } from "./Button";
+
+describe("Button", () => {
+ it("renders a default enabled button with no icons", () => {
+ render();
+
+ const btn = screen.getByRole("button", { name: "Go" });
+ expect(btn).toHaveClass("sq-btn", "sq-btn--default");
+ expect(btn).toHaveAttribute("type", "button");
+ expect(btn).not.toBeDisabled();
+ expect(btn.querySelectorAll("svg")).toHaveLength(0);
+ });
+
+ it("honors variant, size, type, icons, block, active, disabled, className and style", () => {
+ render(
+ ,
+ );
+
+ const btn = screen.getByRole("button", { name: "Run" });
+ expect(btn).toHaveClass("sq-btn--primary", "extra");
+ expect(btn).toHaveAttribute("type", "submit");
+ expect(btn).toBeDisabled();
+ // leading + trailing icons
+ expect(btn.querySelectorAll("svg")).toHaveLength(2);
+ });
+
+ it("fills the default and ghost variants with the hover surface when active", () => {
+ const { rerender } = render(
+ ,
+ );
+ const def = screen.getByRole("button", { name: "D" });
+ expect(def).toHaveClass("bg-surface-hover");
+ expect(def).not.toHaveClass("bg-surface-raised");
+
+ rerender(
+ ,
+ );
+ expect(screen.getByRole("button", { name: "G" })).toHaveClass("bg-surface-hover");
+ });
+});
diff --git a/packages/dashboard/src/ui/Button.tsx b/packages/dashboard/src/ui/Button.tsx
new file mode 100644
index 0000000..f6ff022
--- /dev/null
+++ b/packages/dashboard/src/ui/Button.tsx
@@ -0,0 +1,88 @@
+import type { ButtonHTMLAttributes } from "react";
+import { cn } from "./cn";
+import { Icon, type IconName } from "./Icon";
+
+/** Visual treatments for {@link Button}. */
+export type ButtonVariant = "primary" | "default" | "outline" | "ghost" | "danger";
+/** Sizes for {@link Button}. */
+export type ButtonSize = "sm" | "md" | "lg";
+
+/** Props for the {@link Button} action control. Extends the native `button` attributes. */
+export interface ButtonProps extends ButtonHTMLAttributes {
+ /** Visual treatment. @default "default" */
+ variant?: ButtonVariant;
+ /** @default "md" */
+ size?: ButtonSize;
+ /** Leading Lucide icon name. */
+ icon?: IconName;
+ /** Trailing Lucide icon name. */
+ iconRight?: IconName;
+ /** Full-width. */
+ block?: boolean;
+ /** Pressed/selected look (e.g. active pagination). */
+ active?: boolean;
+}
+
+const BASE =
+ "inline-flex items-center justify-center whitespace-nowrap select-none font-sans font-medium leading-none rounded-sm border border-transparent cursor-pointer transition-[background-color,border-color,transform] duration-[120ms] ease-standard disabled:opacity-50 disabled:cursor-not-allowed enabled:active:translate-y-[0.5px] focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-brand-ring";
+
+const SIZES: Record = {
+ sm: { class: "h-control-sm px-[0.7rem] gap-[0.35rem] text-xs", icon: 14 },
+ md: { class: "h-control px-4 gap-[0.45rem] text-sm", icon: 16 },
+ lg: { class: "h-11 px-[1.35rem] gap-2 text-base", icon: 18 },
+};
+
+const VARIANTS: Record = {
+ primary: "bg-brand text-on-brand border-brand enabled:hover:bg-brand-hover enabled:hover:border-brand-hover",
+ default: "text-fg border-edge-strong enabled:hover:bg-surface-hover",
+ outline: "bg-transparent text-fg border-edge-strong enabled:hover:bg-surface-hover",
+ ghost: "text-fg border-transparent enabled:hover:bg-surface-hover",
+ danger:
+ "bg-transparent text-status-failed border-edge-strong enabled:hover:bg-status-failed/10 enabled:hover:border-status-failed",
+};
+
+/**
+ * Button — the dashboard's primary action control. Subtle raised default, solid brand
+ * primary, hairline outline, ghost (nav), and danger treatments, with an optional
+ * leading/trailing Lucide icon.
+ */
+export function Button({
+ children,
+ variant = "default",
+ size = "md",
+ icon,
+ iconRight,
+ disabled = false,
+ block = false,
+ active = false,
+ type = "button",
+ className = "",
+ ...rest
+}: ButtonProps) {
+ const s = SIZES[size];
+ // `active` fills default/ghost with the hover surface to read as pressed/selected.
+ const activeFill = active && (variant === "default" || variant === "ghost") ? "bg-surface-hover" : "";
+ const defaultRaised = variant === "default" && !active ? "bg-surface-raised" : "";
+
+ return (
+
+ );
+}
diff --git a/packages/dashboard/src/ui/Card.stories.tsx b/packages/dashboard/src/ui/Card.stories.tsx
index 841cee0..5d911ba 100644
--- a/packages/dashboard/src/ui/Card.stories.tsx
+++ b/packages/dashboard/src/ui/Card.stories.tsx
@@ -2,7 +2,7 @@ import type { Meta, StoryObj } from "@storybook/react-vite";
import { Card } from "./Card";
const meta = {
- title: "UI/Card",
+ title: "Layout/Card",
component: Card,
parameters: { layout: "padded" },
} satisfies Meta;
@@ -11,10 +11,22 @@ export default meta;
type Story = StoryObj;
-export const Solid: Story = {
- args: { children: "Solid card" },
+export const Bare: Story = {
+ args: { children: "A bare panel with just body content." },
};
-export const Muted: Story = {
- args: { variant: "muted", children: "Muted card" },
+export const Titled: Story = {
+ args: { title: "Recent jobs", children: "Panel body." },
+};
+
+export const WithActions: Story = {
+ args: {
+ title: "Queues",
+ actions: (
+
+ ),
+ children: "Panel with header actions.",
+ },
};
diff --git a/packages/dashboard/src/ui/Card.test.tsx b/packages/dashboard/src/ui/Card.test.tsx
index 2e31015..a1e1f10 100644
--- a/packages/dashboard/src/ui/Card.test.tsx
+++ b/packages/dashboard/src/ui/Card.test.tsx
@@ -3,32 +3,48 @@ import { describe, expect, it } from "vitest";
import { Card } from "./Card";
describe("Card", () => {
- it("renders children inside a solid surface by default", () => {
- render(hello);
+ it("renders a bare panel (no header) with default padding", () => {
+ const { container } = render(body);
- const el = screen.getByText("hello");
- expect(el).toHaveClass("bg-surface");
- expect(el).toHaveClass("rounded-card");
- expect(el).toHaveClass("border-border");
+ const root = container.querySelector(".sq-card");
+ expect(root).not.toBeNull();
+ expect(screen.getByText("body")).toBeInTheDocument();
+ // no header when neither title nor actions are given
+ expect(screen.queryByRole("heading")).toBeNull();
});
- it("applies the muted variant", () => {
- render(muted);
+ it("renders title, actions, and honors custom padding/className/style/bodyStyle", () => {
+ const { container } = render(
+ refresh}
+ padding="10px"
+ className="extra"
+ style={{ color: "rgb(1, 2, 3)" }}
+ bodyStyle={{ gap: "1px" }}
+ >
+ body
+ ,
+ );
- expect(screen.getByText("muted")).toHaveClass("bg-surface-muted");
+ const root = container.querySelector(".sq-card");
+ expect(root).toHaveClass("extra");
+ expect(root).toHaveStyle({ color: "rgb(1, 2, 3)" });
+ expect(screen.getByRole("heading", { name: "Recent jobs" })).toBeInTheDocument();
+ expect(screen.getByRole("button", { name: "refresh" })).toBeInTheDocument();
});
- it("merges a custom className and forwards native div attributes", () => {
- render(
-
- content
- ,
- );
+ it("renders a header with only a title", () => {
+ render(body);
+
+ expect(screen.getByRole("heading", { name: "Only title" })).toBeInTheDocument();
+ expect(screen.queryByRole("button")).toBeNull();
+ });
+
+ it("renders a header with only actions", () => {
+ render(go}>body);
- const el = screen.getByTestId("card");
- expect(el).toHaveClass("extra");
- expect(el).toHaveClass("bg-surface");
- expect(el).toHaveAttribute("role", "group");
- expect(el).toHaveTextContent("content");
+ expect(screen.queryByRole("heading")).toBeNull();
+ expect(screen.getByRole("button", { name: "go" })).toBeInTheDocument();
});
});
diff --git a/packages/dashboard/src/ui/Card.tsx b/packages/dashboard/src/ui/Card.tsx
index 9bfebef..002b4ab 100644
--- a/packages/dashboard/src/ui/Card.tsx
+++ b/packages/dashboard/src/ui/Card.tsx
@@ -1,32 +1,48 @@
-import type { HTMLAttributes } from "react";
+import type { CSSProperties, ReactNode } from "react";
+import { cn } from "./cn";
-/** Visual variants available for {@link Card}. */
-export type CardVariant = "solid" | "muted";
-
-/** Props for the {@link Card} component. Extends the native `div` attributes. */
-export interface CardProps extends HTMLAttributes {
- /** Background treatment. Defaults to `"solid"`. */
- variant?: CardVariant;
+/** Props for the {@link Card} panel primitive. */
+export interface CardProps {
+ /** Header title. Omit for a bare panel. */
+ title?: ReactNode;
+ /** Right-aligned header actions (buttons). */
+ actions?: ReactNode;
+ /** Body padding (CSS length). @default "var(--space-6)" */
+ padding?: string;
+ children?: ReactNode;
+ className?: string;
+ style?: CSSProperties;
+ bodyStyle?: CSSProperties;
}
-const variantClasses: Record = {
- solid: "bg-surface",
- muted: "bg-surface-muted",
-};
-
/**
- * Surface container used across the Sidequest dashboard. Renders a rounded,
- * bordered panel that wraps arbitrary content and forwards any native `div`
- * attributes.
+ * Card — the dashboard's panel primitive. Optional title/actions header, then a
+ * padded body. Compose stat tiles, tables, and job details inside it.
*/
-export function Card({ variant = "solid", className, children, ...rest }: CardProps) {
- const classes = `rounded-card border border-border p-4 text-content ${variantClasses[variant]}${
- className ? ` ${className}` : ""
- }`;
-
+export function Card({
+ title,
+ actions,
+ children,
+ padding = "var(--space-6)",
+ className = "",
+ style = {},
+ bodyStyle = {},
+}: CardProps) {
return (
-
- {children}
+
+ {(title != null || actions != null) && (
+
+ {title &&
{title}
}
+ {actions &&
{actions}
}
+
+ )}
+
{children}
);
}
diff --git a/packages/dashboard/src/ui/CodeBlock.stories.tsx b/packages/dashboard/src/ui/CodeBlock.stories.tsx
new file mode 100644
index 0000000..77b84b8
--- /dev/null
+++ b/packages/dashboard/src/ui/CodeBlock.stories.tsx
@@ -0,0 +1,22 @@
+import type { Meta, StoryObj } from "@storybook/react-vite";
+import { CodeBlock } from "./CodeBlock";
+
+const meta = {
+ title: "Data display/CodeBlock",
+ component: CodeBlock,
+ parameters: { layout: "padded" },
+} satisfies Meta
;
+
+export default meta;
+
+type Story = StoryObj;
+
+export const Args: Story = {
+ args: { code: { to: "user@example.com", template: "welcome", attempts: 1 } },
+};
+
+export const StackTrace: Story = {
+ args: {
+ code: "Error: connection refused\n at SendEmailJob.run (jobs/send-email.ts:12:15)\n at Runner.perform (runner.ts:88:9)",
+ },
+};
diff --git a/packages/dashboard/src/ui/CodeBlock.test.tsx b/packages/dashboard/src/ui/CodeBlock.test.tsx
new file mode 100644
index 0000000..0b425bf
--- /dev/null
+++ b/packages/dashboard/src/ui/CodeBlock.test.tsx
@@ -0,0 +1,22 @@
+import { render, screen } from "@testing-library/react";
+import { describe, expect, it } from "vitest";
+import { CodeBlock } from "./CodeBlock";
+
+describe("CodeBlock", () => {
+ it("renders a string verbatim with defaults", () => {
+ const { container } = render();
+
+ expect(screen.getByText("hello world")).toBeInTheDocument();
+ expect(container.querySelector(".sq-codeblock")).not.toBeNull();
+ });
+
+ it("pretty-prints an object as JSON and honors language/maxHeight/className", () => {
+ const { container } = render(
+ ,
+ );
+
+ expect(container.querySelector(".sq-codeblock")).toHaveClass("extra");
+ expect(container.querySelector("code")).toHaveAttribute("data-language", "json");
+ expect(screen.getByText(/"a": 1/)).toBeInTheDocument();
+ });
+});
diff --git a/packages/dashboard/src/ui/CodeBlock.tsx b/packages/dashboard/src/ui/CodeBlock.tsx
new file mode 100644
index 0000000..56f39c6
--- /dev/null
+++ b/packages/dashboard/src/ui/CodeBlock.tsx
@@ -0,0 +1,32 @@
+import type { CSSProperties } from "react";
+import { cn } from "./cn";
+
+/** Props for the {@link CodeBlock} monospace panel. */
+export interface CodeBlockProps {
+ /** String, or an object that will be pretty-printed as JSON. */
+ code: string | object;
+ language?: string;
+ /** @default "15rem" */
+ maxHeight?: string;
+ className?: string;
+ style?: CSSProperties;
+}
+
+/**
+ * CodeBlock — near-black monospace panel for job arguments, results, and stack traces.
+ * Pass a string, or an object which is pretty-printed as JSON.
+ */
+export function CodeBlock({ code, language, maxHeight = "15rem", className = "", style = {} }: CodeBlockProps) {
+ const text = typeof code === "string" ? code : JSON.stringify(code, null, 2);
+ return (
+
+ {text}
+
+ );
+}
diff --git a/packages/dashboard/src/ui/FormField.stories.tsx b/packages/dashboard/src/ui/FormField.stories.tsx
new file mode 100644
index 0000000..1d5eb3b
--- /dev/null
+++ b/packages/dashboard/src/ui/FormField.stories.tsx
@@ -0,0 +1,30 @@
+import type { Meta, StoryObj } from "@storybook/react-vite";
+import { FormField } from "./FormField";
+import { Input } from "./Input";
+
+const meta = {
+ title: "Forms/FormField",
+ component: FormField,
+ parameters: { layout: "padded" },
+} satisfies Meta;
+
+export default meta;
+
+type Story = StoryObj;
+
+export const WithInput: Story = {
+ args: {
+ label: "Job class",
+ width: "16rem",
+ children: ,
+ },
+};
+
+export const WithHint: Story = {
+ args: {
+ label: "Cron",
+ hint: "in-memory, per instance",
+ width: "16rem",
+ children: ,
+ },
+};
diff --git a/packages/dashboard/src/ui/FormField.test.tsx b/packages/dashboard/src/ui/FormField.test.tsx
new file mode 100644
index 0000000..bb41750
--- /dev/null
+++ b/packages/dashboard/src/ui/FormField.test.tsx
@@ -0,0 +1,28 @@
+import { render, screen } from "@testing-library/react";
+import { describe, expect, it } from "vitest";
+import { FormField } from "./FormField";
+
+describe("FormField", () => {
+ it("renders only the control when no label or hint is given", () => {
+ render(
+
+
+ ,
+ );
+
+ expect(screen.getByLabelText("bare")).toBeInTheDocument();
+ expect(screen.queryByText("Name")).toBeNull();
+ });
+
+ it("renders label, hint, fixed width and className", () => {
+ const { container } = render(
+
+
+ ,
+ );
+
+ expect(screen.getByText("Name")).toBeInTheDocument();
+ expect(screen.getByText("required")).toBeInTheDocument();
+ expect(container.querySelector(".sq-field")).toHaveClass("extra");
+ });
+});
diff --git a/packages/dashboard/src/ui/FormField.tsx b/packages/dashboard/src/ui/FormField.tsx
new file mode 100644
index 0000000..d19ce80
--- /dev/null
+++ b/packages/dashboard/src/ui/FormField.tsx
@@ -0,0 +1,34 @@
+import type { CSSProperties, ReactNode } from "react";
+import { cn } from "./cn";
+
+/** Props for the {@link FormField} label + control wrapper. */
+export interface FormFieldProps {
+ /** Label text above the control. */
+ label?: string;
+ htmlFor?: string;
+ /** Fixed width, e.g. "12rem" (filter bars). */
+ width?: string;
+ /** Helper text below the control. */
+ hint?: string;
+ children?: ReactNode;
+ className?: string;
+ style?: CSSProperties;
+}
+
+/**
+ * FormField — label + control wrapper for filter bars and forms. Optional label above
+ * and hint below the control.
+ */
+export function FormField({ label, htmlFor, width, hint, className = "", style = {}, children }: FormFieldProps) {
+ return (
+
+ {label != null && (
+
+ )}
+ {children}
+ {hint != null && {hint}}
+
+ );
+}
diff --git a/packages/dashboard/src/ui/Icon.stories.tsx b/packages/dashboard/src/ui/Icon.stories.tsx
new file mode 100644
index 0000000..c35eae8
--- /dev/null
+++ b/packages/dashboard/src/ui/Icon.stories.tsx
@@ -0,0 +1,27 @@
+import type { Meta, StoryObj } from "@storybook/react-vite";
+import { Icon, type IconName } from "./Icon";
+
+const meta = {
+ title: "Actions/Icon",
+ component: Icon,
+} satisfies Meta;
+
+export default meta;
+
+type Story = StoryObj;
+
+export const Play: Story = { args: { name: "Play" } };
+
+export const Large: Story = { args: { name: "RefreshCcw", size: 32 } };
+
+const NAMES: IconName[] = ["Play", "X", "RefreshCcw", "Trash2", "Clock", "Activity", "CircleCheck", "CircleX", "Pause"];
+
+export const Gallery: Story = {
+ render: () => (
+
+ {NAMES.map((n) => (
+
+ ))}
+
+ ),
+};
diff --git a/packages/dashboard/src/ui/Icon.test.tsx b/packages/dashboard/src/ui/Icon.test.tsx
new file mode 100644
index 0000000..cfb774f
--- /dev/null
+++ b/packages/dashboard/src/ui/Icon.test.tsx
@@ -0,0 +1,29 @@
+import { render } from "@testing-library/react";
+import { describe, expect, it } from "vitest";
+import { Icon, type IconName } from "./Icon";
+
+describe("Icon", () => {
+ it("renders a lucide glyph for a known name with defaults", () => {
+ const { container } = render();
+
+ const svg = container.querySelector("svg");
+ expect(svg).not.toBeNull();
+ expect(svg).toHaveAttribute("width", "16");
+ expect(svg).toHaveAttribute("aria-hidden", "true");
+ });
+
+ it("resolves multi-segment names and honors size/strokeWidth/rest props", () => {
+ const { container } = render();
+
+ const svg = container.querySelector("svg");
+ expect(svg).toHaveAttribute("width", "24");
+ expect(svg).toHaveAttribute("stroke-width", "3");
+ expect(svg).toHaveClass("x");
+ });
+
+ it("renders nothing for an unknown name", () => {
+ const { container } = render();
+
+ expect(container.querySelector("svg")).toBeNull();
+ });
+});
diff --git a/packages/dashboard/src/ui/Icon.tsx b/packages/dashboard/src/ui/Icon.tsx
new file mode 100644
index 0000000..382c652
--- /dev/null
+++ b/packages/dashboard/src/ui/Icon.tsx
@@ -0,0 +1,28 @@
+import { icons, type LucideProps } from "lucide-react";
+import type { ComponentType } from "react";
+
+/** A Lucide icon key, e.g. "Play", "RefreshCcw", "X". */
+export type IconName = keyof typeof icons;
+
+/** Props for the {@link Icon} component. Extends the underlying Lucide SVG props. */
+export interface IconProps extends Omit {
+ /** Lucide icon key (PascalCase, as exported by lucide-react), e.g. "Play", "RefreshCcw", "X". */
+ name: IconName;
+ /** Pixel size. @default 16 */
+ size?: number;
+ /** @default 2 */
+ strokeWidth?: number;
+}
+
+/**
+ * Icon — a Lucide glyph resolved by name (Feather's successor; the design system's
+ * iconography). Renders `currentColor`, so it inherits the surrounding text color.
+ * Returns nothing for an unknown name.
+ */
+export function Icon({ name, size = 16, strokeWidth = 2, ...rest }: IconProps) {
+ const Glyph = icons[name] as ComponentType | undefined;
+ if (!Glyph) {
+ return null;
+ }
+ return ;
+}
diff --git a/packages/dashboard/src/ui/Input.stories.tsx b/packages/dashboard/src/ui/Input.stories.tsx
new file mode 100644
index 0000000..ba57f9a
--- /dev/null
+++ b/packages/dashboard/src/ui/Input.stories.tsx
@@ -0,0 +1,18 @@
+import type { Meta, StoryObj } from "@storybook/react-vite";
+import { Input } from "./Input";
+
+const meta = {
+ title: "Forms/Input",
+ component: Input,
+ parameters: { layout: "padded" },
+} satisfies Meta;
+
+export default meta;
+
+type Story = StoryObj;
+
+export const Default: Story = { args: { placeholder: "Search jobs…" } };
+
+export const Small: Story = { args: { size: "sm", placeholder: "Filter" } };
+
+export const Invalid: Story = { args: { invalid: true, defaultValue: "bad value" } };
diff --git a/packages/dashboard/src/ui/Input.test.tsx b/packages/dashboard/src/ui/Input.test.tsx
new file mode 100644
index 0000000..4ca3531
--- /dev/null
+++ b/packages/dashboard/src/ui/Input.test.tsx
@@ -0,0 +1,21 @@
+import { render, screen } from "@testing-library/react";
+import { describe, expect, it } from "vitest";
+import { Input } from "./Input";
+
+describe("Input", () => {
+ it("renders a text input with defaults", () => {
+ render();
+
+ const input = screen.getByLabelText("name");
+ expect(input).toHaveClass("sq-input");
+ expect(input).toHaveAttribute("type", "text");
+ });
+
+ it("honors type, small size, invalid, className and style", () => {
+ render();
+
+ const input = screen.getByLabelText("age");
+ expect(input).toHaveClass("sq-input", "extra");
+ expect(input).toHaveAttribute("type", "number");
+ });
+});
diff --git a/packages/dashboard/src/ui/Input.tsx b/packages/dashboard/src/ui/Input.tsx
new file mode 100644
index 0000000..d39ab3b
--- /dev/null
+++ b/packages/dashboard/src/ui/Input.tsx
@@ -0,0 +1,29 @@
+import type { InputHTMLAttributes } from "react";
+import { cn } from "./cn";
+
+/** Props for the {@link Input} field. Extends the native `input` attributes. */
+export interface InputProps extends Omit, "size"> {
+ /** @default "md" */
+ size?: "sm" | "md";
+ /** Red border for validation errors. */
+ invalid?: boolean;
+}
+
+/**
+ * Input — text/number/datetime field, matching the dashboard's filter-bar inputs.
+ * Pair with {@link FormField} for a label.
+ */
+export function Input({ type = "text", size = "md", invalid = false, className = "", ...rest }: InputProps) {
+ return (
+
+ );
+}
diff --git a/packages/dashboard/src/ui/NavItem.stories.tsx b/packages/dashboard/src/ui/NavItem.stories.tsx
new file mode 100644
index 0000000..1122dfa
--- /dev/null
+++ b/packages/dashboard/src/ui/NavItem.stories.tsx
@@ -0,0 +1,26 @@
+import type { Meta, StoryObj } from "@storybook/react-vite";
+import { NavItem } from "./NavItem";
+
+const meta = {
+ title: "Navigation/NavItem",
+ component: NavItem,
+ parameters: { layout: "padded" },
+} satisfies Meta;
+
+export default meta;
+
+type Story = StoryObj;
+
+export const Active: Story = { args: { label: "Dashboard", icon: "LayoutDashboard", active: true } };
+
+export const Inactive: Story = { args: { label: "Jobs", icon: "List" } };
+
+export const List: Story = {
+ render: () => (
+
+
+
+
+
+ ),
+};
diff --git a/packages/dashboard/src/ui/NavItem.test.tsx b/packages/dashboard/src/ui/NavItem.test.tsx
new file mode 100644
index 0000000..c244405
--- /dev/null
+++ b/packages/dashboard/src/ui/NavItem.test.tsx
@@ -0,0 +1,32 @@
+import { render, screen } from "@testing-library/react";
+import { describe, expect, it } from "vitest";
+import { NavItem } from "./NavItem";
+
+describe("NavItem", () => {
+ it("renders an inactive link with no icon by default", () => {
+ const { container } = render();
+
+ const link = screen.getByRole("link", { name: "Jobs" });
+ expect(link).toHaveClass("sq-navitem");
+ expect(link).toHaveAttribute("href", "#");
+ expect(container.querySelector("svg")).toBeNull();
+ });
+
+ it("renders an inactive link with an icon (muted icon color)", () => {
+ const { container } = render();
+
+ expect(screen.getByRole("link", { name: /Jobs/ })).toBeInTheDocument();
+ expect(container.querySelector("svg")).not.toBeNull();
+ });
+
+ it("renders an active link with an icon, href, className and style", () => {
+ const { container } = render(
+ ,
+ );
+
+ const link = screen.getByRole("link", { name: /Queues/ });
+ expect(link).toHaveClass("extra");
+ expect(link).toHaveAttribute("href", "/queues");
+ expect(container.querySelector("svg")).not.toBeNull();
+ });
+});
diff --git a/packages/dashboard/src/ui/NavItem.tsx b/packages/dashboard/src/ui/NavItem.tsx
new file mode 100644
index 0000000..ff0e415
--- /dev/null
+++ b/packages/dashboard/src/ui/NavItem.tsx
@@ -0,0 +1,39 @@
+import type { CSSProperties, MouseEvent } from "react";
+import { cn } from "./cn";
+import { Icon, type IconName } from "./Icon";
+
+/** Props for the {@link NavItem} sidebar link. */
+export interface NavItemProps {
+ label: string;
+ /** Lucide icon name. */
+ icon?: IconName;
+ active?: boolean;
+ href?: string;
+ onClick?: (e: MouseEvent) => void;
+ className?: string;
+ style?: CSSProperties;
+}
+
+/**
+ * NavItem — a single sidebar link. The active state gets a subtle raised fill and a
+ * brand left-accent; hover lightens.
+ */
+export function NavItem({ label, icon, active = false, href = "#", onClick, className = "", style = {} }: NavItemProps) {
+ return (
+
+ {icon && }
+ {label}
+
+ );
+}
diff --git a/packages/dashboard/src/ui/Pagination.stories.tsx b/packages/dashboard/src/ui/Pagination.stories.tsx
new file mode 100644
index 0000000..c46482e
--- /dev/null
+++ b/packages/dashboard/src/ui/Pagination.stories.tsx
@@ -0,0 +1,18 @@
+import type { Meta, StoryObj } from "@storybook/react-vite";
+import { Pagination } from "./Pagination";
+
+const meta = {
+ title: "Data table/Pagination",
+ component: Pagination,
+ parameters: { layout: "padded" },
+} satisfies Meta;
+
+export default meta;
+
+type Story = StoryObj;
+
+export const Middle: Story = { args: { page: 3, hasNext: true } };
+
+export const FirstPage: Story = { args: { page: 1, hasNext: true } };
+
+export const LastPage: Story = { args: { page: 9, hasNext: false } };
diff --git a/packages/dashboard/src/ui/Pagination.test.tsx b/packages/dashboard/src/ui/Pagination.test.tsx
new file mode 100644
index 0000000..93cea99
--- /dev/null
+++ b/packages/dashboard/src/ui/Pagination.test.tsx
@@ -0,0 +1,22 @@
+import { render, screen } from "@testing-library/react";
+import { describe, expect, it, vi } from "vitest";
+import { Pagination } from "./Pagination";
+
+describe("Pagination", () => {
+ it("enables both ends when there is a previous and next page", () => {
+ render();
+
+ expect(screen.getByText("Page 2")).toBeInTheDocument();
+ expect(screen.getByRole("button", { name: "«" })).toBeEnabled();
+ expect(screen.getByRole("button", { name: "»" })).toBeEnabled();
+ });
+
+ it("disables the ends on the first page with no next, and honors className/style", () => {
+ const onPrev = vi.fn();
+ const onNext = vi.fn();
+ render();
+
+ expect(screen.getByRole("button", { name: "«" })).toBeDisabled();
+ expect(screen.getByRole("button", { name: "»" })).toBeDisabled();
+ });
+});
diff --git a/packages/dashboard/src/ui/Pagination.tsx b/packages/dashboard/src/ui/Pagination.tsx
new file mode 100644
index 0000000..3c0a553
--- /dev/null
+++ b/packages/dashboard/src/ui/Pagination.tsx
@@ -0,0 +1,41 @@
+import type { CSSProperties, ReactNode } from "react";
+import { cn } from "./cn";
+
+/** Props for the {@link Pagination} control. */
+export interface PaginationProps {
+ page: number;
+ hasNext?: boolean;
+ onPrev?: () => void;
+ onNext?: () => void;
+ className?: string;
+ style?: CSSProperties;
+}
+
+/**
+ * Pagination — the dashboard's joined prev / page / next control. Buttons abut with
+ * shared borders; the ends disable when there is no previous/next page.
+ */
+export function Pagination({ page, hasNext = false, onPrev, onNext, className = "", style = {} }: PaginationProps) {
+ const btn = (content: ReactNode, enabled: boolean, onClick: (() => void) | undefined, active: boolean) => (
+
+ );
+
+ return (
+
+ {btn("«", page > 1, onPrev, false)}
+ {btn(`Page ${page}`, false, undefined, true)}
+ {btn("»", hasNext, onNext, false)}
+
+ );
+}
diff --git a/packages/dashboard/src/ui/Select.stories.tsx b/packages/dashboard/src/ui/Select.stories.tsx
new file mode 100644
index 0000000..f2803cb
--- /dev/null
+++ b/packages/dashboard/src/ui/Select.stories.tsx
@@ -0,0 +1,35 @@
+import type { Meta, StoryObj } from "@storybook/react-vite";
+import { Select } from "./Select";
+
+const meta = {
+ title: "Forms/Select",
+ component: Select,
+ parameters: { layout: "padded" },
+} satisfies Meta;
+
+export default meta;
+
+type Story = StoryObj;
+
+export const States: Story = {
+ args: {
+ defaultValue: "running",
+ options: [
+ { value: "waiting", label: "Waiting" },
+ { value: "running", label: "Running" },
+ { value: "completed", label: "Completed" },
+ { value: "failed", label: "Failed" },
+ ],
+ },
+};
+
+export const Small: Story = {
+ args: {
+ size: "sm",
+ defaultValue: "default",
+ options: [
+ { value: "default", label: "default" },
+ { value: "mailers", label: "mailers" },
+ ],
+ },
+};
diff --git a/packages/dashboard/src/ui/Select.test.tsx b/packages/dashboard/src/ui/Select.test.tsx
new file mode 100644
index 0000000..591e839
--- /dev/null
+++ b/packages/dashboard/src/ui/Select.test.tsx
@@ -0,0 +1,35 @@
+import { render, screen } from "@testing-library/react";
+import { describe, expect, it } from "vitest";
+import { Select } from "./Select";
+
+describe("Select", () => {
+ it("renders options passed as data", () => {
+ render(
+