From 76397802d00d916531c32f0cb45339f894c820d0 Mon Sep 17 00:00:00 2001 From: merencia Date: Fri, 3 Jul 2026 20:02:01 -0300 Subject: [PATCH 1/7] feat(dashboard): sync design tokens and add Card + Badge primitives syncs the sidequest.js design system: ships the tokens (colors, typography, spacing, radii, fonts) plus the .sq-* base layer as @sidequest/dashboard/ui/styles.css, and drops tailwind from the ui in favor of the design system's css-variable tokens with inline-styled components. adds the first two primitives, Card and Badge, ported faithfully from the design system, each at 100% test coverage with storybook stories rendered on the dark product surface. --- eslint.config.js | 1 + packages/dashboard/.storybook/main.ts | 5 - packages/dashboard/.storybook/preview.ts | 6 - packages/dashboard/.storybook/preview.tsx | 14 ++ packages/dashboard/package.json | 4 +- packages/dashboard/src/ui/Badge.stories.tsx | 41 ++++ packages/dashboard/src/ui/Badge.test.tsx | 39 +++ packages/dashboard/src/ui/Badge.tsx | 73 ++++++ packages/dashboard/src/ui/Card.stories.tsx | 22 +- packages/dashboard/src/ui/Card.test.tsx | 56 +++-- packages/dashboard/src/ui/Card.tsx | 84 +++++-- packages/dashboard/src/ui/index.ts | 4 +- packages/dashboard/src/ui/styles.css | 26 +- packages/dashboard/src/ui/tokens/base.css | 64 +++++ packages/dashboard/src/ui/tokens/colors.css | 137 +++++++++++ packages/dashboard/src/ui/tokens/fonts.css | 9 + packages/dashboard/src/ui/tokens/radii.css | 32 +++ packages/dashboard/src/ui/tokens/spacing.css | 24 ++ .../dashboard/src/ui/tokens/typography.css | 43 ++++ packages/dashboard/vite.lib.config.ts | 14 ++ yarn.lock | 225 +----------------- 21 files changed, 624 insertions(+), 299 deletions(-) delete mode 100644 packages/dashboard/.storybook/preview.ts create mode 100644 packages/dashboard/.storybook/preview.tsx create mode 100644 packages/dashboard/src/ui/Badge.stories.tsx create mode 100644 packages/dashboard/src/ui/Badge.test.tsx create mode 100644 packages/dashboard/src/ui/Badge.tsx create mode 100644 packages/dashboard/src/ui/tokens/base.css create mode 100644 packages/dashboard/src/ui/tokens/colors.css create mode 100644 packages/dashboard/src/ui/tokens/fonts.css create mode 100644 packages/dashboard/src/ui/tokens/radii.css create mode 100644 packages/dashboard/src/ui/tokens/spacing.css create mode 100644 packages/dashboard/src/ui/tokens/typography.css 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..5e18b62 --- /dev/null +++ b/packages/dashboard/.storybook/preview.tsx @@ -0,0 +1,14 @@ +import type { Preview } from "@storybook/react-vite"; +import "../src/ui/styles.css"; + +const preview: Preview = { + decorators: [ + (Story) => ( +
+ +
+ ), + ], +}; + +export default preview; diff --git a/packages/dashboard/package.json b/packages/dashboard/package.json index 57ff6ed..352b00c 100644 --- a/packages/dashboard/package.json +++ b/packages/dashboard/package.json @@ -36,7 +36,8 @@ "./ui": { "types": "./dist/ui/index.d.ts", "import": "./dist/ui/index.js" - } + }, + "./ui/styles.css": "./dist/ui/styles.css" }, "files": [ "dist" @@ -62,7 +63,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", 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..f0e94a6 --- /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).toHaveStyle({ color: "#7bd6a0" }); + // 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).toHaveStyle({ color: "var(--text-secondary)" }); + }); +}); diff --git a/packages/dashboard/src/ui/Badge.tsx b/packages/dashboard/src/ui/Badge.tsx new file mode 100644 index 0000000..62f5618 --- /dev/null +++ b/packages/dashboard/src/ui/Badge.tsx @@ -0,0 +1,73 @@ +import type { CSSProperties, ReactNode } from "react"; + +/** 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. */ + bg?: string; + /** Override foreground. */ + fg?: string; + children?: ReactNode; + className?: string; + style?: CSSProperties; +} + +interface StatePreset { + bg: string; + fg: string; + label: string; +} + +const STATE: Record = { + completed: { bg: "rgba(79,175,117,0.18)", fg: "#7bd6a0", label: "Completed" }, + failed: { bg: "rgba(183,82,82,0.20)", fg: "#e88d8d", label: "Failed" }, + running: { bg: "rgba(43,124,211,0.20)", fg: "#71b0f2", label: "Running" }, + claimed: { bg: "rgba(61,125,216,0.18)", fg: "#6fa8ec", label: "Claimed" }, + waiting: { bg: "rgba(210,169,76,0.18)", fg: "#e0c078", label: "Waiting" }, + scheduled: { bg: "rgba(210,169,76,0.18)", fg: "#e0c078", label: "Scheduled" }, + canceled: { bg: "rgba(154,166,186,0.16)", fg: "#b3bdcc", label: "Canceled" }, + active: { bg: "rgba(79,175,117,0.18)", fg: "#7bd6a0", label: "Active" }, + paused: { bg: "rgba(210,169,76,0.18)", fg: "#e0c078", label: "Paused" }, + disabled: { bg: "rgba(183,82,82,0.20)", fg: "#e88d8d", label: "Disabled" }, + neutral: { bg: "var(--surface-hover)", fg: "var(--text-secondary)", 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). Pass `state` for a preset or `bg`/`fg` to override. + */ +export function Badge({ state = "neutral", children, dot = false, className = "", style = {}, bg, fg }: BadgeProps) { + const preset = STATE[state] || STATE.neutral; + return ( + + {dot && } + {children ?? preset.label} + + ); +} 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..2e3a109 100644 --- a/packages/dashboard/src/ui/Card.tsx +++ b/packages/dashboard/src/ui/Card.tsx @@ -1,32 +1,70 @@ -import type { HTMLAttributes } from "react"; +import type { CSSProperties, ReactNode } from "react"; -/** 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. @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/index.ts b/packages/dashboard/src/ui/index.ts index 6374a95..7be684c 100644 --- a/packages/dashboard/src/ui/index.ts +++ b/packages/dashboard/src/ui/index.ts @@ -1,2 +1,4 @@ export { Card } from "./Card"; -export type { CardProps, CardVariant } from "./Card"; +export type { CardProps } from "./Card"; +export { Badge } from "./Badge"; +export type { BadgeProps, JobState, QueueState } from "./Badge"; diff --git a/packages/dashboard/src/ui/styles.css b/packages/dashboard/src/ui/styles.css index 7386d58..9352244 100644 --- a/packages/dashboard/src/ui/styles.css +++ b/packages/dashboard/src/ui/styles.css @@ -1,17 +1,11 @@ -@import "tailwindcss"; - -/* - * Placeholder design tokens. These are replaced by the user's Claude design - * system via DesignSync (run /design-login first). Tailwind v4 turns each - * token into utilities, e.g. --color-surface -> bg-surface / border-surface. +/* @sidequest/dashboard/ui — global stylesheet. + * Link this once. It is a manifest of @imports: every design token (fonts, colors, + * typography, spacing, radii) plus the small base layer with the `.sq-*` interaction + * classes. Synced from the Sidequest.js design system. Order matters. */ -@theme { - --color-surface: oklch(0.23 0.02 260); - --color-surface-muted: oklch(0.19 0.02 260); - --color-border: oklch(0.32 0.02 260); - --color-content: oklch(0.93 0.01 260); - --color-content-muted: oklch(0.72 0.02 260); - --color-accent: oklch(0.62 0.19 275); - - --radius-card: 0.75rem; -} +@import "./tokens/fonts.css"; +@import "./tokens/colors.css"; +@import "./tokens/typography.css"; +@import "./tokens/spacing.css"; +@import "./tokens/radii.css"; +@import "./tokens/base.css"; diff --git a/packages/dashboard/src/ui/tokens/base.css b/packages/dashboard/src/ui/tokens/base.css new file mode 100644 index 0000000..979e987 --- /dev/null +++ b/packages/dashboard/src/ui/tokens/base.css @@ -0,0 +1,64 @@ +/* Sidequest.js — Base layer + * Minimal resets + the ambient defaults specimen cards & kits rely on. Kept small; + * components style themselves via tokens. Applying `.sq-surface` to a preview root + * paints the app background + primary text so cards read like the real product. + */ + +*, *::before, *::after { box-sizing: border-box; } + +.sq-surface, +body.sq-surface { + margin: 0; + background: var(--surface-app); + color: var(--text-primary); + font-family: var(--font-sans); + font-size: var(--text-sm); + line-height: var(--leading-normal); + -webkit-font-smoothing: antialiased; + text-rendering: optimizeLegibility; +} + +.sq-brand-text { + font-family: var(--font-brand); + font-weight: var(--weight-semibold); + letter-spacing: var(--tracking-tight); +} + +.sq-mono { font-family: var(--font-mono); } + +/* Brand gradient text (wordmark / hero accents) */ +.sq-gradient-text { + background: var(--brand-gradient); + -webkit-background-clip: text; + background-clip: text; + color: transparent; +} + +/* Uppercase eyebrow / column-header treatment */ +.sq-eyebrow { + text-transform: uppercase; + letter-spacing: var(--tracking-caps); + font-size: var(--text-xs); + font-weight: var(--weight-semibold); + color: var(--text-muted); +} + +::selection { background: var(--brand-ring); color: #fff; } + +/* ---- Component interaction states (inline styles can't express :hover/:focus) ---- */ +.sq-btn:not(:disabled):active { transform: translateY(0.5px); } +.sq-btn--primary:not(:disabled):hover { background: var(--brand-primary-hover) !important; border-color: var(--brand-primary-hover) !important; } +.sq-btn--default:not(:disabled):hover { background: var(--surface-hover) !important; } +.sq-btn--outline:not(:disabled):hover { background: var(--surface-hover) !important; } +.sq-btn--ghost:not(:disabled):hover { background: var(--surface-hover) !important; } +.sq-btn--danger:not(:disabled):hover { background: rgba(183, 82, 82, 0.12) !important; border-color: var(--status-failed) !important; } +.sq-btn:focus-visible { outline: none; box-shadow: var(--focus-ring); } + +.sq-input:focus, .sq-select:focus { outline: none; border-color: var(--brand-primary) !important; box-shadow: var(--focus-ring); } +.sq-input::placeholder { color: var(--text-muted); } + +.sq-row { transition: background var(--duration-fast) var(--ease-standard); } +.sq-row:hover { background: var(--surface-hover); } + +.sq-navitem { transition: background var(--duration-fast) var(--ease-standard), color var(--duration-fast) var(--ease-standard); } +.sq-navitem:hover { background: var(--surface-hover); color: var(--text-strong); } diff --git a/packages/dashboard/src/ui/tokens/colors.css b/packages/dashboard/src/ui/tokens/colors.css new file mode 100644 index 0000000..ef10671 --- /dev/null +++ b/packages/dashboard/src/ui/tokens/colors.css @@ -0,0 +1,137 @@ +/* Sidequest.js — Color tokens + * Raw palette lifted verbatim from the dashboard's DaisyUI "sidequest-dark" theme + * (packages/dashboard/src/public/css/styles.css) plus the Tailwind accent tints the + * templates use for stat numbers. Semantic aliases sit on top and are theme-aware: + * :root is the canonical dark product theme; [data-theme="light"] is a derived light theme. + */ + +:root { + /* ---- Brand blue (from logo gradient + primary #2b7cd3) ---- */ + --sq-blue-50: #eef5fc; + --sq-blue-100: #d4e6f8; + --sq-blue-200: #a9cdf0; + --sq-blue-300: #7db8ec; + --sq-blue-400: #4d97e0; + --sq-blue-500: #2b7cd3; /* primary */ + --sq-blue-600: #2366b4; + --sq-blue-700: #1d5490; + --sq-blue-800: #1a446f; + --sq-blue-900: #16304d; + + /* Logo gradient endpoints (light cyan-blue -> deep blue) */ + --sq-grad-start: #3aa0e6; + --sq-grad-end: #2464c8; + + /* ---- Navy / ink surfaces (base-100..300 from theme) ---- */ + --sq-navy-950: #080c1a; + --sq-navy-900: #0c1226; /* base-100 — app background */ + --sq-navy-850: #101830; + --sq-navy-800: #182038; /* base-200 — raised panels */ + --sq-navy-700: #202a47; + --sq-navy-600: #2a3b5f; /* base-300 — hover / lines */ + --sq-slate-700: #2c3448; /* neutral — sidebar / cards */ + --sq-slate-600: #38425c; + + /* ---- Neutral / grey ramp (light theme + text) ---- */ + --sq-gray-50: #f7f9fc; + --sq-gray-100: #eef1f6; + --sq-gray-200: #e0e5ee; + --sq-gray-300: #cdd5e0; /* base-content on dark */ + --sq-gray-400: #9aa6ba; + --sq-gray-500: #6b7688; + --sq-gray-600: #4b5468; + --sq-gray-700: #374151; /* secondary-content */ + --sq-gray-800: #232b3d; + --sq-gray-900: #141a28; + + /* ---- Status (from theme semantic colors) ---- */ + --sq-success: #4faf75; + --sq-success-strong: #4ade80; /* text-green-400 accent */ + --sq-warning: #d2a94c; + --sq-warning-strong: #facc15; /* text-yellow-400 accent */ + --sq-error: #b75252; + --sq-error-strong: #f87171; /* text-red-400 accent */ + --sq-info: #3d7dd8; + --sq-info-strong: #60a5fa; /* text-blue-400 accent */ + --sq-accent: #4c5faf; /* accent */ + + /* ---- Code-block surface (near-black used in job view) ---- */ + --sq-code-bg: #0b0f1a; +} + +/* ============================================================ + SEMANTIC ALIASES — DARK (default, canonical product theme) + `:root` keeps dark the no-attribute default; [data-theme="dark"] + is the explicit selector so consumers can force it symmetrically. + ============================================================ */ +:root, +[data-theme="dark"] { + --surface-app: var(--sq-navy-900); + --surface-raised: var(--sq-navy-800); + --surface-card: var(--sq-slate-700); + --surface-hover: var(--sq-navy-600); + --surface-code: var(--sq-code-bg); + --surface-inset: var(--sq-navy-850); + + --text-primary: var(--sq-gray-300); + --text-strong: #ffffff; + --text-secondary: #9aa6ba; + --text-muted: #6b7688; + --text-inverse: #0c1226; + --text-link: var(--sq-blue-300); + + --border-subtle: rgba(255, 255, 255, 0.06); + --border-default: #2a3b5f; + --border-strong: #38425c; + + --brand-primary: var(--sq-blue-500); + --brand-primary-hover: var(--sq-blue-400); + --brand-primary-active: var(--sq-blue-600); + --on-brand: #ffffff; + --brand-gradient: linear-gradient(150deg, var(--sq-grad-start) 0%, var(--sq-grad-end) 100%); + --brand-ring: rgba(43, 124, 211, 0.45); + + /* status text accents on dark */ + --status-running: var(--sq-info-strong); + --status-completed: var(--sq-success-strong); + --status-failed: var(--sq-error-strong); + --status-scheduled: var(--sq-warning-strong); + + color-scheme: dark; +} + +/* ============================================================ + SEMANTIC ALIASES — LIGHT (derived; opt-in via data-theme) + ============================================================ */ +[data-theme="light"] { + --surface-app: var(--sq-gray-50); + --surface-raised: #ffffff; + --surface-card: #ffffff; + --surface-hover: var(--sq-gray-100); + --surface-code: #0b0f1a; + --surface-inset: var(--sq-gray-100); + + --text-primary: #1f2739; + --text-strong: #0c1226; + --text-secondary: #414b60; /* 7:1 on white — was #4b5468 */ + --text-muted: #616b7e; /* 4.6:1 on white — was #6b7688 (3.9:1) */ + --text-inverse: #ffffff; + --text-link: var(--sq-blue-700); /* 5.3:1 — was blue-600 (4.1:1) */ + + --border-subtle: rgba(12, 18, 38, 0.08); + --border-default: var(--sq-gray-200); + --border-strong: var(--sq-gray-300); + + --brand-primary: var(--sq-blue-500); + --brand-primary-hover: var(--sq-blue-600); + --brand-primary-active: var(--sq-blue-700); + --on-brand: #ffffff; + --brand-ring: rgba(43, 124, 211, 0.30); + + --status-running: var(--sq-info); + --status-completed: var(--sq-success); + --status-failed: var(--sq-error); + --status-scheduled: #b7902f; + + color-scheme: light; +} diff --git a/packages/dashboard/src/ui/tokens/fonts.css b/packages/dashboard/src/ui/tokens/fonts.css new file mode 100644 index 0000000..ccd08ad --- /dev/null +++ b/packages/dashboard/src/ui/tokens/fonts.css @@ -0,0 +1,9 @@ +/* Sidequest.js — Webfonts + * The shipped product renders in the platform system-ui stack (Tailwind/DaisyUI default) + * with `monospace` for code. This design system pairs that with two curated webfonts: + * - Poppins → brand wordmark + display, closest match to the rounded geometric + * lettering in the Sidequest.js logo (SUBSTITUTION — see README). + * - JetBrains Mono → code / data / job arguments (SUBSTITUTION for generic monospace). + * Both load from Google Fonts. Swap the @import for local @font-face if you self-host. + */ +@import url('https://fonts.googleapis.com/css2?family=Poppins:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600;700&display=swap'); diff --git a/packages/dashboard/src/ui/tokens/radii.css b/packages/dashboard/src/ui/tokens/radii.css new file mode 100644 index 0000000..c0d2b6a --- /dev/null +++ b/packages/dashboard/src/ui/tokens/radii.css @@ -0,0 +1,32 @@ +/* Sidequest.js — Radii & shadow tokens + * Radii lifted from DaisyUI theme: --radius-field .25rem, --radius-box/selector .5rem. + * Shadows are restrained — the product is flat/dark with hairline borders; elevation + * comes from surface lightness first, shadow second. + */ + +:root { + /* ---- Corner radii ---- */ + --radius-xs: 0.125rem; /* 2 */ + --radius-sm: 0.25rem; /* 4 — inputs, selects, badges (radius-field) */ + --radius-md: 0.5rem; /* 8 — cards, buttons, panels (radius-box) */ + --radius-lg: 0.75rem; /* 12 — large panels */ + --radius-xl: 1rem; /* 16 */ + --radius-full: 9999px; /* pills, node dots, avatars */ + + /* ---- Elevation (tuned for dark navy surfaces) ---- */ + --shadow-xs: 0 1px 2px rgba(0, 0, 0, 0.25); + --shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.30), 0 1px 2px rgba(0, 0, 0, 0.20); + --shadow-md: 0 4px 12px rgba(0, 0, 0, 0.35); + --shadow-lg: 0 10px 28px rgba(0, 0, 0, 0.45); + --shadow-glow: 0 0 0 1px var(--brand-ring), 0 6px 20px rgba(43, 124, 211, 0.25); + + /* ---- Focus ring ---- */ + --focus-ring: 0 0 0 3px var(--brand-ring); + + /* ---- Motion ---- */ + --ease-standard: cubic-bezier(0.4, 0, 0.2, 1); + --ease-out: cubic-bezier(0.16, 1, 0.3, 1); + --duration-fast: 120ms; + --duration-base: 180ms; + --duration-slow: 280ms; +} diff --git a/packages/dashboard/src/ui/tokens/spacing.css b/packages/dashboard/src/ui/tokens/spacing.css new file mode 100644 index 0000000..6663e68 --- /dev/null +++ b/packages/dashboard/src/ui/tokens/spacing.css @@ -0,0 +1,24 @@ +/* Sidequest.js — Spacing & sizing tokens + * 4px base grid. Dashboard uses Tailwind's scale (p-4, gap-4, p-6) — mirrored here. + */ + +:root { + --space-0: 0; + --space-1: 0.25rem; /* 4 */ + --space-2: 0.5rem; /* 8 */ + --space-3: 0.75rem; /* 12 */ + --space-4: 1rem; /* 16 — default control / card padding step */ + --space-5: 1.25rem; /* 20 */ + --space-6: 1.5rem; /* 24 — main content padding, card body */ + --space-8: 2rem; /* 32 — section gap */ + --space-10: 2.5rem; /* 40 */ + --space-12: 3rem; /* 48 */ + --space-16: 4rem; /* 64 */ + --space-20: 5rem; /* 80 */ + + /* ---- Layout constants (from dashboard) ---- */ + --sidebar-width: 16rem; /* w-64 */ + --content-max: 80rem; + --control-height: 2.5rem; /* 40px — inputs / selects / md button */ + --control-height-sm: 2rem;/* 32px — btn-sm */ +} diff --git a/packages/dashboard/src/ui/tokens/typography.css b/packages/dashboard/src/ui/tokens/typography.css new file mode 100644 index 0000000..2e3062d --- /dev/null +++ b/packages/dashboard/src/ui/tokens/typography.css @@ -0,0 +1,43 @@ +/* Sidequest.js — Typography tokens + * Product UI renders in the platform system-ui stack (faithful to the dashboard). + * Poppins is reserved for the brand wordmark + display; JetBrains Mono for code/data. + */ + +:root { + /* ---- Families ---- */ + --font-sans: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, + "Helvetica Neue", Arial, "Noto Sans", sans-serif; + --font-brand: "Poppins", var(--font-sans); + --font-mono: "JetBrains Mono", ui-monospace, SFMono-Regular, "SF Mono", + Menlo, Consolas, "Liberation Mono", monospace; + + /* ---- Type scale (px in rem) ---- */ + --text-2xs: 0.6875rem; /* 11px — micro labels, stack traces */ + --text-xs: 0.75rem; /* 12px — badges, table meta */ + --text-sm: 0.875rem; /* 14px — body, table cells, controls */ + --text-base:1rem; /* 16px — default */ + --text-lg: 1.125rem; /* 18px — card titles */ + --text-xl: 1.25rem; /* 20px — sidebar brand */ + --text-2xl: 1.5rem; /* 24px — page headings */ + --text-3xl: 1.875rem; /* 30px — stat numbers / marketing */ + --text-4xl: 2.5rem; /* 40px — hero */ + --text-5xl: 3.5rem; /* 56px — hero display */ + + /* ---- Weights ---- */ + --weight-regular: 400; + --weight-medium: 500; + --weight-semibold: 600; + --weight-bold: 700; + + /* ---- Line heights ---- */ + --leading-tight: 1.15; + --leading-snug: 1.35; + --leading-normal: 1.55; + --leading-relaxed: 1.7; + + /* ---- Letter spacing ---- */ + --tracking-tight: -0.02em; + --tracking-normal: 0; + --tracking-wide: 0.02em; + --tracking-caps: 0.06em; /* uppercase eyebrows / table headers */ +} diff --git a/packages/dashboard/vite.lib.config.ts b/packages/dashboard/vite.lib.config.ts index e0867b5..531b761 100644 --- a/packages/dashboard/vite.lib.config.ts +++ b/packages/dashboard/vite.lib.config.ts @@ -1,10 +1,23 @@ import react from "@vitejs/plugin-react"; +import { cpSync } from "node:fs"; import { resolve } from "node:path"; import { defineConfig } from "vite"; import dts from "vite-plugin-dts"; const pkgDir = import.meta.dirname; +// The design tokens ship as plain CSS (consumers link `@sidequest/dashboard/ui/styles.css`). +// The lib entry stays JS-only, so copy the token stylesheet tree into dist after the build. +function copyTokenCss() { + return { + name: "sq-copy-ui-css", + closeBundle() { + cpSync(resolve(pkgDir, "src/ui/styles.css"), resolve(pkgDir, "dist/ui/styles.css")); + cpSync(resolve(pkgDir, "src/ui/tokens"), resolve(pkgDir, "dist/ui/tokens"), { recursive: true }); + }, + }; +} + // Vite builds the `@sidequest/dashboard/ui` component library. The Express // server keeps its own Rollup build (see rollup.config.js) untouched. export default defineConfig({ @@ -17,6 +30,7 @@ export default defineConfig({ exclude: ["src/ui/**/*.test.tsx", "src/ui/**/*.stories.tsx"], outDir: resolve(pkgDir, "dist/ui"), }), + copyTokenCss(), ], build: { outDir: resolve(pkgDir, "dist/ui"), diff --git a/yarn.lock b/yarn.lock index c3fd5de..56098a9 100644 --- a/yarn.lock +++ b/yarn.lock @@ -902,16 +902,6 @@ __metadata: languageName: node linkType: hard -"@emnapi/core@npm:^1.11.1": - version: 1.11.2 - resolution: "@emnapi/core@npm:1.11.2" - dependencies: - "@emnapi/wasi-threads": "npm:1.2.2" - tslib: "npm:^2.4.0" - checksum: 10c0/424ca1607f498e524eb58db1095e6cc2082986edfd84a74b116d4e2c6e43b5e9d557ea7f0d7b9adf7f6d5023e25ac2ed6bd08caf0043d3d8602598d79579c2cd - languageName: node - linkType: hard - "@emnapi/core@npm:^1.7.1": version: 1.8.1 resolution: "@emnapi/core@npm:1.8.1" @@ -940,15 +930,6 @@ __metadata: languageName: node linkType: hard -"@emnapi/runtime@npm:^1.11.1": - version: 1.11.2 - resolution: "@emnapi/runtime@npm:1.11.2" - dependencies: - tslib: "npm:^2.4.0" - checksum: 10c0/d8d500059fe9c0864571c79ce29439c1b6fb44d7ec4ac865a2aaaa7469194e5d91ebdc820cabd37c3d373478b725a8b60771733e4743cfef41fc86d9a1f2eab0 - languageName: node - linkType: hard - "@emnapi/runtime@npm:^1.7.1": version: 1.8.1 resolution: "@emnapi/runtime@npm:1.8.1" @@ -976,7 +957,7 @@ __metadata: languageName: node linkType: hard -"@emnapi/wasi-threads@npm:1.2.2, @emnapi/wasi-threads@npm:^1.2.2": +"@emnapi/wasi-threads@npm:1.2.2": version: 1.2.2 resolution: "@emnapi/wasi-threads@npm:1.2.2" dependencies: @@ -4010,7 +3991,6 @@ __metadata: "@sidequest/core": "workspace:*" "@sidequest/engine": "workspace:*" "@storybook/react-vite": "npm:^10.4.6" - "@tailwindcss/vite": "npm:^4.3.2" "@testing-library/dom": "npm:^10.4.1" "@testing-library/jest-dom": "npm:^6.9.1" "@testing-library/react": "npm:^16.3.2" @@ -4359,21 +4339,6 @@ __metadata: languageName: node linkType: hard -"@tailwindcss/node@npm:4.3.2": - version: 4.3.2 - resolution: "@tailwindcss/node@npm:4.3.2" - dependencies: - "@jridgewell/remapping": "npm:^2.3.5" - enhanced-resolve: "npm:5.21.6" - jiti: "npm:^2.7.0" - lightningcss: "npm:1.32.0" - magic-string: "npm:^0.30.21" - source-map-js: "npm:^1.2.1" - tailwindcss: "npm:4.3.2" - checksum: 10c0/3b83caeb3a913b1a537640bbe4df3ac68dcfba1c95b5c2dedc3788bf6437b6ebb06512ec31ae1fb3aaf570eee0a2672e35ef872969a441e07861c2d248a9da3e - languageName: node - linkType: hard - "@tailwindcss/oxide-android-arm64@npm:4.1.18": version: 4.1.18 resolution: "@tailwindcss/oxide-android-arm64@npm:4.1.18" @@ -4381,13 +4346,6 @@ __metadata: languageName: node linkType: hard -"@tailwindcss/oxide-android-arm64@npm:4.3.2": - version: 4.3.2 - resolution: "@tailwindcss/oxide-android-arm64@npm:4.3.2" - conditions: os=android & cpu=arm64 - languageName: node - linkType: hard - "@tailwindcss/oxide-darwin-arm64@npm:4.1.18": version: 4.1.18 resolution: "@tailwindcss/oxide-darwin-arm64@npm:4.1.18" @@ -4395,13 +4353,6 @@ __metadata: languageName: node linkType: hard -"@tailwindcss/oxide-darwin-arm64@npm:4.3.2": - version: 4.3.2 - resolution: "@tailwindcss/oxide-darwin-arm64@npm:4.3.2" - conditions: os=darwin & cpu=arm64 - languageName: node - linkType: hard - "@tailwindcss/oxide-darwin-x64@npm:4.1.18": version: 4.1.18 resolution: "@tailwindcss/oxide-darwin-x64@npm:4.1.18" @@ -4409,13 +4360,6 @@ __metadata: languageName: node linkType: hard -"@tailwindcss/oxide-darwin-x64@npm:4.3.2": - version: 4.3.2 - resolution: "@tailwindcss/oxide-darwin-x64@npm:4.3.2" - conditions: os=darwin & cpu=x64 - languageName: node - linkType: hard - "@tailwindcss/oxide-freebsd-x64@npm:4.1.18": version: 4.1.18 resolution: "@tailwindcss/oxide-freebsd-x64@npm:4.1.18" @@ -4423,13 +4367,6 @@ __metadata: languageName: node linkType: hard -"@tailwindcss/oxide-freebsd-x64@npm:4.3.2": - version: 4.3.2 - resolution: "@tailwindcss/oxide-freebsd-x64@npm:4.3.2" - conditions: os=freebsd & cpu=x64 - languageName: node - linkType: hard - "@tailwindcss/oxide-linux-arm-gnueabihf@npm:4.1.18": version: 4.1.18 resolution: "@tailwindcss/oxide-linux-arm-gnueabihf@npm:4.1.18" @@ -4437,13 +4374,6 @@ __metadata: languageName: node linkType: hard -"@tailwindcss/oxide-linux-arm-gnueabihf@npm:4.3.2": - version: 4.3.2 - resolution: "@tailwindcss/oxide-linux-arm-gnueabihf@npm:4.3.2" - conditions: os=linux & cpu=arm - languageName: node - linkType: hard - "@tailwindcss/oxide-linux-arm64-gnu@npm:4.1.18": version: 4.1.18 resolution: "@tailwindcss/oxide-linux-arm64-gnu@npm:4.1.18" @@ -4451,13 +4381,6 @@ __metadata: languageName: node linkType: hard -"@tailwindcss/oxide-linux-arm64-gnu@npm:4.3.2": - version: 4.3.2 - resolution: "@tailwindcss/oxide-linux-arm64-gnu@npm:4.3.2" - conditions: os=linux & cpu=arm64 & libc=glibc - languageName: node - linkType: hard - "@tailwindcss/oxide-linux-arm64-musl@npm:4.1.18": version: 4.1.18 resolution: "@tailwindcss/oxide-linux-arm64-musl@npm:4.1.18" @@ -4465,13 +4388,6 @@ __metadata: languageName: node linkType: hard -"@tailwindcss/oxide-linux-arm64-musl@npm:4.3.2": - version: 4.3.2 - resolution: "@tailwindcss/oxide-linux-arm64-musl@npm:4.3.2" - conditions: os=linux & cpu=arm64 & libc=musl - languageName: node - linkType: hard - "@tailwindcss/oxide-linux-x64-gnu@npm:4.1.18": version: 4.1.18 resolution: "@tailwindcss/oxide-linux-x64-gnu@npm:4.1.18" @@ -4479,13 +4395,6 @@ __metadata: languageName: node linkType: hard -"@tailwindcss/oxide-linux-x64-gnu@npm:4.3.2": - version: 4.3.2 - resolution: "@tailwindcss/oxide-linux-x64-gnu@npm:4.3.2" - conditions: os=linux & cpu=x64 & libc=glibc - languageName: node - linkType: hard - "@tailwindcss/oxide-linux-x64-musl@npm:4.1.18": version: 4.1.18 resolution: "@tailwindcss/oxide-linux-x64-musl@npm:4.1.18" @@ -4493,13 +4402,6 @@ __metadata: languageName: node linkType: hard -"@tailwindcss/oxide-linux-x64-musl@npm:4.3.2": - version: 4.3.2 - resolution: "@tailwindcss/oxide-linux-x64-musl@npm:4.3.2" - conditions: os=linux & cpu=x64 & libc=musl - languageName: node - linkType: hard - "@tailwindcss/oxide-wasm32-wasi@npm:4.1.18": version: 4.1.18 resolution: "@tailwindcss/oxide-wasm32-wasi@npm:4.1.18" @@ -4514,20 +4416,6 @@ __metadata: languageName: node linkType: hard -"@tailwindcss/oxide-wasm32-wasi@npm:4.3.2": - version: 4.3.2 - resolution: "@tailwindcss/oxide-wasm32-wasi@npm:4.3.2" - dependencies: - "@emnapi/core": "npm:^1.11.1" - "@emnapi/runtime": "npm:^1.11.1" - "@emnapi/wasi-threads": "npm:^1.2.2" - "@napi-rs/wasm-runtime": "npm:^1.1.4" - "@tybys/wasm-util": "npm:^0.10.2" - tslib: "npm:^2.8.1" - conditions: cpu=wasm32 - languageName: node - linkType: hard - "@tailwindcss/oxide-win32-arm64-msvc@npm:4.1.18": version: 4.1.18 resolution: "@tailwindcss/oxide-win32-arm64-msvc@npm:4.1.18" @@ -4535,13 +4423,6 @@ __metadata: languageName: node linkType: hard -"@tailwindcss/oxide-win32-arm64-msvc@npm:4.3.2": - version: 4.3.2 - resolution: "@tailwindcss/oxide-win32-arm64-msvc@npm:4.3.2" - conditions: os=win32 & cpu=arm64 - languageName: node - linkType: hard - "@tailwindcss/oxide-win32-x64-msvc@npm:4.1.18": version: 4.1.18 resolution: "@tailwindcss/oxide-win32-x64-msvc@npm:4.1.18" @@ -4549,13 +4430,6 @@ __metadata: languageName: node linkType: hard -"@tailwindcss/oxide-win32-x64-msvc@npm:4.3.2": - version: 4.3.2 - resolution: "@tailwindcss/oxide-win32-x64-msvc@npm:4.3.2" - conditions: os=win32 & cpu=x64 - languageName: node - linkType: hard - "@tailwindcss/oxide@npm:4.1.18": version: 4.1.18 resolution: "@tailwindcss/oxide@npm:4.1.18" @@ -4601,51 +4475,6 @@ __metadata: languageName: node linkType: hard -"@tailwindcss/oxide@npm:4.3.2": - version: 4.3.2 - resolution: "@tailwindcss/oxide@npm:4.3.2" - dependencies: - "@tailwindcss/oxide-android-arm64": "npm:4.3.2" - "@tailwindcss/oxide-darwin-arm64": "npm:4.3.2" - "@tailwindcss/oxide-darwin-x64": "npm:4.3.2" - "@tailwindcss/oxide-freebsd-x64": "npm:4.3.2" - "@tailwindcss/oxide-linux-arm-gnueabihf": "npm:4.3.2" - "@tailwindcss/oxide-linux-arm64-gnu": "npm:4.3.2" - "@tailwindcss/oxide-linux-arm64-musl": "npm:4.3.2" - "@tailwindcss/oxide-linux-x64-gnu": "npm:4.3.2" - "@tailwindcss/oxide-linux-x64-musl": "npm:4.3.2" - "@tailwindcss/oxide-wasm32-wasi": "npm:4.3.2" - "@tailwindcss/oxide-win32-arm64-msvc": "npm:4.3.2" - "@tailwindcss/oxide-win32-x64-msvc": "npm:4.3.2" - dependenciesMeta: - "@tailwindcss/oxide-android-arm64": - optional: true - "@tailwindcss/oxide-darwin-arm64": - optional: true - "@tailwindcss/oxide-darwin-x64": - optional: true - "@tailwindcss/oxide-freebsd-x64": - optional: true - "@tailwindcss/oxide-linux-arm-gnueabihf": - optional: true - "@tailwindcss/oxide-linux-arm64-gnu": - optional: true - "@tailwindcss/oxide-linux-arm64-musl": - optional: true - "@tailwindcss/oxide-linux-x64-gnu": - optional: true - "@tailwindcss/oxide-linux-x64-musl": - optional: true - "@tailwindcss/oxide-wasm32-wasi": - optional: true - "@tailwindcss/oxide-win32-arm64-msvc": - optional: true - "@tailwindcss/oxide-win32-x64-msvc": - optional: true - checksum: 10c0/9c82a1eb1e6cd7a29118a4f9cfae5fbf83950757cfbb5e51fb212b1e17c9195632ce245f873aee6ad3133921dbb617d050d7f12530905ab1f2683d41909b1de4 - languageName: node - linkType: hard - "@tailwindcss/postcss@npm:^4.1.18": version: 4.1.18 resolution: "@tailwindcss/postcss@npm:4.1.18" @@ -4659,19 +4488,6 @@ __metadata: languageName: node linkType: hard -"@tailwindcss/vite@npm:^4.3.2": - version: 4.3.2 - resolution: "@tailwindcss/vite@npm:4.3.2" - dependencies: - "@tailwindcss/node": "npm:4.3.2" - "@tailwindcss/oxide": "npm:4.3.2" - tailwindcss: "npm:4.3.2" - peerDependencies: - vite: ^5.2.0 || ^6 || ^7 || ^8 - checksum: 10c0/90fe2b5cd05b29fb555a3fc554d26187b9e9040ed762ce96e7f39490e0fac65c990fb873fb2b458385c90f70220e4ded1a898513f03b7c87e6b11bb9292ba92f - languageName: node - linkType: hard - "@testing-library/dom@npm:^10.4.1": version: 10.4.1 resolution: "@testing-library/dom@npm:10.4.1" @@ -4771,7 +4587,7 @@ __metadata: languageName: node linkType: hard -"@tybys/wasm-util@npm:^0.10.2, @tybys/wasm-util@npm:^0.10.3": +"@tybys/wasm-util@npm:^0.10.3": version: 0.10.3 resolution: "@tybys/wasm-util@npm:0.10.3" dependencies: @@ -7852,16 +7668,6 @@ __metadata: languageName: node linkType: hard -"enhanced-resolve@npm:5.21.6": - version: 5.21.6 - resolution: "enhanced-resolve@npm:5.21.6" - dependencies: - graceful-fs: "npm:^4.2.4" - tapable: "npm:^2.3.3" - checksum: 10c0/4991b0ee020ce534c824e8f191a2cf068b9206dc6c9aef5797d41db5c45036c868145c2f0badee6084de2cc3703c7ca76426b254c6b2eac0e0e670ab4a33e528 - languageName: node - linkType: hard - "enhanced-resolve@npm:^5.18.3": version: 5.18.3 resolution: "enhanced-resolve@npm:5.18.3" @@ -10621,15 +10427,6 @@ __metadata: languageName: node linkType: hard -"jiti@npm:^2.7.0": - version: 2.7.0 - resolution: "jiti@npm:2.7.0" - bin: - jiti: lib/jiti-cli.mjs - checksum: 10c0/1b1e2310a490dce1aeea3da5f5dfe18273516c20ce48be2e98eb8ea452d5f3dcc8fd0cfd6d28b4052a24c5dbab6e3089b2d7e79f0bce7915b10d750929563c42 - languageName: node - linkType: hard - "js-tokens@npm:^10.0.0": version: 10.0.0 resolution: "js-tokens@npm:10.0.0" @@ -11242,7 +11039,7 @@ __metadata: languageName: node linkType: hard -"lightningcss@npm:1.32.0, lightningcss@npm:^1.32.0": +"lightningcss@npm:^1.32.0": version: 1.32.0 resolution: "lightningcss@npm:1.32.0" dependencies: @@ -16028,13 +15825,6 @@ __metadata: languageName: node linkType: hard -"tailwindcss@npm:4.3.2": - version: 4.3.2 - resolution: "tailwindcss@npm:4.3.2" - checksum: 10c0/5a377846f77590812df234446175c4c2a45111a9626fd135e46f4552a33faee0d10c4e51aa5bbec955583265d48b380fb66c96f5da2775c961d4c26157617d19 - languageName: node - linkType: hard - "tapable@npm:^2.2.0": version: 2.2.3 resolution: "tapable@npm:2.2.3" @@ -16042,13 +15832,6 @@ __metadata: languageName: node linkType: hard -"tapable@npm:^2.3.3": - version: 2.3.3 - resolution: "tapable@npm:2.3.3" - checksum: 10c0/47992e861053f861154e92fb4a98ac4ab47b6463717e60792dd1e8c755da0c4964cd8bb68c308a9066d6da89000b6310457b4d5d985c30de4ccc29066068cc17 - languageName: node - linkType: hard - "tar-fs@npm:^2.0.0": version: 2.1.3 resolution: "tar-fs@npm:2.1.3" @@ -16387,7 +16170,7 @@ __metadata: languageName: node linkType: hard -"tslib@npm:^2.0.1, tslib@npm:^2.1.0, tslib@npm:^2.4.0, tslib@npm:^2.8.1": +"tslib@npm:^2.0.1, tslib@npm:^2.1.0, tslib@npm:^2.4.0": version: 2.8.1 resolution: "tslib@npm:2.8.1" checksum: 10c0/9c4759110a19c53f992d9aae23aac5ced636e99887b51b9e61def52611732872ff7668757d4e4c61f19691e36f4da981cd9485e869b4a7408d689f6bf1f14e62 From e905bccfe008c36a5b620e3e3a8af127ab135c21 Mon Sep 17 00:00:00 2001 From: merencia Date: Fri, 3 Jul 2026 20:13:06 -0300 Subject: [PATCH 2/7] feat(dashboard): add Icon and Button primitives Icon resolves lucide-react glyphs by name (the design system's iconography), keeping the string-name api; Button ports the design system's action control with primary/default/outline/ghost/danger variants, three sizes, and optional leading/trailing icons. both at 100% coverage with stories. lucide-react is an optional peer, external to the lib build. --- packages/dashboard/package.json | 5 + packages/dashboard/src/ui/Button.stories.tsx | 39 +++++++ packages/dashboard/src/ui/Button.test.tsx | 41 +++++++ packages/dashboard/src/ui/Button.tsx | 111 +++++++++++++++++++ packages/dashboard/src/ui/Icon.stories.tsx | 27 +++++ packages/dashboard/src/ui/Icon.test.tsx | 29 +++++ packages/dashboard/src/ui/Icon.tsx | 34 ++++++ packages/dashboard/src/ui/index.ts | 4 + packages/dashboard/vite.lib.config.ts | 2 +- yarn.lock | 11 ++ 10 files changed, 302 insertions(+), 1 deletion(-) create mode 100644 packages/dashboard/src/ui/Button.stories.tsx create mode 100644 packages/dashboard/src/ui/Button.test.tsx create mode 100644 packages/dashboard/src/ui/Button.tsx create mode 100644 packages/dashboard/src/ui/Icon.stories.tsx create mode 100644 packages/dashboard/src/ui/Icon.test.tsx create mode 100644 packages/dashboard/src/ui/Icon.tsx diff --git a/packages/dashboard/package.json b/packages/dashboard/package.json index 352b00c..e6d102d 100644 --- a/packages/dashboard/package.json +++ b/packages/dashboard/package.json @@ -77,6 +77,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 +85,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/Button.stories.tsx b/packages/dashboard/src/ui/Button.stories.tsx new file mode 100644 index 0000000..72dc328 --- /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: "trash-2", 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..239ace5 --- /dev/null +++ b/packages/dashboard/src/ui/Button.test.tsx @@ -0,0 +1,41 @@ +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); + }); +}); diff --git a/packages/dashboard/src/ui/Button.tsx b/packages/dashboard/src/ui/Button.tsx new file mode 100644 index 0000000..4c9feab --- /dev/null +++ b/packages/dashboard/src/ui/Button.tsx @@ -0,0 +1,111 @@ +import type { ButtonHTMLAttributes, CSSProperties } from "react"; +import { Icon } 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?: string; + /** Trailing Lucide icon name. */ + iconRight?: string; + /** Full-width. */ + block?: boolean; + /** Pressed/selected look (e.g. active pagination). */ + active?: boolean; +} + +interface SizeSpec { + height: string; + padding: string; + font: string; + gap: string; + icon: number; +} + +const SIZES: Record = { + sm: { height: "var(--control-height-sm)", padding: "0 0.7rem", font: "var(--text-xs)", gap: "0.35rem", icon: 14 }, + md: { height: "var(--control-height)", padding: "0 1rem", font: "var(--text-sm)", gap: "0.45rem", icon: 16 }, + lg: { height: "2.75rem", padding: "0 1.35rem", font: "var(--text-base)", gap: "0.5rem", icon: 18 }, +}; + +/** + * 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 = "", + style = {}, + ...rest +}: ButtonProps) { + const s = SIZES[size]; + + const base: CSSProperties = { + display: block ? "flex" : "inline-flex", + width: block ? "100%" : "auto", + alignItems: "center", + justifyContent: "center", + gap: s.gap, + height: s.height, + padding: s.padding, + fontFamily: "var(--font-sans)", + fontSize: s.font, + fontWeight: "var(--weight-medium)", + lineHeight: 1, + whiteSpace: "nowrap", + borderRadius: "var(--radius-sm)", + border: "1px solid transparent", + cursor: disabled ? "not-allowed" : "pointer", + opacity: disabled ? 0.5 : 1, + transition: + "background var(--duration-fast) var(--ease-standard), border-color var(--duration-fast) var(--ease-standard), transform var(--duration-fast) var(--ease-standard)", + userSelect: "none", + }; + + const variants: Record = { + primary: { background: "var(--brand-primary)", color: "var(--on-brand)", borderColor: "var(--brand-primary)" }, + default: { + background: active ? "var(--surface-hover)" : "var(--surface-raised)", + color: "var(--text-primary)", + borderColor: "var(--border-strong)", + }, + outline: { background: "transparent", color: "var(--text-primary)", borderColor: "var(--border-strong)" }, + ghost: { + background: active ? "var(--surface-hover)" : "transparent", + color: "var(--text-primary)", + borderColor: "transparent", + }, + danger: { background: "transparent", color: "var(--status-failed)", borderColor: "var(--border-strong)" }, + }; + + return ( + + ); +} diff --git a/packages/dashboard/src/ui/Icon.stories.tsx b/packages/dashboard/src/ui/Icon.stories.tsx new file mode 100644 index 0000000..790360a --- /dev/null +++ b/packages/dashboard/src/ui/Icon.stories.tsx @@ -0,0 +1,27 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { Icon } 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: "refresh-ccw", size: 32 } }; + +const NAMES = ["play", "x", "refresh-ccw", "trash-2", "clock", "activity", "check-circle", "x-circle", "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..6d725ee --- /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 } 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..bb77a39 --- /dev/null +++ b/packages/dashboard/src/ui/Icon.tsx @@ -0,0 +1,34 @@ +import { icons, type LucideProps } from "lucide-react"; +import type { ComponentType } from "react"; + +/** Props for the {@link Icon} component. Extends the underlying Lucide SVG props. */ +export interface IconProps extends Omit { + /** Lucide icon name, kebab or space separated, e.g. "play", "x", "refresh-ccw". */ + name: string; + /** Pixel size. @default 16 */ + size?: number; + /** @default 2 */ + strokeWidth?: number; +} + +/** "refresh-ccw" | "refresh ccw" -> "RefreshCcw" (the Lucide component key). */ +function toPascalCase(name: string): string { + return name + .split(/[-_ ]+/) + .filter(Boolean) + .map((part) => part[0].toUpperCase() + part.slice(1)) + .join(""); +} + +/** + * 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[toPascalCase(name) as keyof typeof icons] as ComponentType | undefined; + if (!Glyph) { + return null; + } + return ; +} diff --git a/packages/dashboard/src/ui/index.ts b/packages/dashboard/src/ui/index.ts index 7be684c..6e07bb4 100644 --- a/packages/dashboard/src/ui/index.ts +++ b/packages/dashboard/src/ui/index.ts @@ -2,3 +2,7 @@ export { Card } from "./Card"; export type { CardProps } from "./Card"; export { Badge } from "./Badge"; export type { BadgeProps, JobState, QueueState } from "./Badge"; +export { Icon } from "./Icon"; +export type { IconProps } from "./Icon"; +export { Button } from "./Button"; +export type { ButtonProps, ButtonSize, ButtonVariant } from "./Button"; diff --git a/packages/dashboard/vite.lib.config.ts b/packages/dashboard/vite.lib.config.ts index 531b761..3376f9e 100644 --- a/packages/dashboard/vite.lib.config.ts +++ b/packages/dashboard/vite.lib.config.ts @@ -41,7 +41,7 @@ export default defineConfig({ fileName: () => "index.js", }, rollupOptions: { - external: ["react", "react-dom", "react/jsx-runtime"], + external: ["react", "react-dom", "react/jsx-runtime", "lucide-react"], }, }, }); diff --git a/yarn.lock b/yarn.lock index 56098a9..730144f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4009,6 +4009,7 @@ __metadata: feather-icons: "npm:^4.29.2" htmx.org: "npm:^2.0.8" jsdom: "npm:^29.1.1" + lucide-react: "npm:^1.23.0" morgan: "npm:^1.10.1" react: "npm:^19.2.7" react-dom: "npm:^19.2.7" @@ -4016,6 +4017,7 @@ __metadata: vite: "npm:^8.1.3" vite-plugin-dts: "npm:^5.0.3" peerDependencies: + lucide-react: "*" react: "*" react-dom: "*" peerDependenciesMeta: @@ -11329,6 +11331,15 @@ __metadata: languageName: node linkType: hard +"lucide-react@npm:^1.23.0": + version: 1.23.0 + resolution: "lucide-react@npm:1.23.0" + peerDependencies: + react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 + checksum: 10c0/1cb2108d81c9713e20dd1beb7b6ac5bfc17307d5f38388f84921cf6a124efb4b933c73929bede42f00bd08aa2df340b0904acdf4fb90206ccd52971e943e775b + languageName: node + linkType: hard + "lz-string@npm:^1.5.0": version: 1.5.0 resolution: "lz-string@npm:1.5.0" From 32f3b50b27a7828ad55c16895719bd352d6a9f06 Mon Sep 17 00:00:00 2001 From: merencia Date: Fri, 3 Jul 2026 20:45:10 -0300 Subject: [PATCH 3/7] feat(dashboard): add remaining ui primitives and theme switching ports the rest of the design system into @sidequest/dashboard/ui: Input, Select, FormField, Table, Pagination, StatCard, StepProgress, CodeBlock, Sidebar, NavItem and ThemeToggle. every primitive is faithful to the design system (css-variable tokens + inline styles), typed, and at 100% test coverage with storybook stories. the ui library is now 15 components. storybook gets a dark/light toolbar switch so every component can be verified in both themes; the tokens already flip via [data-theme], so Card/Button/Icon and friends adapt with no component changes. --- packages/dashboard/.storybook/preview.tsx | 28 ++++- .../dashboard/src/ui/CodeBlock.stories.tsx | 22 ++++ packages/dashboard/src/ui/CodeBlock.test.tsx | 22 ++++ packages/dashboard/src/ui/CodeBlock.tsx | 41 +++++++ .../dashboard/src/ui/FormField.stories.tsx | 30 +++++ packages/dashboard/src/ui/FormField.test.tsx | 28 +++++ packages/dashboard/src/ui/FormField.tsx | 39 ++++++ packages/dashboard/src/ui/Icon.stories.tsx | 2 +- packages/dashboard/src/ui/Input.stories.tsx | 18 +++ packages/dashboard/src/ui/Input.test.tsx | 21 ++++ packages/dashboard/src/ui/Input.tsx | 38 ++++++ packages/dashboard/src/ui/NavItem.stories.tsx | 26 ++++ packages/dashboard/src/ui/NavItem.test.tsx | 32 +++++ packages/dashboard/src/ui/NavItem.tsx | 45 +++++++ .../dashboard/src/ui/Pagination.stories.tsx | 18 +++ packages/dashboard/src/ui/Pagination.test.tsx | 22 ++++ packages/dashboard/src/ui/Pagination.tsx | 51 ++++++++ packages/dashboard/src/ui/Select.stories.tsx | 35 ++++++ packages/dashboard/src/ui/Select.test.tsx | 35 ++++++ packages/dashboard/src/ui/Select.tsx | 59 +++++++++ packages/dashboard/src/ui/Sidebar.stories.tsx | 24 ++++ packages/dashboard/src/ui/Sidebar.test.tsx | 29 +++++ packages/dashboard/src/ui/Sidebar.tsx | 90 ++++++++++++++ .../dashboard/src/ui/StatCard.stories.tsx | 27 +++++ packages/dashboard/src/ui/StatCard.test.tsx | 23 ++++ packages/dashboard/src/ui/StatCard.tsx | 60 ++++++++++ .../dashboard/src/ui/StepProgress.stories.tsx | 45 +++++++ .../dashboard/src/ui/StepProgress.test.tsx | 26 ++++ packages/dashboard/src/ui/StepProgress.tsx | 95 +++++++++++++++ packages/dashboard/src/ui/Table.stories.tsx | 30 +++++ packages/dashboard/src/ui/Table.test.tsx | 41 +++++++ packages/dashboard/src/ui/Table.tsx | 103 ++++++++++++++++ .../dashboard/src/ui/ThemeToggle.stories.tsx | 15 +++ .../dashboard/src/ui/ThemeToggle.test.tsx | 77 ++++++++++++ packages/dashboard/src/ui/ThemeToggle.tsx | 113 ++++++++++++++++++ packages/dashboard/src/ui/index.ts | 30 ++++- 36 files changed, 1430 insertions(+), 10 deletions(-) create mode 100644 packages/dashboard/src/ui/CodeBlock.stories.tsx create mode 100644 packages/dashboard/src/ui/CodeBlock.test.tsx create mode 100644 packages/dashboard/src/ui/CodeBlock.tsx create mode 100644 packages/dashboard/src/ui/FormField.stories.tsx create mode 100644 packages/dashboard/src/ui/FormField.test.tsx create mode 100644 packages/dashboard/src/ui/FormField.tsx create mode 100644 packages/dashboard/src/ui/Input.stories.tsx create mode 100644 packages/dashboard/src/ui/Input.test.tsx create mode 100644 packages/dashboard/src/ui/Input.tsx create mode 100644 packages/dashboard/src/ui/NavItem.stories.tsx create mode 100644 packages/dashboard/src/ui/NavItem.test.tsx create mode 100644 packages/dashboard/src/ui/NavItem.tsx create mode 100644 packages/dashboard/src/ui/Pagination.stories.tsx create mode 100644 packages/dashboard/src/ui/Pagination.test.tsx create mode 100644 packages/dashboard/src/ui/Pagination.tsx create mode 100644 packages/dashboard/src/ui/Select.stories.tsx create mode 100644 packages/dashboard/src/ui/Select.test.tsx create mode 100644 packages/dashboard/src/ui/Select.tsx create mode 100644 packages/dashboard/src/ui/Sidebar.stories.tsx create mode 100644 packages/dashboard/src/ui/Sidebar.test.tsx create mode 100644 packages/dashboard/src/ui/Sidebar.tsx create mode 100644 packages/dashboard/src/ui/StatCard.stories.tsx create mode 100644 packages/dashboard/src/ui/StatCard.test.tsx create mode 100644 packages/dashboard/src/ui/StatCard.tsx create mode 100644 packages/dashboard/src/ui/StepProgress.stories.tsx create mode 100644 packages/dashboard/src/ui/StepProgress.test.tsx create mode 100644 packages/dashboard/src/ui/StepProgress.tsx create mode 100644 packages/dashboard/src/ui/Table.stories.tsx create mode 100644 packages/dashboard/src/ui/Table.test.tsx create mode 100644 packages/dashboard/src/ui/Table.tsx create mode 100644 packages/dashboard/src/ui/ThemeToggle.stories.tsx create mode 100644 packages/dashboard/src/ui/ThemeToggle.test.tsx create mode 100644 packages/dashboard/src/ui/ThemeToggle.tsx diff --git a/packages/dashboard/.storybook/preview.tsx b/packages/dashboard/.storybook/preview.tsx index 5e18b62..12456aa 100644 --- a/packages/dashboard/.storybook/preview.tsx +++ b/packages/dashboard/.storybook/preview.tsx @@ -2,12 +2,30 @@ 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) => ( -
- -
- ), + (Story, context) => { + const theme = context.globals.theme ?? "dark"; + return ( +
+ +
+ ); + }, ], }; 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..1bae134 --- /dev/null +++ b/packages/dashboard/src/ui/CodeBlock.tsx @@ -0,0 +1,41 @@ +import type { CSSProperties } from "react"; + +/** 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..55b8e81 --- /dev/null +++ b/packages/dashboard/src/ui/FormField.tsx @@ -0,0 +1,39 @@ +import type { CSSProperties, ReactNode } from "react"; + +/** 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 index 790360a..c3e90bf 100644 --- a/packages/dashboard/src/ui/Icon.stories.tsx +++ b/packages/dashboard/src/ui/Icon.stories.tsx @@ -14,7 +14,7 @@ export const Play: Story = { args: { name: "play" } }; export const Large: Story = { args: { name: "refresh-ccw", size: 32 } }; -const NAMES = ["play", "x", "refresh-ccw", "trash-2", "clock", "activity", "check-circle", "x-circle", "pause"]; +const NAMES = ["play", "x", "refresh-ccw", "trash-2", "clock", "activity", "circle-check", "circle-x", "pause"]; export const Gallery: Story = { render: () => ( 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..48ba8f9 --- /dev/null +++ b/packages/dashboard/src/ui/Input.tsx @@ -0,0 +1,38 @@ +import type { InputHTMLAttributes } from "react"; + +/** 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 = "", style = {}, ...rest }: InputProps) { + const height = size === "sm" ? "var(--control-height-sm)" : "var(--control-height)"; + 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..778db43 --- /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: "layout-dashboard", 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..11ac998 --- /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..2bba8dd --- /dev/null +++ b/packages/dashboard/src/ui/NavItem.tsx @@ -0,0 +1,45 @@ +import type { CSSProperties, MouseEvent } from "react"; +import { Icon } from "./Icon"; + +/** Props for the {@link NavItem} sidebar link. */ +export interface NavItemProps { + label: string; + /** Lucide icon name. */ + icon?: string; + 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 (see `.sq-navitem` in the base layer). + */ +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..35d93f8 --- /dev/null +++ b/packages/dashboard/src/ui/Pagination.tsx @@ -0,0 +1,51 @@ +import type { CSSProperties, ReactNode } from "react"; + +/** 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( + + + , + ); + + expect(screen.getByLabelText("queue")).toHaveClass("sq-select", "extra"); + expect(screen.getByRole("option", { name: "Gamma" })).toBeInTheDocument(); + }); +}); diff --git a/packages/dashboard/src/ui/Select.tsx b/packages/dashboard/src/ui/Select.tsx new file mode 100644 index 0000000..5f3a541 --- /dev/null +++ b/packages/dashboard/src/ui/Select.tsx @@ -0,0 +1,59 @@ +import type { SelectHTMLAttributes } from "react"; + +/** A single option for {@link Select} when passing data instead of children. */ +export interface SelectOption { + value: string; + label: string; +} + +/** Props for the {@link Select} dropdown. Extends the native `select` attributes. */ +export interface SelectProps extends Omit, "size"> { + /** Options as data; alternatively pass `