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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
5 changes: 0 additions & 5 deletions packages/dashboard/.storybook/main.ts
Original file line number Diff line number Diff line change
@@ -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;
6 changes: 0 additions & 6 deletions packages/dashboard/.storybook/preview.ts

This file was deleted.

32 changes: 32 additions & 0 deletions packages/dashboard/.storybook/preview.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="sq-surface" data-theme={theme} style={{ minHeight: "100vh", padding: "2rem" }}>
<Story />
</div>
);
},
],
};

export default preview;
12 changes: 9 additions & 3 deletions packages/dashboard/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand All @@ -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",
Expand All @@ -77,17 +78,22 @@
"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",
"vite": "^8.1.3",
"vite-plugin-dts": "^5.0.3"
},
"peerDependencies": {
"lucide-react": "*",
"react": "*",
"react-dom": "*"
},
"peerDependenciesMeta": {
"lucide-react": {
"optional": true
},
"react": {
"optional": true
},
Expand Down
41 changes: 41 additions & 0 deletions packages/dashboard/src/ui/Badge.stories.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof Badge>;

export default meta;

type Story = StoryObj<typeof meta>;

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: () => (
<div style={{ display: "flex", flexWrap: "wrap", gap: "0.5rem" }}>
{ALL.map((s) => (
<Badge key={s} state={s} dot />
))}
</div>
),
};
39 changes: 39 additions & 0 deletions packages/dashboard/src/ui/Badge.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<Badge state="completed" />);

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(
<Badge dot className="extra" style={{ marginTop: "2px" }} bg="rgb(17, 17, 17)" fg="rgb(238, 238, 238)">
Hi
</Badge>,
);

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(<Badge state={"bogus" as JobState} />);

const el = container.querySelector(".sq-badge");
// neutral label is empty
expect(el).toHaveTextContent("");
expect(el).toHaveClass("text-fg-secondary", "bg-fg-secondary/15");
});
});
72 changes: 72 additions & 0 deletions packages/dashboard/src/ui/Badge.tsx
Original file line number Diff line number Diff line change
@@ -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<string, StatePreset> = {
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 (
<span
className={cn(
"sq-badge inline-flex items-center gap-[0.35rem] h-6 px-[0.6rem] text-xs font-semibold leading-none rounded-full whitespace-nowrap",
!fg && preset.text,
!bg && preset.fill,
className,
)}
style={{ ...override, ...style }}
>
{dot && <span className="w-1.5 h-1.5 rounded-full bg-current" />}
{children ?? preset.label}
</span>
);
}
39 changes: 39 additions & 0 deletions packages/dashboard/src/ui/Button.stories.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof Button>;

export default meta;

type Story = StoryObj<typeof meta>;

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: () => (
<div style={{ display: "flex", gap: "0.5rem", flexWrap: "wrap" }}>
<Button variant="primary">Primary</Button>
<Button variant="default">Default</Button>
<Button variant="outline">Outline</Button>
<Button variant="ghost">Ghost</Button>
<Button variant="danger">Danger</Button>
</div>
),
};

export const Sizes: Story = {
render: () => (
<div style={{ display: "flex", gap: "0.5rem", alignItems: "center" }}>
<Button size="sm">Small</Button>
<Button size="md">Medium</Button>
<Button size="lg">Large</Button>
</div>
),
};
59 changes: 59 additions & 0 deletions packages/dashboard/src/ui/Button.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<Button>Go</Button>);

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(
<Button
variant="primary"
size="lg"
type="submit"
icon="Play"
iconRight="X"
disabled
block
active
className="extra"
style={{ margin: 0 }}
>
Run
</Button>,
);

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(
<Button variant="default" active>
D
</Button>,
);
const def = screen.getByRole("button", { name: "D" });
expect(def).toHaveClass("bg-surface-hover");
expect(def).not.toHaveClass("bg-surface-raised");

rerender(
<Button variant="ghost" active>
G
</Button>,
);
expect(screen.getByRole("button", { name: "G" })).toHaveClass("bg-surface-hover");
});
});
Loading