# Description of Changes

## What & why

This PR introduces the **Stirling developer portal** — a new
control-plane frontend that sits alongside the existing PDF editor —
plus the shared design system and workspace structure needed to host
both apps in one frontend.

The portal is the parent product surface: where users connect sources,
compose pipelines, wire agents, and manage usage / billing /
infrastructure, with the PDF editor as one capability inside it. This PR
lays the **foundation** — workspace reshape, design system, app shell,
navigation, and a mock-driven home — rather than wiring real backends
(those surfaces are placeholders for follow-up phases).

## What's in this PR

**1. Frontend repo reshape (`frontend/src/` → `frontend/editor/`)**
The existing editor app moved under `frontend/editor/`, so `editor`,
`portal`, and `shared` are siblings in one workspace. All references
were updated accordingly: `LICENSE`, `.dockerignore`, `.gitignore`,
build/sign shell scripts, the GH language-check script, the Taskfile,
and Docker config. **No editor source logic changed — path references
only.**

**2. New shared design system (`frontend/shared/`)**
- **Design tokens** in `tokens.css` as the single runtime source of
truth (light/dark, category accents, gradients). `tokens.ts` now holds
only the `Tier` type — the old JS palette mirror was removed (nothing
consumed it and it had drifted).
- ~30 framework-light **components** (Card, Button, Input, Select, Tabs,
Modal, Drawer, Toast, MetricCard, StatusBadge, Skeleton, EmptyState, …)
with Storybook stories.
- **Typed data catalogues**: `endpoints.ts` (10 verticals / 64
endpoints) and `ops.ts`.

**3. New developer portal app (`frontend/portal/`)**
- App shell: `Header`, `Sidebar`, `AssistantPanel`, search modal,
notifications, tier switcher, theme toggle, MSW toggle.
- **Tier-aware** home (free / pay-as-you-go / enterprise): KPI strip,
30-day usage chart, onboarding checklist, quick actions, recent
activity, region health, product grid, and a curated **"Popular use
cases"** teaser.
- **Documents** view hosting the full, tab-filterable endpoint
catalogue.
- Placeholder views for Sources / Pipelines / Agents / Editor /
Infrastructure / Usage & Billing / Developer Docs / Settings (follow-up
phases).
- **MSW-mocked** API layer: `api/*` issues real `fetch`, intercepted by
mocks in dev/Storybook; pointing at a real backend is just a matter of
not registering MSW. `react-router` URLs; Tier / View / UI contexts.

**4. Tooling & guardrails**
- ESLint extended to `portal` + `shared`, with **layering-boundary
rules**: `shared/` may depend only on third-party packages and itself
(no `@app` / `@portal` / `@core` / `@proprietary` / Tauri), so it stays
cleanly extractable into a standalone package later.
- `dpdm` circular-dependency check now walks editor + portal + shared
(the old glob matched only 2 files).
- New **devDependencies only** — Storybook (+ a11y/docs/themes addons),
MSW. No runtime dependencies added.
- New tasks: `frontend:dev:portal`, `frontend:build:portal`.

## Testing done locally

- `tsc` for both `portal` and `shared` projects — clean
- `eslint --max-warnings=0` across the whole frontend — clean
- `dpdm` circular-dependency check — no cycles
- Editor builds clean: `vite build editor --mode core` (✓ built, only
the pre-existing >500 kB chunk-size advisory)
- Editor runs in dev (core mode) with **zero console errors**; portal
runs in dev across all three tiers

## Notes for reviewers

- The change is overwhelmingly **additive**: `shared/` and `portal/` are
brand-new; the existing editor is path-reference changes only.
- The portal is intentionally **mock-driven** at this stage — real
backends and the remaining views land in follow-up phases.

---

## Checklist

### General

- [ ] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [ ] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [x] I have performed a self-review of my own code
- [x] My changes generate no new warnings

### Documentation

- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)

### Translations (if applicable)

- [ ] I ran
[`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md)

### UI Changes (if applicable)

- [ ] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)

### Testing (if applicable)

- [ ] I have run `task check` to verify linters, typechecks, and tests
pass
- [x] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#7-testing)
for more details.

---------

Co-authored-by: Claude Opus 4.8 (1M context) <[email protected]>
This commit is contained in:
Reece Browne
2026-06-02 16:08:24 +00:00
committed by GitHub
co-authored by Claude Opus 4.8
parent b355ccec9e
commit 919f0ade99
201 changed files with 19912 additions and 43 deletions
+68
View File
@@ -0,0 +1,68 @@
.sui-avatar {
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 50%;
font-weight: 600;
color: var(--color-text-on-accent);
font-family: var(--font-sans);
letter-spacing: 0.01em;
overflow: hidden;
flex-shrink: 0;
}
.sui-avatar--interactive {
cursor: pointer;
transition: transform var(--motion-fast);
}
.sui-avatar--interactive:hover {
transform: scale(1.05);
}
.sui-avatar--xs {
width: 1.25rem;
height: 1.25rem;
font-size: 0.625rem;
}
.sui-avatar--sm {
width: 1.625rem;
height: 1.625rem;
font-size: 0.6875rem;
}
.sui-avatar--md {
width: 2rem;
height: 2rem;
font-size: 0.8125rem;
}
.sui-avatar--lg {
width: 2.5rem;
height: 2.5rem;
font-size: 1rem;
}
.sui-avatar__img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.sui-avatar--blue {
background: var(--grad-blue-btn);
}
.sui-avatar--purple {
background: var(--grad-purple-btn);
}
.sui-avatar--green {
background: var(--grad-green-btn);
}
.sui-avatar--amber {
background: var(--grad-amber-btn);
}
.sui-avatar--red {
background: var(--grad-red-btn);
}
.sui-avatar--neutral {
background: var(--color-bg-muted);
color: var(--color-text-2);
}
@@ -0,0 +1,46 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { Avatar } from "@shared/components/Avatar";
const meta: Meta<typeof Avatar> = {
title: "Primitives/Avatar",
component: Avatar,
tags: ["autodocs"],
parameters: { layout: "centered" },
args: { name: "Harper Lee", size: "md", tone: "blue" },
argTypes: {
size: { control: "inline-radio", options: ["xs", "sm", "md", "lg"] },
tone: {
control: "inline-radio",
options: ["blue", "purple", "green", "amber", "red", "neutral"],
},
onClick: { action: "clicked" },
},
};
export default meta;
type Story = StoryObj<typeof Avatar>;
/** Flip name / size / tone / interactive in controls. */
export const Playground: Story = {};
export const SizeRow: Story = {
render: () => (
<div style={{ display: "flex", gap: 12, alignItems: "center" }}>
<Avatar name="A B" size="xs" />
<Avatar name="A B" size="sm" />
<Avatar name="A B" size="md" />
<Avatar name="A B" size="lg" />
</div>
),
};
export const ToneRow: Story = {
render: () => (
<div style={{ display: "flex", gap: 8 }}>
{(["blue", "purple", "green", "amber", "red", "neutral"] as const).map(
(t) => (
<Avatar key={t} name={t[0].toUpperCase()} tone={t} />
),
)}
</div>
),
};
+79
View File
@@ -0,0 +1,79 @@
import "@shared/components/Avatar.css";
export type AvatarSize = "xs" | "sm" | "md" | "lg";
export type AvatarTone =
| "blue"
| "purple"
| "green"
| "amber"
| "red"
| "neutral";
export interface AvatarProps {
/** Image source. Falls back to initials when missing or load fails. */
src?: string;
/** Full name. Initials are derived from the first letter of each word, max 2. */
name: string;
size?: AvatarSize;
/** Background tone when rendering initials. Defaults to blue. */
tone?: AvatarTone;
/** Optional click handler — renders as a button when supplied. */
onClick?: () => void;
ariaLabel?: string;
className?: string;
}
function initialsOf(name: string): string {
const parts = name.trim().split(/\s+/).filter(Boolean);
if (parts.length === 0) return "?";
if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase();
return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
}
/**
* Round avatar showing either an image or coloured initials. Used by the
* Header account button, reviewer rows, and assistant message gutters.
*/
export function Avatar({
src,
name,
size = "md",
tone = "blue",
onClick,
ariaLabel,
className,
}: AvatarProps) {
const classes = [
"sui-avatar",
`sui-avatar--${size}`,
`sui-avatar--${tone}`,
onClick ? "sui-avatar--interactive" : "",
className ?? "",
]
.filter(Boolean)
.join(" ");
const content = src ? (
<img src={src} alt={ariaLabel ?? name} className="sui-avatar__img" />
) : (
<span aria-hidden>{initialsOf(name)}</span>
);
if (onClick) {
return (
<button
type="button"
className={classes}
onClick={onClick}
aria-label={ariaLabel ?? name}
>
{content}
</button>
);
}
return (
<span className={classes} role="img" aria-label={ariaLabel ?? name}>
{content}
</span>
);
}
+94
View File
@@ -0,0 +1,94 @@
.sui-banner {
display: flex;
align-items: flex-start;
gap: var(--space-3);
padding: var(--space-3) var(--space-4);
border-radius: var(--radius-md);
border: 1px solid transparent;
font-size: 0.8125rem;
line-height: 1.5;
}
.sui-banner--info {
background: var(--color-blue-light);
color: var(--color-text-2);
border-color: var(--color-blue-border);
}
.sui-banner--success {
background: var(--color-green-light);
color: var(--color-text-2);
border-color: var(--color-green-border);
}
.sui-banner--warning {
background: var(--color-amber-light);
color: var(--color-text-2);
border-color: var(--color-amber-border);
}
.sui-banner--danger {
background: var(--color-red-light);
color: var(--color-text-2);
border-color: var(--color-red-border);
}
.sui-banner--neutral {
background: var(--color-bg-subtle);
color: var(--color-text-2);
border-color: var(--color-border);
}
.sui-banner__icon {
flex: 0 0 auto;
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.25rem;
height: 1.25rem;
}
.sui-banner--info .sui-banner__icon {
color: var(--color-blue);
}
.sui-banner--success .sui-banner__icon {
color: var(--color-green);
}
.sui-banner--warning .sui-banner__icon {
color: var(--color-amber-dark);
}
.sui-banner--danger .sui-banner__icon {
color: var(--color-red);
}
.sui-banner__body {
flex: 1 1 auto;
min-width: 0;
}
.sui-banner__title {
font-weight: 600;
color: var(--color-text-1);
}
.sui-banner__desc {
color: var(--color-text-3);
margin-top: 0.125rem;
}
.sui-banner__action {
flex: 0 0 auto;
display: flex;
align-items: center;
}
.sui-banner__close {
flex: 0 0 auto;
width: 1.5rem;
height: 1.5rem;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: var(--radius-sm);
color: var(--color-text-4);
font-size: 1rem;
line-height: 1;
}
.sui-banner__close:hover {
background: rgba(0, 0, 0, 0.06);
color: var(--color-text-1);
}
@@ -0,0 +1,70 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { Banner } from "@shared/components/Banner";
import { Button } from "@shared/components/Button";
const meta: Meta<typeof Banner> = {
title: "Primitives/Banner",
component: Banner,
tags: ["autodocs"],
parameters: { layout: "padded" },
args: {
tone: "info",
title: "Heads up",
description: "Storage quota approaching 80%.",
},
argTypes: {
tone: {
control: "inline-radio",
options: ["info", "success", "warning", "danger", "neutral"],
},
onDismiss: { action: "dismissed" },
},
decorators: [
(S) => (
<div style={{ maxWidth: "40rem" }}>
<S />
</div>
),
],
};
export default meta;
type Story = StoryObj<typeof Banner>;
/** Flip tone / title / description / action / onDismiss in controls. */
export const Playground: Story = {};
export const WithAction: Story = {
args: {
tone: "warning",
title: "Approaching cap",
description: "389k of 500k docs processed.",
action: (
<Button size="sm" variant="outline" accent="amber">
Upgrade
</Button>
),
},
};
export const ToneMatrix: Story = {
render: () => (
<div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
<Banner tone="info" title="Info" description="Just so you know." />
<Banner
tone="success"
title="Deployed"
description="Prior Auth v3.1.0 is live in us-east-1."
/>
<Banner
tone="warning"
title="Schema drift"
description="12 docs in 1h didn't match — confidence ↓ 0.07."
/>
<Banner
tone="danger"
title="Pipeline run failed"
description="8% error rate — 14 docs sent to review queue."
/>
</div>
),
};
+65
View File
@@ -0,0 +1,65 @@
import type { ReactNode } from "react";
import "@shared/components/Banner.css";
export type BannerTone = "info" | "success" | "warning" | "danger" | "neutral";
export interface BannerProps {
tone?: BannerTone;
title?: ReactNode;
description?: ReactNode;
/** Right-aligned action — typically a button. */
action?: ReactNode;
/** When set, shows an × button that calls this handler. */
onDismiss?: () => void;
/** Optional leading icon (caller supplies — keeps the primitive icon-set-agnostic). */
icon?: ReactNode;
className?: string;
children?: ReactNode;
}
/**
* Inline alert. Use `tone` to convey severity; pair with `action` for
* "Approaching cap → Upgrade" style flows. Use `Toast` (separate primitive)
* for transient/dismissible notifications layered over the UI.
*/
export function Banner({
tone = "info",
title,
description,
action,
onDismiss,
icon,
className,
children,
}: BannerProps) {
return (
<div
role="status"
className={["sui-banner", `sui-banner--${tone}`, className ?? ""]
.filter(Boolean)
.join(" ")}
>
{icon && (
<div className="sui-banner__icon" aria-hidden>
{icon}
</div>
)}
<div className="sui-banner__body">
{title && <div className="sui-banner__title">{title}</div>}
{description && <div className="sui-banner__desc">{description}</div>}
{children}
</div>
{action && <div className="sui-banner__action">{action}</div>}
{onDismiss && (
<button
type="button"
className="sui-banner__close"
onClick={onDismiss}
aria-label="Dismiss"
>
×
</button>
)}
</div>
);
}
+118
View File
@@ -0,0 +1,118 @@
.sui-btn {
display: inline-flex;
align-items: center;
gap: 0.5rem;
border: 1px solid transparent;
border-radius: var(--radius-md);
font-family: var(--font-sans);
font-weight: 500;
cursor: pointer;
transition:
transform var(--motion-fast),
box-shadow var(--motion-fast),
background var(--motion-fast),
color var(--motion-fast),
border-color var(--motion-fast);
white-space: nowrap;
user-select: none;
}
.sui-btn:disabled {
cursor: not-allowed;
opacity: 0.55;
}
.sui-btn:focus-visible {
outline: 2px solid var(--color-blue);
outline-offset: 2px;
}
/* sizes */
.sui-btn--md {
font-size: 0.8125rem;
padding: 0.4375rem 0.875rem;
min-height: 2rem;
}
.sui-btn--sm {
font-size: 0.75rem;
padding: 0.3125rem 0.625rem;
min-height: 1.625rem;
border-radius: var(--radius-sm);
}
.sui-btn--full {
width: 100%;
justify-content: center;
}
/* gradient (primary) */
.sui-btn--gradient {
color: var(--color-text-on-accent);
background: var(--grad-blue-btn);
box-shadow: var(--shadow-blue);
}
.sui-btn--gradient.sui-btn--purple {
background: var(--grad-purple-btn);
}
.sui-btn--gradient.sui-btn--green {
background: var(--grad-green-btn);
}
.sui-btn--gradient:hover:not(:disabled) {
box-shadow: var(--shadow-blue-hover);
transform: translateY(-0.0625rem);
}
.sui-btn--gradient:active:not(:disabled) {
transform: translateY(0);
}
/* outline (secondary) */
.sui-btn--outline {
color: var(--color-text-2);
background: var(--color-surface);
border-color: var(--color-border);
}
.sui-btn--outline.sui-btn--blue {
color: var(--color-blue);
border-color: var(--color-blue-border);
}
.sui-btn--outline.sui-btn--purple {
color: var(--color-purple);
border-color: var(--color-purple-border);
}
.sui-btn--outline.sui-btn--green {
color: var(--color-green);
border-color: var(--color-green-border);
}
.sui-btn--outline.sui-btn--amber {
color: var(--color-amber);
border-color: var(--color-amber-border);
}
.sui-btn--outline.sui-btn--red {
color: var(--color-red);
border-color: var(--color-red-border);
}
.sui-btn--outline:hover:not(:disabled) {
background: var(--color-bg-hover);
border-color: var(--color-border-hover);
}
/* ghost (tertiary) */
.sui-btn--ghost {
color: var(--color-text-3);
background: transparent;
}
.sui-btn--ghost:hover:not(:disabled) {
color: var(--color-text-1);
background: var(--color-bg-hover);
}
/* spinner */
.sui-btn__spinner {
width: 0.875rem;
height: 0.875rem;
border-radius: 50%;
border: 2px solid currentColor;
border-right-color: transparent;
animation: spin 0.7s linear infinite;
}
.sui-btn__label {
display: inline-block;
}
@@ -0,0 +1,57 @@
import type { Meta, StoryObj } from "@storybook/react";
import { Button } from "@shared/components/Button";
const meta: Meta<typeof Button> = {
title: "Primitives/Button",
component: Button,
parameters: { layout: "centered" },
args: { children: "Connect agent" },
argTypes: {
variant: {
control: "inline-radio",
options: ["gradient", "outline", "ghost"],
},
accent: {
control: "inline-radio",
options: ["blue", "purple", "green", "amber", "red"],
},
size: { control: "inline-radio", options: ["sm", "md"] },
},
};
export default meta;
type Story = StoryObj<typeof Button>;
export const Gradient: Story = { args: { variant: "gradient" } };
export const Outline: Story = { args: { variant: "outline" } };
export const Ghost: Story = { args: { variant: "ghost" } };
export const WithTrailingArrow: Story = {
args: {
variant: "gradient",
children: "Build a pipeline",
trailingIcon: <span aria-hidden></span>,
},
};
export const Loading: Story = { args: { variant: "gradient", loading: true } };
export const Disabled: Story = {
args: { variant: "gradient", disabled: true },
};
export const AccentMatrix: Story = {
render: () => (
<div style={{ display: "flex", flexWrap: "wrap", gap: 8 }}>
{(["gradient", "outline"] as const).flatMap((variant) =>
(["blue", "purple", "green", "amber", "red"] as const).map((accent) => (
<Button
key={`${variant}-${accent}`}
variant={variant}
accent={accent}
>
{variant} · {accent}
</Button>
)),
)}
</div>
),
};
+68
View File
@@ -0,0 +1,68 @@
import type { ButtonHTMLAttributes, ReactNode } from "react";
import "@shared/components/Button.css";
export type ButtonVariant = "gradient" | "outline" | "ghost";
export type ButtonSize = "sm" | "md";
export type ButtonAccent = "blue" | "purple" | "green" | "amber" | "red";
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
/** Visual variant. `gradient` is the primary CTA; `outline` is secondary; `ghost` is tertiary/text. */
variant?: ButtonVariant;
/** Optional accent colour. Currently affects `gradient` and `outline` variants. */
accent?: ButtonAccent;
size?: ButtonSize;
/** Icon node rendered before the label. */
leadingIcon?: ReactNode;
/** Icon node rendered after the label (arrow → for "next" CTAs). */
trailingIcon?: ReactNode;
/** Show a spinner and disable interactivity. */
loading?: boolean;
/** Stretches to fill its parent container. */
fullWidth?: boolean;
children?: ReactNode;
}
/**
* Stirling's two-button system: a gradient primary CTA and an outlined
* secondary action. Ghost is reserved for tertiary links inside a row.
*
* Buttons compose with the existing CSS variables — they re-skin cleanly in
* dark mode without per-variant overrides.
*/
export function Button({
variant = "gradient",
accent = "blue",
size = "md",
leadingIcon,
trailingIcon,
loading = false,
fullWidth = false,
disabled,
className,
children,
...rest
}: ButtonProps) {
const classes = [
"sui-btn",
`sui-btn--${variant}`,
`sui-btn--${accent}`,
`sui-btn--${size}`,
fullWidth ? "sui-btn--full" : "",
loading ? "sui-btn--loading" : "",
className ?? "",
]
.filter(Boolean)
.join(" ");
return (
<button {...rest} disabled={disabled || loading} className={classes}>
{loading ? (
<span className="sui-btn__spinner" aria-hidden />
) : (
leadingIcon
)}
{children && <span className="sui-btn__label">{children}</span>}
{!loading && trailingIcon}
</button>
);
}
+62
View File
@@ -0,0 +1,62 @@
.sui-card {
position: relative;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-md);
transition:
box-shadow var(--motion-fast),
border-color var(--motion-fast),
transform var(--motion-fast);
}
.sui-card--pad-none {
padding: 0;
overflow: hidden;
}
.sui-card--pad-tight {
padding: 0.75rem;
}
.sui-card--pad-default {
padding: 1.125rem 1.25rem;
}
.sui-card--pad-loose {
padding: 1.5rem 1.75rem;
}
.sui-card--interactive {
cursor: pointer;
}
.sui-card--interactive:hover {
border-color: var(--color-border-hover);
box-shadow: var(--shadow-lg);
transform: translateY(-0.0625rem);
}
.sui-card--accent-blue::before,
.sui-card--accent-purple::before,
.sui-card--accent-green::before,
.sui-card--accent-amber::before,
.sui-card--accent-red::before {
content: "";
position: absolute;
top: 0;
left: 0;
bottom: 0;
width: 0.25rem;
border-radius: var(--radius-lg) 0 0 var(--radius-lg);
}
.sui-card--accent-blue::before {
background: var(--color-blue);
}
.sui-card--accent-purple::before {
background: var(--color-purple);
}
.sui-card--accent-green::before {
background: var(--color-green);
}
.sui-card--accent-amber::before {
background: var(--color-amber);
}
.sui-card--accent-red::before {
background: var(--color-red);
}
+189
View File
@@ -0,0 +1,189 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { Card } from "@shared/components/Card";
import { Button } from "@shared/components/Button";
import { MetricCard } from "@shared/components/MetricCard";
import { StatusBadge } from "@shared/components/StatusBadge";
function CardBody({ color }: { color: string }) {
return (
<div>
<h3 style={{ margin: 0, fontSize: 14, color: "var(--color-text-1)" }}>
{color} card
</h3>
<p
style={{
margin: "4px 0 0",
fontSize: 12,
color: "var(--color-text-3)",
}}
>
Surface treatment with the {color} accent strip.
</p>
</div>
);
}
const meta: Meta<typeof Card> = {
title: "Primitives/Card",
component: Card,
tags: ["autodocs"],
parameters: { layout: "padded" },
args: {
accent: undefined,
padding: "default",
interactive: false,
},
argTypes: {
accent: {
control: "inline-radio",
options: [undefined, "blue", "purple", "green", "amber", "red"],
},
padding: {
control: "inline-radio",
options: ["tight", "default", "loose"],
},
interactive: { control: "boolean" },
},
decorators: [
(S) => (
<div style={{ width: "20rem" }}>
<S />
</div>
),
],
render: (args) => (
<Card {...args}>
<CardBody color={args.accent ?? "neutral"} />
</Card>
),
};
export default meta;
type Story = StoryObj<typeof Card>;
/** Flip accent / padding / interactive in controls. */
export const Playground: Story = {};
export const AccentMatrix: Story = {
decorators: [
(S) => (
<div style={{ width: "100%" }}>
<S />
</div>
),
],
render: () => (
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(3, 1fr)",
gap: 12,
}}
>
{(["blue", "purple", "green", "amber", "red", undefined] as const).map(
(accent) => (
<Card key={accent ?? "none"} accent={accent}>
<CardBody color={accent ?? "neutral"} />
</Card>
),
)}
</div>
),
};
export const InContext_ProductGrid: Story = {
decorators: [
(S) => (
<div style={{ width: "100%" }}>
<S />
</div>
),
],
render: () => (
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(3, 1fr)",
gap: 14,
}}
>
<Card accent="purple" padding="loose" interactive>
<h3 style={{ margin: 0, fontSize: 16 }}>Sources</h3>
<p
style={{
margin: "6px 0 14px",
fontSize: 13,
color: "var(--color-text-3)",
}}
>
Attach pipelines where PDFs already live.
</p>
<Button variant="outline" accent="purple" size="sm">
Connect a source
</Button>
</Card>
<Card accent="blue" padding="loose" interactive>
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
marginBottom: 6,
}}
>
<h3 style={{ margin: 0, fontSize: 16 }}>Pipelines</h3>
<StatusBadge tone="info" size="sm">
Hero
</StatusBadge>
</div>
<p
style={{
margin: "0 0 14px",
fontSize: 13,
color: "var(--color-text-3)",
}}
>
Compose document workflows from typed operations.
</p>
<Button variant="outline" accent="blue" size="sm">
Build a pipeline
</Button>
</Card>
<Card accent="purple" padding="loose" interactive>
<h3 style={{ margin: 0, fontSize: 16 }}>Agents</h3>
<p
style={{
margin: "6px 0 14px",
fontSize: 13,
color: "var(--color-text-3)",
}}
>
Wire your agent via MCP, REST, or tool definitions.
</p>
<Button variant="outline" accent="purple" size="sm">
Connect an agent
</Button>
</Card>
</div>
),
};
export const InContext_MetricsInsideCard: Story = {
args: { padding: "loose" },
render: (args) => (
<Card {...args}>
<h3 style={{ margin: "0 0 12px", fontSize: 14 }}>Last 24 hours</h3>
<div
style={{
display: "grid",
gridTemplateColumns: "1fr 1fr",
gap: 8,
}}
>
<MetricCard label="Docs" value={"1,287"} />
<MetricCard label="Errors" value={"0.4%"} />
<MetricCard label="P95" value="412ms" />
<MetricCard label="Uptime" value="99.99%" />
</div>
</Card>
),
};
+47
View File
@@ -0,0 +1,47 @@
import type { HTMLAttributes, ReactNode } from "react";
import "@shared/components/Card.css";
export interface CardProps extends HTMLAttributes<HTMLDivElement> {
/** Adds an accent strip on the left edge of the card. */
accent?: "blue" | "purple" | "green" | "amber" | "red";
/**
* Padding profile. `tight` = 0.75rem, `default` = 1.125rem, `loose` = 1.5rem.
* `none` removes padding so the card surface can host edge-to-edge content
* (e.g. a list with row dividers).
*/
padding?: "none" | "tight" | "default" | "loose";
/** Use the lifted surface treatment (taller shadow, hover affordance). */
interactive?: boolean;
children?: ReactNode;
}
/**
* Generic surface — the prototype's cardStyle / cardStyleSm helper, lifted
* into a primitive. Any list item, panel, or detail card that needs the
* standard surface treatment composes from here.
*/
export function Card({
accent,
padding = "default",
interactive = false,
className,
children,
...rest
}: CardProps) {
return (
<div
{...rest}
className={[
"sui-card",
`sui-card--pad-${padding}`,
accent ? `sui-card--accent-${accent}` : "",
interactive ? "sui-card--interactive" : "",
className ?? "",
]
.filter(Boolean)
.join(" ")}
>
{children}
</div>
);
}
+89
View File
@@ -0,0 +1,89 @@
.sui-check {
display: inline-flex;
align-items: flex-start;
gap: var(--space-2);
cursor: pointer;
font-size: 0.8125rem;
color: var(--color-text-2);
}
.sui-check--disabled {
opacity: 0.5;
cursor: not-allowed;
}
.sui-check__input {
position: absolute;
opacity: 0;
width: 0;
height: 0;
}
.sui-check__box {
flex: 0 0 auto;
width: 1rem;
height: 1rem;
margin-top: 0.0625rem;
border-radius: var(--radius-xs);
border: 1.5px solid var(--color-border-hover);
background: var(--color-surface);
display: inline-flex;
align-items: center;
justify-content: center;
transition:
background var(--motion-fast),
border-color var(--motion-fast);
position: relative;
}
.sui-check__input:focus-visible + .sui-check__box {
outline: 2px solid var(--color-blue);
outline-offset: 2px;
}
.sui-check__input:checked + .sui-check__box {
background: var(--color-blue);
border-color: var(--color-blue);
color: var(--color-text-on-accent);
}
.sui-check__tick {
opacity: 0;
}
.sui-check__input:checked + .sui-check__box .sui-check__tick {
opacity: 1;
}
.sui-check--mixed .sui-check__box {
background: var(--color-blue);
border-color: var(--color-blue);
}
.sui-check--mixed .sui-check__tick {
opacity: 0;
}
.sui-check--mixed .sui-check__dash {
position: absolute;
width: 0.5rem;
height: 2px;
background: var(--color-text-on-accent);
border-radius: 1px;
}
.sui-check__dash {
display: none;
}
.sui-check--mixed .sui-check__dash {
display: block;
}
.sui-check__text {
display: flex;
flex-direction: column;
}
.sui-check__label {
color: var(--color-text-1);
font-weight: 500;
}
.sui-check__desc {
font-size: 0.75rem;
color: var(--color-text-4);
}
+71
View File
@@ -0,0 +1,71 @@
import { forwardRef, type InputHTMLAttributes, type ReactNode } from "react";
import "@shared/components/Checkbox.css";
export interface CheckboxProps extends Omit<
InputHTMLAttributes<HTMLInputElement>,
"type" | "size"
> {
label?: ReactNode;
description?: ReactNode;
/** Tri-state checkbox visual (still focusable + submittable, but renders the dash). */
indeterminate?: boolean;
}
export const Checkbox = forwardRef<HTMLInputElement, CheckboxProps>(
function Checkbox(
{ label, description, indeterminate, className, id, ...rest },
ref,
) {
return (
<label
className={[
"sui-check",
rest.disabled ? "sui-check--disabled" : "",
indeterminate ? "sui-check--mixed" : "",
className ?? "",
]
.filter(Boolean)
.join(" ")}
htmlFor={id}
>
<input
ref={(el) => {
if (typeof ref === "function") ref(el);
else if (ref)
(ref as React.MutableRefObject<HTMLInputElement | null>).current =
el;
if (el) el.indeterminate = !!indeterminate;
}}
type="checkbox"
id={id}
className="sui-check__input"
{...rest}
/>
<span className="sui-check__box" aria-hidden>
<svg
viewBox="0 0 16 16"
width="10"
height="10"
fill="none"
stroke="currentColor"
strokeWidth={2.5}
strokeLinecap="round"
strokeLinejoin="round"
className="sui-check__tick"
>
<polyline points="3 8.5 7 12 13 4" />
</svg>
<span className="sui-check__dash" />
</span>
{(label || description) && (
<span className="sui-check__text">
{label && <span className="sui-check__label">{label}</span>}
{description && (
<span className="sui-check__desc">{description}</span>
)}
</span>
)}
</label>
);
},
);
+96
View File
@@ -0,0 +1,96 @@
.sui-chip {
display: inline-flex;
align-items: center;
gap: var(--space-1_5);
border-radius: var(--radius-pill);
font-family: var(--font-sans);
font-weight: 500;
white-space: nowrap;
border: 1px solid transparent;
transition:
background var(--motion-fast),
border-color var(--motion-fast),
transform var(--motion-fast);
}
.sui-chip--sm {
padding: var(--space-0_5) var(--space-2);
font-size: 0.6875rem;
}
.sui-chip--md {
padding: var(--space-1) var(--space-2_5, 0.5rem);
font-size: 0.75rem;
}
.sui-chip--md {
padding: var(--space-1) 0.625rem;
font-size: 0.75rem;
}
.sui-chip--neutral {
background: var(--color-bg-muted);
color: var(--color-text-2);
border-color: var(--color-border);
}
.sui-chip--blue {
background: var(--color-blue-light);
color: var(--color-blue);
border-color: var(--color-blue-border);
}
.sui-chip--purple {
background: var(--color-purple-light);
color: var(--color-purple);
border-color: var(--color-purple-border);
}
.sui-chip--green {
background: var(--color-green-light);
color: var(--color-green);
border-color: var(--color-green-border);
}
.sui-chip--amber {
background: var(--color-amber-light);
color: var(--color-amber-dark);
border-color: var(--color-amber-border);
}
.sui-chip--red {
background: var(--color-red-light);
color: var(--color-red);
border-color: var(--color-red-border);
}
.sui-chip--interactive {
cursor: pointer;
}
.sui-chip--interactive:hover {
transform: translateY(-0.0625rem);
filter: brightness(1.05);
}
.sui-chip__dot {
width: 0.4375rem;
height: 0.4375rem;
border-radius: 50%;
background: currentColor;
opacity: 0.85;
}
.sui-chip__icon {
display: inline-flex;
line-height: 1;
}
.sui-chip__remove {
display: inline-flex;
align-items: center;
justify-content: center;
width: 1rem;
height: 1rem;
border-radius: 50%;
color: inherit;
opacity: 0.6;
font-size: 0.875rem;
line-height: 1;
}
.sui-chip__remove:hover {
opacity: 1;
background: rgba(0, 0, 0, 0.08);
}
@@ -0,0 +1,79 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { Chip } from "@shared/components/Chip";
const meta: Meta<typeof Chip> = {
title: "Primitives/Chip",
component: Chip,
tags: ["autodocs"],
parameters: { layout: "centered" },
args: {
children: "us-east-1",
tone: "neutral",
size: "md",
showDot: false,
},
argTypes: {
tone: {
control: "inline-radio",
options: ["neutral", "blue", "purple", "green", "amber", "red"],
},
size: { control: "inline-radio", options: ["sm", "md"] },
showDot: { control: "boolean" },
onClick: { action: "clicked" },
onRemove: { action: "removed" },
},
};
export default meta;
type Story = StoryObj<typeof Chip>;
/** Flip tone / size / dot / interactive / removable in controls. */
export const Playground: Story = {};
export const ToneRow: Story = {
render: () => (
<div style={{ display: "flex", gap: 8 }}>
{(["neutral", "blue", "purple", "green", "amber", "red"] as const).map(
(t) => (
<Chip key={t} tone={t}>
{t}
</Chip>
),
)}
</div>
),
};
export const InContext_OpChain: Story = {
render: () => (
<div
style={{
display: "flex",
flexWrap: "wrap",
gap: 6,
maxWidth: "32rem",
}}
>
<Chip tone="green" showDot>
ocr
</Chip>
<Chip tone="green" showDot>
classify
</Chip>
<Chip tone="green" showDot>
extract
</Chip>
<Chip tone="blue" showDot>
validate
</Chip>
<Chip tone="red" showDot>
redact
</Chip>
<Chip tone="red" showDot>
encrypt-rest
</Chip>
<Chip tone="purple" showDot>
store-primary
</Chip>
</div>
),
};
+88
View File
@@ -0,0 +1,88 @@
import type { ReactNode } from "react";
import "@shared/components/Chip.css";
export type ChipTone =
| "neutral"
| "blue"
| "purple"
| "green"
| "amber"
| "red";
export type ChipSize = "sm" | "md";
export interface ChipProps {
tone?: ChipTone;
size?: ChipSize;
leadingIcon?: ReactNode;
trailingIcon?: ReactNode;
/** Show a `×` button. Calls `onRemove` when clicked. */
onRemove?: () => void;
/** Renders as a button when set. */
onClick?: () => void;
/** Show the leading dot affordance. Defaults to false (set true for status-style chips). */
showDot?: boolean;
children?: ReactNode;
className?: string;
}
/**
* Generic chip / tag — `StatusBadge` has a fixed taxonomy (success/warning/…),
* `MethodBadge` is HTTP-method-only; this is the open-ended one for tag rows
* (selected ops, document regions, kbd hints, sort chips, etc).
*/
export function Chip({
tone = "neutral",
size = "md",
leadingIcon,
trailingIcon,
onRemove,
onClick,
showDot,
children,
className,
}: ChipProps) {
const Tag = onClick ? "button" : "span";
const classes = [
"sui-chip",
`sui-chip--${tone}`,
`sui-chip--${size}`,
onClick ? "sui-chip--interactive" : "",
className ?? "",
]
.filter(Boolean)
.join(" ");
return (
<Tag
className={classes}
onClick={onClick}
type={onClick ? "button" : undefined}
>
{showDot && <span className="sui-chip__dot" aria-hidden />}
{leadingIcon && (
<span className="sui-chip__icon" aria-hidden>
{leadingIcon}
</span>
)}
<span className="sui-chip__label">{children}</span>
{trailingIcon && !onRemove && (
<span className="sui-chip__icon" aria-hidden>
{trailingIcon}
</span>
)}
{onRemove && (
<button
type="button"
className="sui-chip__remove"
onClick={(e) => {
e.stopPropagation();
onRemove();
}}
aria-label="Remove"
>
×
</button>
)}
</Tag>
);
}
+69
View File
@@ -0,0 +1,69 @@
.sui-code {
background: var(--code-bg);
color: var(--code-text);
border-radius: var(--radius-lg);
overflow: hidden;
border: 1px solid var(--code-border);
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.15);
}
.sui-code__chrome {
display: flex;
align-items: center;
gap: 0.625rem;
background: var(--code-bg-header);
padding: 0.5rem 0.75rem;
border-bottom: 1px solid var(--code-border);
font-size: 0.6875rem;
color: var(--code-muted);
}
.sui-code__dots {
display: inline-flex;
gap: 0.375rem;
align-items: center;
}
.sui-code__dots > span {
width: 0.625rem;
height: 0.625rem;
border-radius: 50%;
background: var(--code-dot);
}
.sui-code__caption {
color: var(--code-muted);
margin-left: 0.25rem;
font-family: var(--font-mono);
}
.sui-code__lang {
margin-left: auto;
font-family: var(--font-mono);
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--code-dim);
}
.sui-code__copy {
background: transparent;
color: var(--code-text);
border: 1px solid var(--code-border);
border-radius: var(--radius-sm);
padding: 0.125rem 0.5rem;
font-size: 0.6875rem;
cursor: pointer;
font-family: var(--font-sans);
transition: background var(--motion-fast);
}
.sui-code__copy:hover {
background: rgba(255, 255, 255, 0.05);
}
.sui-code__pre {
margin: 0;
padding: 0.875rem 1rem;
font-family: var(--font-mono);
font-size: 0.78125rem;
line-height: 1.55;
overflow: auto;
white-space: pre;
}
.sui-code__pre code {
white-space: inherit;
color: inherit;
font-family: inherit;
}
@@ -0,0 +1,85 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { CodeBlock } from "@shared/components/CodeBlock";
const CURL_EXAMPLE = `curl https://api.stirling.com/v1/coi \\
-H "Authorization: Bearer sk_live_a3f8..." \\
-F "[email protected]"`;
const JSON_RESULT = `{
"schema": "coi.v2",
"fields": {
"carrier": "Travelers Casualty",
"policy_number": "PHB-1108-2025",
"gl_limit": 1000000,
"umbrella_limit": 5000000,
"effective": "2026-01-15",
"expiry": "2027-01-15"
},
"confidence_avg": 0.96
}`;
const PYTHON_EXAMPLE = `import stirling
client = stirling.Client(api_key="sk_live_a3f8...")
result = client.extract(file="certificate.pdf", schema="coi.v2")
print(result.fields)`;
const meta: Meta<typeof CodeBlock> = {
title: "Primitives/CodeBlock",
component: CodeBlock,
tags: ["autodocs"],
parameters: { layout: "padded" },
args: { code: CURL_EXAMPLE, lang: "curl", copyable: true, maxHeight: 400 },
argTypes: {
lang: {
control: "inline-radio",
options: [
"json",
"javascript",
"typescript",
"python",
"bash",
"curl",
"http",
"plain",
],
},
copyable: { control: "boolean" },
maxHeight: { control: "number" },
code: { control: "text" },
},
};
export default meta;
type Story = StoryObj<typeof CodeBlock>;
/** Flip lang / copyable / maxHeight / code in controls. */
export const Playground: Story = {};
export const LongScrolling: Story = {
args: {
code: Array.from(
{ length: 40 },
(_, i) => `line ${i + 1}: const x = ${i};`,
).join("\n"),
lang: "javascript",
maxHeight: 240,
},
};
export const InContext_TwoUpComparison: Story = {
render: () => (
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 16 }}>
<CodeBlock code={CURL_EXAMPLE} lang="curl" caption="Request" />
<CodeBlock code={JSON_RESULT} lang="json" caption="Response" />
</div>
),
};
export const InContext_Quickstart: Story = {
render: () => (
<div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
<CodeBlock code={PYTHON_EXAMPLE} lang="python" caption="quickstart.py" />
<CodeBlock code={CURL_EXAMPLE} lang="curl" caption="curl" />
</div>
),
};
+82
View File
@@ -0,0 +1,82 @@
import { useState, type ReactNode } from "react";
import "@shared/components/CodeBlock.css";
export type CodeLang =
| "json"
| "javascript"
| "typescript"
| "python"
| "bash"
| "curl"
| "http"
| "plain";
export interface CodeBlockProps {
/** The code content. */
code: string;
/** Language label shown in the chrome bar. Highlight wiring is out of scope here — bring Shiki/Prism. */
lang?: CodeLang;
/** Optional caption text shown in the chrome bar (e.g. a file path). */
caption?: ReactNode;
/** Show a copy-to-clipboard button. Defaults to true. */
copyable?: boolean;
/** Max height in pixels; longer content scrolls. */
maxHeight?: number;
className?: string;
}
/**
* Always-dark code block, matched to the prototype's CODE palette.
*
* Highlighting is intentionally not wired here — drop in Shiki at the call
* site and feed pre-highlighted HTML through `dangerouslySetInnerHTML` on a
* fork of this component if you need it. For most surfaces the raw
* monospaced text plus copy button is sufficient (and ~80% lighter).
*/
export function CodeBlock({
code,
lang = "plain",
caption,
copyable = true,
maxHeight = 400,
className,
}: CodeBlockProps) {
const [copied, setCopied] = useState(false);
async function copy() {
try {
await navigator.clipboard.writeText(code);
setCopied(true);
setTimeout(() => setCopied(false), 1500);
} catch {
// Older browsers / non-secure contexts — silently fall through.
}
}
return (
<div className={["sui-code", className ?? ""].filter(Boolean).join(" ")}>
<div className="sui-code__chrome">
<span className="sui-code__dots" aria-hidden>
<span />
<span />
<span />
</span>
{caption && <span className="sui-code__caption">{caption}</span>}
<span className="sui-code__lang">{lang}</span>
{copyable && (
<button
type="button"
className="sui-code__copy"
onClick={copy}
aria-label="Copy code"
>
{copied ? "Copied" : "Copy"}
</button>
)}
</div>
<pre className="sui-code__pre" style={{ maxHeight }}>
<code>{code}</code>
</pre>
</div>
);
}
+110
View File
@@ -0,0 +1,110 @@
.sui-drawer__backdrop {
position: fixed;
inset: 0;
background: rgba(15, 23, 42, 0.32);
z-index: var(--z-drawer);
animation: fadeIn 0.18s ease both;
}
.sui-drawer {
position: fixed;
top: 0;
bottom: 0;
max-width: 100vw;
background: var(--color-surface);
display: flex;
flex-direction: column;
z-index: calc(var(--z-drawer) + 1);
animation: slideInRight 0.22s cubic-bezier(0.4, 0, 0.2, 1) both;
}
.sui-drawer--right {
right: 0;
border-left: 1px solid var(--color-border);
box-shadow: -4px 0 24px rgba(15, 23, 42, 0.12);
}
.sui-drawer--left {
left: 0;
border-right: 1px solid var(--color-border);
box-shadow: 4px 0 24px rgba(15, 23, 42, 0.12);
animation-name: slideInLeft;
}
@keyframes slideInLeft {
from {
transform: translateX(-100%);
}
to {
transform: translateX(0);
}
}
.sui-drawer--sm {
width: 22rem;
}
.sui-drawer--md {
width: 27.5rem;
}
.sui-drawer--lg {
width: 36rem;
}
.sui-drawer__header {
display: flex;
align-items: flex-start;
gap: var(--space-3);
padding: var(--space-4) var(--space-4) var(--space-3);
border-bottom: 1px solid var(--color-border-light);
}
.sui-drawer__header-text {
flex: 1 1 auto;
min-width: 0;
}
.sui-drawer__title {
font-size: 1rem;
font-weight: 600;
color: var(--color-text-1);
}
.sui-drawer__sub {
margin-top: 0.125rem;
font-size: 0.8125rem;
color: var(--color-text-4);
}
.sui-drawer__close {
flex: 0 0 auto;
width: 1.75rem;
height: 1.75rem;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: var(--radius-sm);
color: var(--color-text-4);
transition:
background var(--motion-fast),
color var(--motion-fast);
}
.sui-drawer__close:hover {
background: var(--color-bg-hover);
color: var(--color-text-1);
}
.sui-drawer__body {
flex: 1 1 auto;
overflow-y: auto;
padding: var(--space-3) var(--space-4) var(--space-6);
}
.sui-drawer__footer {
padding: var(--space-3) var(--space-4);
border-top: 1px solid var(--color-border-light);
background: var(--color-bg-subtle);
display: flex;
align-items: center;
justify-content: flex-end;
gap: var(--space-2);
}
@@ -0,0 +1,91 @@
import { useState } from "react";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { Drawer } from "@shared/components/Drawer";
import { Button } from "@shared/components/Button";
const meta: Meta<typeof Drawer> = {
title: "Primitives/Drawer",
component: Drawer,
parameters: { layout: "fullscreen" },
tags: ["autodocs"],
args: {
side: "right",
width: "md",
title: "Pipeline detail",
subtitle: "COI Compliance · us-east-1",
},
argTypes: {
side: { control: "inline-radio", options: ["left", "right"] },
width: { control: "inline-radio", options: ["sm", "md", "lg"] },
},
decorators: [
(S) => (
<div
style={{
minHeight: "100vh",
padding: 24,
background: "var(--color-bg)",
}}
>
<S />
</div>
),
],
render: (args) => {
function Bound() {
const [open, setOpen] = useState(true);
return (
<>
<Button onClick={() => setOpen(true)}>Open drawer</Button>
<Drawer {...args} open={open} onClose={() => setOpen(false)}>
<p style={{ color: "var(--color-text-3)" }}>
The drawer body scrolls when its content overflows. The header and
footer (when present) are sticky.
</p>
</Drawer>
</>
);
}
return <Bound />;
},
};
export default meta;
type Story = StoryObj<typeof Drawer>;
/** Flip side / width / title / subtitle in controls. */
export const Playground: Story = {};
export const WithFooter: Story = {
render: () => {
function Bound() {
const [open, setOpen] = useState(true);
return (
<>
<Button onClick={() => setOpen(true)}>Open drawer</Button>
<Drawer
open={open}
onClose={() => setOpen(false)}
side="right"
width="md"
title="Pipeline detail"
subtitle="COI Compliance · us-east-1"
footer={
<>
<Button variant="ghost" onClick={() => setOpen(false)}>
Close
</Button>
<Button variant="outline">Edit composition</Button>
<Button variant="gradient">View runs</Button>
</>
}
>
<p style={{ color: "var(--color-text-3)" }}>
Sticky footer demo scroll the body, footer stays anchored.
</p>
</Drawer>
</>
);
}
return <Bound />;
},
};
+171
View File
@@ -0,0 +1,171 @@
import { useEffect, useId, useRef, type ReactNode } from "react";
import { createPortal } from "react-dom";
import "@shared/components/Drawer.css";
const FOCUSABLE_SELECTOR =
'button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';
export type DrawerSide = "right" | "left";
export type DrawerWidth = "sm" | "md" | "lg";
export interface DrawerProps {
open: boolean;
onClose: () => void;
side?: DrawerSide;
/** Width preset. sm=22rem, md=27.5rem, lg=36rem. */
width?: DrawerWidth;
title?: ReactNode;
subtitle?: ReactNode;
/** Sticky footer slot below the body. */
footer?: ReactNode;
disableBackdropClose?: boolean;
disableEscapeClose?: boolean;
className?: string;
ariaLabel?: string;
children?: ReactNode;
}
/**
* Side drawer — sibling to {@link Modal}. Slides in from `side`, locks body
* scroll, closes on backdrop click or Escape. PipelineDetailDrawer, doc
* detail drawers, etc. should all sit on top of this primitive.
*/
export function Drawer({
open,
onClose,
side = "right",
width = "md",
title,
subtitle,
footer,
disableBackdropClose = false,
disableEscapeClose = false,
className,
ariaLabel,
children,
}: DrawerProps) {
const dialogRef = useRef<HTMLElement | null>(null);
const titleId = useId();
useEffect(() => {
if (!open || disableEscapeClose) return;
function onKey(e: KeyboardEvent) {
if (e.key === "Escape") onClose();
}
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
}, [open, disableEscapeClose, onClose]);
useEffect(() => {
if (!open) return;
const previous = document.body.style.overflow;
document.body.style.overflow = "hidden";
return () => {
document.body.style.overflow = previous;
};
}, [open]);
useEffect(() => {
if (!open) return;
const previouslyFocused = document.activeElement as HTMLElement | null;
const dialog = dialogRef.current;
const first = dialog?.querySelector<HTMLElement>(FOCUSABLE_SELECTOR);
first?.focus();
function onKey(e: KeyboardEvent) {
if (e.key !== "Tab" || !dialog) return;
const focusables =
dialog.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR);
if (focusables.length === 0) {
e.preventDefault();
return;
}
const firstEl = focusables[0];
const lastEl = focusables[focusables.length - 1];
const active = document.activeElement;
if (e.shiftKey && active === firstEl) {
e.preventDefault();
lastEl.focus();
} else if (!e.shiftKey && active === lastEl) {
e.preventDefault();
firstEl.focus();
}
}
document.addEventListener("keydown", onKey);
return () => {
document.removeEventListener("keydown", onKey);
previouslyFocused?.focus?.();
};
}, [open]);
if (!open) return null;
function onBackdropClick() {
if (!disableBackdropClose) onClose();
}
const hasTitle = title !== undefined && title !== null;
return createPortal(
<>
<div
className="sui-drawer__backdrop"
onClick={onBackdropClick}
role="presentation"
/>
<aside
ref={dialogRef}
className={[
"sui-drawer",
`sui-drawer--${side}`,
`sui-drawer--${width}`,
className ?? "",
]
.filter(Boolean)
.join(" ")}
role="dialog"
aria-modal="true"
aria-labelledby={hasTitle ? titleId : undefined}
aria-label={!hasTitle ? ariaLabel : undefined}
>
{(title || subtitle) && (
<header className="sui-drawer__header">
<div className="sui-drawer__header-text">
{title && (
<div id={titleId} className="sui-drawer__title">
{title}
</div>
)}
{subtitle && <div className="sui-drawer__sub">{subtitle}</div>}
</div>
<button
type="button"
className="sui-drawer__close"
onClick={onClose}
aria-label="Close"
>
<svg
viewBox="0 0 24 24"
width="16"
height="16"
fill="none"
stroke="currentColor"
strokeWidth={1.75}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden
>
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
</header>
)}
<div className="sui-drawer__body">{children}</div>
{footer && <footer className="sui-drawer__footer">{footer}</footer>}
</aside>
</>,
document.body,
);
}
+77
View File
@@ -0,0 +1,77 @@
.sui-dd {
position: relative;
display: inline-flex;
}
.sui-dd__menu {
position: absolute;
top: calc(100% + var(--space-1));
min-width: 12rem;
padding: var(--space-1);
background: var(--color-dropdown-bg);
border: 1px solid var(--color-dropdown-border);
border-radius: var(--radius-md);
box-shadow: var(--shadow-lg);
z-index: var(--z-dropdown);
animation: fadeInUp var(--motion-enter) both;
display: flex;
flex-direction: column;
gap: 0.0625rem;
}
.sui-dd__menu--start {
left: 0;
}
.sui-dd__menu--end {
right: 0;
}
.sui-dd__item {
display: flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-1_5) var(--space-2);
width: 100%;
text-align: left;
font-size: 0.8125rem;
color: var(--color-text-2);
border-radius: var(--radius-sm);
transition:
background var(--motion-fast),
color var(--motion-fast);
}
.sui-dd__item:hover:not(.is-disabled) {
background: var(--color-dropdown-hover);
color: var(--color-text-1);
}
.sui-dd__item.is-active {
background: var(--color-nav-active);
color: var(--color-nav-active-text);
font-weight: 500;
}
.sui-dd__item.is-disabled {
opacity: 0.4;
cursor: not-allowed;
}
.sui-dd__item-leading,
.sui-dd__item-trailing {
display: inline-flex;
align-items: center;
}
.sui-dd__item-trailing {
margin-left: auto;
font-family: var(--font-mono);
font-size: 0.6875rem;
color: var(--color-text-5);
}
.sui-dd__divider {
height: 1px;
background: var(--color-divider);
margin: var(--space-1) 0;
}
@@ -0,0 +1,82 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { Dropdown } from "@shared/components/Dropdown";
import { Button } from "@shared/components/Button";
const meta: Meta = {
title: "Primitives/Dropdown",
parameters: { layout: "padded" },
decorators: [
(S) => (
<div style={{ padding: "4rem 2rem", minHeight: "24rem" }}>
<S />
</div>
),
],
};
export default meta;
type Story = StoryObj;
export const Basic: Story = {
render: () => (
<Dropdown.Root>
<Dropdown.Trigger>
<Button variant="outline">Open menu</Button>
</Dropdown.Trigger>
<Dropdown.Menu>
<Dropdown.Item onSelect={() => console.log("a")}>Item A</Dropdown.Item>
<Dropdown.Item onSelect={() => console.log("b")}>Item B</Dropdown.Item>
<Dropdown.Item onSelect={() => console.log("c")} active>
Item C (active)
</Dropdown.Item>
</Dropdown.Menu>
</Dropdown.Root>
),
};
export const WithDivider: Story = {
render: () => (
<Dropdown.Root>
<Dropdown.Trigger>
<Button variant="outline">Account</Button>
</Dropdown.Trigger>
<Dropdown.Menu width="13rem">
<Dropdown.Item>Profile</Dropdown.Item>
<Dropdown.Item>Workspace settings</Dropdown.Item>
<Dropdown.Divider />
<Dropdown.Item>Sign out</Dropdown.Item>
</Dropdown.Menu>
</Dropdown.Root>
),
};
export const WithTrailingHints: Story = {
render: () => (
<Dropdown.Root>
<Dropdown.Trigger>
<Button variant="outline">Commands</Button>
</Dropdown.Trigger>
<Dropdown.Menu width="16rem">
<Dropdown.Item trailing="⌘ K">Search</Dropdown.Item>
<Dropdown.Item trailing="N P">New pipeline</Dropdown.Item>
<Dropdown.Item trailing="N K">New API key</Dropdown.Item>
<Dropdown.Item trailing="T" active>
Toggle theme
</Dropdown.Item>
</Dropdown.Menu>
</Dropdown.Root>
),
};
export const AlignStart: Story = {
render: () => (
<Dropdown.Root align="start">
<Dropdown.Trigger>
<Button variant="outline">Aligned to start</Button>
</Dropdown.Trigger>
<Dropdown.Menu>
<Dropdown.Item>One</Dropdown.Item>
<Dropdown.Item>Two</Dropdown.Item>
</Dropdown.Menu>
</Dropdown.Root>
),
};
+233
View File
@@ -0,0 +1,233 @@
import {
cloneElement,
createContext,
isValidElement,
useCallback,
useContext,
useEffect,
useId,
useMemo,
useRef,
useState,
type ReactElement,
type ReactNode,
} from "react";
import "@shared/components/Dropdown.css";
type Alignment = "start" | "end";
interface DropdownContextValue {
open: boolean;
setOpen: (open: boolean) => void;
triggerRef: React.RefObject<HTMLElement | null>;
menuId: string;
align: Alignment;
}
const DropdownContext = createContext<DropdownContextValue | null>(null);
function useDropdownCtx(): DropdownContextValue {
const ctx = useContext(DropdownContext);
if (!ctx)
throw new Error(
"Dropdown subcomponents must be used inside <Dropdown.Root>",
);
return ctx;
}
export interface DropdownRootProps {
/** Controlled open state. Omit for uncontrolled. */
open?: boolean;
defaultOpen?: boolean;
onOpenChange?: (open: boolean) => void;
/** Alignment of the menu relative to the trigger. */
align?: Alignment;
children: ReactNode;
className?: string;
}
function Root({
open: openProp,
defaultOpen,
onOpenChange,
align = "end",
children,
className,
}: DropdownRootProps) {
const [uncontrolled, setUncontrolled] = useState(defaultOpen ?? false);
const isControlled = openProp !== undefined;
const open = isControlled ? openProp : uncontrolled;
const setOpen = useCallback(
(next: boolean) => {
if (!isControlled) setUncontrolled(next);
onOpenChange?.(next);
},
[isControlled, onOpenChange],
);
const triggerRef = useRef<HTMLElement | null>(null);
const containerRef = useRef<HTMLDivElement | null>(null);
const menuId = useId();
// Click-outside + Escape close.
useEffect(() => {
if (!open) return;
function onDocClick(e: MouseEvent) {
if (
containerRef.current &&
!containerRef.current.contains(e.target as Node)
) {
setOpen(false);
}
}
function onKey(e: KeyboardEvent) {
if (e.key === "Escape") {
setOpen(false);
triggerRef.current?.focus();
}
}
document.addEventListener("mousedown", onDocClick);
document.addEventListener("keydown", onKey);
return () => {
document.removeEventListener("mousedown", onDocClick);
document.removeEventListener("keydown", onKey);
};
}, [open, setOpen]);
const value = useMemo<DropdownContextValue>(
() => ({ open, setOpen, triggerRef, menuId, align }),
[open, setOpen, menuId, align],
);
return (
<DropdownContext.Provider value={value}>
<div
ref={containerRef}
className={["sui-dd", className ?? ""].filter(Boolean).join(" ")}
>
{children}
</div>
</DropdownContext.Provider>
);
}
export interface DropdownTriggerProps {
/** A single button-like element. Receives onClick + aria props. */
children: ReactElement<{
onClick?: (e: React.MouseEvent) => void;
"aria-haspopup"?: string;
"aria-expanded"?: boolean;
"aria-controls"?: string;
ref?: React.Ref<unknown>;
}>;
}
function Trigger({ children }: DropdownTriggerProps) {
const { open, setOpen, triggerRef, menuId } = useDropdownCtx();
if (!isValidElement(children)) {
throw new Error(
"Dropdown.Trigger requires exactly one React element child",
);
}
return cloneElement(children, {
ref: triggerRef as React.Ref<unknown>,
onClick: (e: React.MouseEvent) => {
children.props.onClick?.(e);
setOpen(!open);
},
"aria-haspopup": "menu",
"aria-expanded": open,
"aria-controls": menuId,
});
}
export interface DropdownMenuProps {
children: ReactNode;
className?: string;
/** Optional min-width override (px or CSS length). */
width?: string | number;
}
function Menu({ children, className, width }: DropdownMenuProps) {
const { open, menuId, align } = useDropdownCtx();
if (!open) return null;
const style =
width !== undefined
? { minWidth: typeof width === "number" ? `${width}px` : width }
: undefined;
return (
<div
id={menuId}
role="menu"
className={["sui-dd__menu", `sui-dd__menu--${align}`, className ?? ""]
.filter(Boolean)
.join(" ")}
style={style}
>
{children}
</div>
);
}
export interface DropdownItemProps {
onSelect?: () => void;
/** Active visual state (e.g. current value in a switcher). */
active?: boolean;
disabled?: boolean;
/** Optional leading visual. */
leading?: ReactNode;
/** Optional trailing visual (kbd hint, badge, etc). */
trailing?: ReactNode;
children?: ReactNode;
className?: string;
}
function Item({
onSelect,
active,
disabled,
leading,
trailing,
children,
className,
}: DropdownItemProps) {
const { setOpen } = useDropdownCtx();
return (
<button
type="button"
role="menuitem"
disabled={disabled}
aria-current={active ? "true" : undefined}
className={[
"sui-dd__item",
active ? "is-active" : "",
disabled ? "is-disabled" : "",
className ?? "",
]
.filter(Boolean)
.join(" ")}
onClick={() => {
if (disabled) return;
onSelect?.();
setOpen(false);
}}
>
{leading && <span className="sui-dd__item-leading">{leading}</span>}
<span className="sui-dd__item-label">{children}</span>
{trailing && <span className="sui-dd__item-trailing">{trailing}</span>}
</button>
);
}
function Divider() {
return <div className="sui-dd__divider" role="separator" aria-hidden />;
}
export const Dropdown = {
Root,
Trigger,
Menu,
Item,
Divider,
};
+57
View File
@@ -0,0 +1,57 @@
.sui-empty {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
padding: var(--space-6) var(--space-4);
gap: var(--space-2);
}
.sui-empty--compact {
padding: var(--space-4) var(--space-3);
}
.sui-empty__icon {
display: inline-flex;
margin-bottom: var(--space-2);
color: var(--color-text-4);
}
.sui-empty__eyebrow {
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--color-blue);
}
.sui-empty__title {
margin: 0;
font-size: 1.125rem;
font-weight: 600;
color: var(--color-text-1);
}
.sui-empty--compact .sui-empty__title {
font-size: 0.9375rem;
}
.sui-empty__copy {
margin: 0;
max-width: 32rem;
font-size: 0.875rem;
line-height: 1.5;
color: var(--color-text-3);
}
.sui-empty--compact .sui-empty__copy {
font-size: 0.8125rem;
}
.sui-empty__actions {
margin-top: var(--space-2);
display: flex;
gap: var(--space-2);
flex-wrap: wrap;
justify-content: center;
}
@@ -0,0 +1,52 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { EmptyState } from "@shared/components/EmptyState";
import { Button } from "@shared/components/Button";
import { Card } from "@shared/components/Card";
const meta: Meta<typeof EmptyState> = {
title: "Primitives/EmptyState",
component: EmptyState,
tags: ["autodocs"],
parameters: { layout: "padded" },
args: {
title: "Nothing here yet",
description: "When pipelines are deployed they'll appear in this list.",
size: "default",
},
argTypes: {
size: { control: "inline-radio", options: ["default", "compact"] },
},
};
export default meta;
type Story = StoryObj<typeof EmptyState>;
/** Flip title / description / eyebrow / size / actions in controls. */
export const Playground: Story = {};
export const WithCTAs: Story = {
args: {
eyebrow: "No pipelines yet",
title: "Start from a template — or compose from scratch.",
description:
"The fastest way in is forking a pre-bundled pipeline like PII Sweep or Compliance Pack.",
actions: (
<>
<Button variant="gradient" trailingIcon={<span aria-hidden></span>}>
Browse templates
</Button>
<Button variant="outline">Build from scratch</Button>
</>
),
},
};
export const InCard: Story = {
render: () => (
<Card padding="loose" style={{ maxWidth: "40rem" }}>
<EmptyState
title="You're all caught up"
description="No new notifications."
/>
</Card>
),
};
+51
View File
@@ -0,0 +1,51 @@
import type { ReactNode } from "react";
import "@shared/components/EmptyState.css";
export interface EmptyStateProps {
/** Eyebrow text shown above the title (e.g. "No pipelines yet"). */
eyebrow?: ReactNode;
/** Headline. */
title: ReactNode;
/** One- or two-line body copy. */
description?: ReactNode;
/** Optional visual at the top (icon, illustration, etc). */
icon?: ReactNode;
/** Primary + secondary CTAs. */
actions?: ReactNode;
/** Visual size. `compact` removes the icon row's padding for inline use. */
size?: "compact" | "default";
className?: string;
}
/**
* Centered "nothing here yet" panel. Replaces the bespoke empty cards inline
* in Pipelines (free tier), NotificationsDropdown (all caught up), and the
* search modal (no matches).
*/
export function EmptyState({
eyebrow,
title,
description,
icon,
actions,
size = "default",
className,
}: EmptyStateProps) {
return (
<div
className={["sui-empty", `sui-empty--${size}`, className ?? ""]
.filter(Boolean)
.join(" ")}
>
{icon && (
<div className="sui-empty__icon" aria-hidden>
{icon}
</div>
)}
{eyebrow && <div className="sui-empty__eyebrow">{eyebrow}</div>}
<h2 className="sui-empty__title">{title}</h2>
{description && <p className="sui-empty__copy">{description}</p>}
{actions && <div className="sui-empty__actions">{actions}</div>}
</div>
);
}
+33
View File
@@ -0,0 +1,33 @@
.sui-field {
display: flex;
flex-direction: column;
gap: var(--space-1);
min-width: 0;
}
.sui-field__label {
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--color-section-label);
}
.sui-field__required {
color: var(--color-red);
}
.sui-field__control {
display: flex;
flex-direction: column;
}
.sui-field__help {
font-size: 0.6875rem;
color: var(--color-text-4);
line-height: 1.45;
}
.sui-field--error .sui-field__help {
color: var(--color-red);
}
+79
View File
@@ -0,0 +1,79 @@
import {
cloneElement,
isValidElement,
useId,
type ReactElement,
type ReactNode,
} from "react";
import "@shared/components/FormField.css";
export interface FormFieldProps {
label?: ReactNode;
/** Helper text shown under the control. Replaced by `error` when present. */
helperText?: ReactNode;
/** Error string. Causes the control + helper region to swap to the error tone. */
error?: ReactNode;
required?: boolean;
/** A single form control as a React element. Receives id + aria-* props. */
children: ReactElement<{
id?: string;
"aria-invalid"?: boolean;
"aria-describedby"?: string;
required?: boolean;
}>;
className?: string;
}
/**
* Standardised label + control + helper/error wrapper. Works with the bare
* native controls in this design system (Input, Select, Checkbox, Radio,
* Slider) or with any third-party control that accepts `id` + `aria-*`.
*/
export function FormField({
label,
helperText,
error,
required,
children,
className,
}: FormFieldProps) {
const autoId = useId();
if (!isValidElement(children)) {
throw new Error("FormField requires exactly one React element child");
}
const controlId = children.props.id ?? autoId;
const describedById = error || helperText ? `${controlId}-help` : undefined;
const child = cloneElement(children, {
id: controlId,
"aria-invalid": error ? true : undefined,
"aria-describedby": describedById,
required: required ?? children.props.required,
});
return (
<div
className={["sui-field", error ? "sui-field--error" : "", className ?? ""]
.filter(Boolean)
.join(" ")}
>
{label && (
<label htmlFor={controlId} className="sui-field__label">
{label}
{required && (
<span className="sui-field__required" aria-hidden>
{" "}
*
</span>
)}
</label>
)}
<div className="sui-field__control">{child}</div>
{(error || helperText) && (
<div id={describedById} className="sui-field__help">
{error ?? helperText}
</div>
)}
</div>
);
}
@@ -0,0 +1,303 @@
import { useState } from "react";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { FormField } from "@shared/components/FormField";
import { Input } from "@shared/components/Input";
import { Select } from "@shared/components/Select";
import { Checkbox } from "@shared/components/Checkbox";
import { RadioGroup } from "@shared/components/Radio";
import { Slider } from "@shared/components/Slider";
import { Stack } from "@shared/components/Stack";
import { Inline } from "@shared/components/Inline";
// Inline icon to avoid a cross-layer import; shared/ must not depend on portal/.
function SearchIcon({ size = 14 }: { size?: number }) {
return (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={1.75}
strokeLinecap="round"
strokeLinejoin="round"
>
<circle cx="11" cy="11" r="7" />
<line x1="21" y1="21" x2="16.65" y2="16.65" />
</svg>
);
}
const meta: Meta = {
title: "Primitives/Forms",
parameters: { layout: "padded" },
decorators: [
(S) => (
<div style={{ width: "28rem" }}>
<S />
</div>
),
],
};
export default meta;
type Story = StoryObj;
export const Input_Default: Story = {
render: () => (
<FormField
label="Pipeline name"
required
helperText="Lowercase letters and dashes only."
>
<Input placeholder="e.g. coi-compliance" />
</FormField>
),
};
export const Input_WithIcon: Story = {
render: () => (
<FormField label="Search">
<Input
leadingIcon={<SearchIcon size={14} />}
placeholder="Search Stirling…"
/>
</FormField>
),
};
export const Input_Error: Story = {
render: () => (
<FormField label="Email" error="Must be a valid email address" required>
<Input value="not-an-email" onChange={() => {}} />
</FormField>
),
};
export const Select_Default: Story = {
render: () => (
<FormField label="Retention period">
<Select
defaultValue="90"
options={[
{ value: "30", label: "30 days" },
{ value: "60", label: "60 days" },
{ value: "90", label: "90 days (default)" },
{ value: "180", label: "180 days" },
{ value: "never", label: "Never expire" },
]}
/>
</FormField>
),
};
export const Checkbox_Single: Story = {
render: () => (
<Stack gap="2">
<Checkbox
defaultChecked
label="Notify on pipeline failure (webhook + email)"
/>
<Checkbox
label="Send low-confidence docs to review queue"
description="Confidence below 0.85 routes to a reviewer."
/>
<Checkbox indeterminate label="Mixed state" />
<Checkbox disabled label="Disabled (off)" />
<Checkbox defaultChecked disabled label="Disabled (on)" />
</Stack>
),
};
export const Checkbox_GridOfCategories: Story = {
render: () => (
<FormField label="PII categories">
<Inline gap="2">
{["SSN", "DOB", "Accounts", "Contacts", "Names", "Addresses"].map(
(c) => (
<Checkbox key={c} defaultChecked label={c} />
),
)}
</Inline>
</FormField>
),
};
export const Radio_Group: Story = {
render: () => {
function Bound() {
const [mode, setMode] = useState<"stirling" | "byok" | "hyok">(
"stirling",
);
return (
<FormField label="Key mode">
<RadioGroup
name="keymode"
value={mode}
onChange={setMode}
options={[
{
value: "stirling",
label: "Stirling-managed",
description: "Stirling generates and rotates keys.",
},
{
value: "byok",
label: "BYOK (AWS KMS)",
description: "Bring your own KMS key.",
},
{
value: "hyok",
label: "HYOK (Enterprise)",
description: "Stirling never sees the key material.",
},
]}
/>
</FormField>
);
}
return <Bound />;
},
};
export const Radio_Horizontal: Story = {
render: () => {
function Bound() {
const [v, setV] = useState("us");
return (
<FormField label="Region">
<RadioGroup
name="region"
direction="horizontal"
value={v}
onChange={setV}
options={[
{ value: "us", label: "US" },
{ value: "eu", label: "EU" },
{ value: "apac", label: "APAC" },
]}
/>
</FormField>
);
}
return <Bound />;
},
};
export const Slider_Confidence: Story = {
render: () => {
function Bound() {
const [v, setV] = useState(0.85);
return (
<FormField
label="Minimum confidence"
helperText="Default 0.85 — gates downstream ops below this threshold."
>
<Slider
value={v}
min={0}
max={1}
step={0.01}
onChange={setV}
formatValue={(x) => x.toFixed(2)}
/>
</FormField>
);
}
return <Bound />;
},
};
export const Slider_Retention: Story = {
render: () => {
function Bound() {
const [days, setDays] = useState(90);
return (
<FormField label="Retain artifacts for">
<Slider
value={days}
min={7}
max={365}
step={1}
onChange={setDays}
formatValue={(d) => `${d} days`}
/>
</FormField>
);
}
return <Bound />;
},
};
export const FullForm: Story = {
render: () => {
function Form() {
const [name, setName] = useState("");
const [retention, setRetention] = useState("90");
const [mode, setMode] = useState<"stirling" | "byok" | "hyok">(
"stirling",
);
const [conf, setConf] = useState(0.85);
const [notify, setNotify] = useState(true);
const [review, setReview] = useState(false);
return (
<Stack gap="4">
<FormField label="Pipeline name" required>
<Input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="e.g. coi-compliance"
/>
</FormField>
<FormField label="Retention">
<Select
value={retention}
onChange={(e) => setRetention(e.target.value)}
options={[
{ value: "30", label: "30 days" },
{ value: "90", label: "90 days" },
{ value: "365", label: "1 year" },
]}
/>
</FormField>
<FormField label="Key mode">
<RadioGroup
name="km"
value={mode}
onChange={setMode}
options={[
{ value: "stirling", label: "Stirling-managed" },
{ value: "byok", label: "BYOK" },
{ value: "hyok", label: "HYOK" },
]}
/>
</FormField>
<FormField label="Confidence gate" helperText="Default 0.85.">
<Slider
value={conf}
min={0}
max={1}
step={0.01}
onChange={setConf}
formatValue={(v) => v.toFixed(2)}
/>
</FormField>
<FormField label="Alerts">
<Stack gap="2">
<Checkbox
checked={notify}
onChange={(e) => setNotify(e.target.checked)}
label="Notify on failure"
/>
<Checkbox
checked={review}
onChange={(e) => setReview(e.target.checked)}
label="Send low-confidence to review"
/>
</Stack>
</FormField>
</Stack>
);
}
return <Form />;
},
};
+76
View File
@@ -0,0 +1,76 @@
.sui-inline {
display: flex;
flex-direction: row;
flex-wrap: wrap;
min-width: 0;
}
.sui-inline--nowrap {
flex-wrap: nowrap;
}
.sui-inline--gap-0 {
gap: var(--space-0);
}
.sui-inline--gap-0_5 {
gap: var(--space-0_5);
}
.sui-inline--gap-1 {
gap: var(--space-1);
}
.sui-inline--gap-1_5 {
gap: var(--space-1_5);
}
.sui-inline--gap-2 {
gap: var(--space-2);
}
.sui-inline--gap-3 {
gap: var(--space-3);
}
.sui-inline--gap-4 {
gap: var(--space-4);
}
.sui-inline--gap-5 {
gap: var(--space-5);
}
.sui-inline--gap-6 {
gap: var(--space-6);
}
.sui-inline--gap-8 {
gap: var(--space-8);
}
.sui-inline--align-stretch {
align-items: stretch;
}
.sui-inline--align-start {
align-items: flex-start;
}
.sui-inline--align-center {
align-items: center;
}
.sui-inline--align-end {
align-items: flex-end;
}
.sui-inline--align-baseline {
align-items: baseline;
}
.sui-inline--justify-start {
justify-content: flex-start;
}
.sui-inline--justify-center {
justify-content: center;
}
.sui-inline--justify-end {
justify-content: flex-end;
}
.sui-inline--justify-between {
justify-content: space-between;
}
.sui-inline--justify-around {
justify-content: space-around;
}
.sui-inline--justify-evenly {
justify-content: space-evenly;
}
@@ -0,0 +1,54 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { Inline } from "@shared/components/Inline";
import { Button } from "@shared/components/Button";
import { StatusBadge } from "@shared/components/StatusBadge";
const meta: Meta<typeof Inline> = {
title: "Primitives/Layout/Inline",
component: Inline,
tags: ["autodocs"],
parameters: { layout: "padded" },
};
export default meta;
type Story = StoryObj<typeof Inline>;
export const Default: Story = {
render: () => (
<Inline gap="2">
<Button variant="gradient">Primary</Button>
<Button variant="outline">Secondary</Button>
<Button variant="ghost">Cancel</Button>
</Inline>
),
};
export const SpaceBetween: Story = {
render: () => (
<Inline
justify="between"
style={{
width: "30rem",
padding: 12,
border: "1px solid var(--color-border)",
borderRadius: 8,
}}
>
<span>Pipeline name</span>
<StatusBadge tone="success" pulse>
healthy
</StatusBadge>
</Inline>
),
};
export const Wrap: Story = {
render: () => (
<Inline gap="1" style={{ maxWidth: "20rem" }}>
{Array.from({ length: 12 }).map((_, i) => (
<StatusBadge key={i} tone="info" size="sm">
chip-{i + 1}
</StatusBadge>
))}
</Inline>
),
};
+50
View File
@@ -0,0 +1,50 @@
import type { ElementType, ReactNode, HTMLAttributes } from "react";
import type {
StackGap,
StackAlign,
StackJustify,
} from "@shared/components/Stack";
import "@shared/components/Inline.css";
export interface InlineProps extends HTMLAttributes<HTMLElement> {
/** Token-aligned gap between children (maps to `--space-*`). */
gap?: StackGap;
align?: StackAlign;
justify?: StackJustify;
/** Wrap to a new line when children overflow. Defaults to true. */
wrap?: boolean;
as?: ElementType;
children?: ReactNode;
}
/**
* Horizontal flex row with token-aligned gap. Sister to {@link Stack}.
* Wraps by default — pass `wrap={false}` for a strict single-line layout.
*/
export function Inline({
gap = "2",
align = "center",
justify,
wrap = true,
as,
className,
children,
...rest
}: InlineProps) {
const Tag: ElementType = as ?? "div";
const classes = [
"sui-inline",
`sui-inline--gap-${gap}`,
`sui-inline--align-${align}`,
justify ? `sui-inline--justify-${justify}` : "",
wrap ? "" : "sui-inline--nowrap",
className ?? "",
]
.filter(Boolean)
.join(" ");
return (
<Tag className={classes} {...rest}>
{children}
</Tag>
);
}
+66
View File
@@ -0,0 +1,66 @@
.sui-input {
display: inline-flex;
align-items: center;
gap: var(--space-2);
padding: 0 var(--space-2);
background: var(--color-surface);
border: 1px solid var(--color-border-input);
border-radius: var(--radius-md);
color: var(--color-text-1);
transition:
border-color var(--motion-fast),
box-shadow var(--motion-fast);
}
.sui-input:focus-within {
border-color: var(--color-blue);
box-shadow: 0 0 0 2px color-mix(in srgb, var(--color-blue) 16%, transparent);
}
.sui-input--sm {
min-height: 1.75rem;
padding: 0 var(--space-2);
}
.sui-input--md {
min-height: 2.25rem;
padding: 0 var(--space-3);
}
.sui-input--invalid {
border-color: var(--color-red);
}
.sui-input--invalid:focus-within {
box-shadow: 0 0 0 2px color-mix(in srgb, var(--color-red) 16%, transparent);
}
.sui-input--disabled {
opacity: 0.5;
cursor: not-allowed;
}
.sui-input__el {
flex: 1 1 auto;
min-width: 0;
font: inherit;
font-size: 0.875rem;
background: transparent;
border: none;
color: inherit;
outline: none;
padding: 0;
}
.sui-input--sm .sui-input__el {
font-size: 0.8125rem;
}
.sui-input__el::placeholder {
color: var(--color-text-placeholder);
}
.sui-input__icon {
display: inline-flex;
align-items: center;
color: var(--color-text-4);
flex-shrink: 0;
}
+47
View File
@@ -0,0 +1,47 @@
import { forwardRef, type InputHTMLAttributes, type ReactNode } from "react";
import "@shared/components/Input.css";
export type InputSize = "sm" | "md";
export interface InputProps extends Omit<
InputHTMLAttributes<HTMLInputElement>,
"size"
> {
/** Visual size. Distinct from the HTML `size` attribute (which is for character width). */
inputSize?: InputSize;
leadingIcon?: ReactNode;
trailingIcon?: ReactNode;
/** Force the error tone independently of FormField. */
invalid?: boolean;
}
export const Input = forwardRef<HTMLInputElement, InputProps>(function Input(
{ inputSize = "md", leadingIcon, trailingIcon, invalid, className, ...rest },
ref,
) {
return (
<span
className={[
"sui-input",
`sui-input--${inputSize}`,
invalid ? "sui-input--invalid" : "",
rest.disabled ? "sui-input--disabled" : "",
className ?? "",
]
.filter(Boolean)
.join(" ")}
>
{leadingIcon && (
<span className="sui-input__icon sui-input__icon--leading" aria-hidden>
{leadingIcon}
</span>
)}
<input ref={ref} className="sui-input__el" {...rest} />
{trailingIcon && (
<span className="sui-input__icon sui-input__icon--trailing" aria-hidden>
{trailingIcon}
</span>
)}
</span>
);
});
@@ -0,0 +1,36 @@
.sui-method {
display: inline-block;
font-family: var(--font-mono);
font-size: 0.6875rem;
font-weight: 600;
letter-spacing: 0.04em;
padding: 0.125rem 0.4375rem;
border-radius: var(--radius-sm);
border: 1px solid transparent;
line-height: 1.4;
}
.sui-method--get {
color: var(--color-green);
background: var(--color-green-light);
border-color: var(--color-green-border);
}
.sui-method--post {
color: var(--color-blue);
background: var(--color-blue-light);
border-color: var(--color-blue-border);
}
.sui-method--put {
color: var(--color-amber-dark);
background: var(--color-amber-light);
border-color: var(--color-amber-border);
}
.sui-method--patch {
color: var(--color-purple);
background: var(--color-purple-light);
border-color: var(--color-purple-border);
}
.sui-method--delete {
color: var(--color-red);
background: var(--color-red-light);
border-color: var(--color-red-border);
}
@@ -0,0 +1,42 @@
import type { Meta, StoryObj } from "@storybook/react";
import { MethodBadge, type HttpMethod } from "@shared/components/MethodBadge";
const meta: Meta<typeof MethodBadge> = {
title: "Primitives/MethodBadge",
component: MethodBadge,
parameters: { layout: "centered" },
args: { method: "POST" },
argTypes: {
method: {
control: "inline-radio",
options: ["GET", "POST", "PUT", "PATCH", "DELETE"] satisfies HttpMethod[],
},
},
};
export default meta;
type Story = StoryObj<typeof MethodBadge>;
export const Default: Story = {};
export const InRow: Story = {
render: () => (
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
<MethodBadge method="POST" />
<span
style={{ fontFamily: "var(--font-mono)", color: "var(--color-text-2)" }}
>
/v1/coi
</span>
</div>
),
};
export const Matrix: Story = {
render: () => (
<div style={{ display: "flex", gap: 8 }}>
{(["GET", "POST", "PUT", "PATCH", "DELETE"] as const).map((m) => (
<MethodBadge key={m} method={m} />
))}
</div>
),
};
@@ -0,0 +1,28 @@
import "@shared/components/MethodBadge.css";
export type HttpMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
export interface MethodBadgeProps {
method: HttpMethod;
className?: string;
}
/**
* Method chip used in the docs and the pipeline row to label HTTP verbs.
* Colours match the prototype: GET green, POST blue, PUT amber, DELETE red.
*/
export function MethodBadge({ method, className }: MethodBadgeProps) {
return (
<span
className={[
"sui-method",
`sui-method--${method.toLowerCase()}`,
className ?? "",
]
.filter(Boolean)
.join(" ")}
>
{method}
</span>
);
}
+84
View File
@@ -0,0 +1,84 @@
.sui-metric {
display: flex;
flex-direction: column;
gap: 0.5rem;
padding: 1.125rem 1.25rem;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-md);
transition:
box-shadow var(--motion-fast),
transform var(--motion-fast),
border-color var(--motion-fast);
min-width: 10rem;
/* When rendered as <button> in the interactive variant, neutralise UA chrome. */
font: inherit;
color: inherit;
text-align: left;
}
.sui-metric--primary {
background: var(--color-bg-subtle);
}
.sui-metric--interactive {
cursor: pointer;
}
.sui-metric--interactive:hover {
border-color: var(--color-border-hover);
box-shadow: var(--shadow-lg);
transform: translateY(-0.0625rem);
}
.sui-metric--interactive:focus-visible {
outline: 2px solid var(--color-blue);
outline-offset: 2px;
}
.sui-metric__header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
}
.sui-metric__label {
font-size: 0.75rem;
color: var(--color-text-4);
font-weight: 500;
letter-spacing: 0.01em;
}
.sui-metric__icon {
color: var(--color-text-5);
display: inline-flex;
}
.sui-metric__value {
font-size: 1.625rem;
font-weight: 600;
color: var(--color-text-1);
line-height: 1.1;
}
.sui-metric__footer {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.75rem;
}
.sui-metric__delta {
display: inline-flex;
align-items: center;
gap: 0.1875rem;
font-weight: 500;
}
.sui-metric__delta-arrow {
font-size: 0.6875rem;
}
.sui-metric__delta--up {
color: var(--color-green);
}
.sui-metric__delta--down {
color: var(--color-red);
}
.sui-metric__delta--flat {
color: var(--color-text-5);
}
.sui-metric__desc {
color: var(--color-text-4);
}
@@ -0,0 +1,87 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { MetricCard } from "@shared/components/MetricCard";
const meta: Meta<typeof MetricCard> = {
title: "Primitives/MetricCard",
component: MetricCard,
tags: ["autodocs"],
parameters: { layout: "padded" },
args: {
label: "Docs / 24h",
value: "2,481",
delta: 0.07,
emphasis: "default",
},
argTypes: {
emphasis: { control: "inline-radio", options: ["default", "primary"] },
deltaDirection: {
control: "inline-radio",
options: ["up", "down", "flat", undefined],
},
delta: { control: { type: "number", step: 0.01 } },
},
decorators: [
(S) => (
<div style={{ width: "16rem" }}>
<S />
</div>
),
],
};
export default meta;
type Story = StoryObj<typeof MetricCard>;
/** Flip value / delta / description / emphasis in controls. */
export const Playground: Story = {};
export const ProTierStrip: Story = {
decorators: [
(S) => (
<div style={{ width: "100%" }}>
<S />
</div>
),
],
render: () => (
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(4, 1fr)",
gap: 12,
}}
>
<MetricCard label="Docs / 30d" value="76,318" delta={0.12} />
<MetricCard label="Pipelines" value={12} delta={0.16} />
<MetricCard label="Agents active" value={7} delta={0.4} />
<MetricCard label="Eval pass rate" value="94.6%" delta={0.02} />
</div>
),
};
export const FreeTierStrip: Story = {
decorators: [
(S) => (
<div style={{ width: "100%" }}>
<S />
</div>
),
],
render: () => (
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(4, 1fr)",
gap: 12,
}}
>
<MetricCard
label="Docs processed"
value="247 / 500"
description="Free plan cap"
/>
<MetricCard label="Operations" value={189} />
<MetricCard label="Pipelines" value={3} />
<MetricCard label="Agents" value={1} />
</div>
),
};
+98
View File
@@ -0,0 +1,98 @@
import type { ReactNode } from "react";
import "@shared/components/MetricCard.css";
export type DeltaDirection = "up" | "down" | "flat";
export interface MetricCardProps {
label: string;
value: string | number;
/** Optional description shown under the delta line. */
description?: string;
/** Numeric delta as a fraction (0.12 = +12%). The sign drives direction unless `deltaDirection` is set. */
delta?: number;
/** Override the inferred direction — useful when you only want the colour, not the value. */
deltaDirection?: DeltaDirection;
/** Visual emphasis. `primary` = darker surface, used for hero metrics. */
emphasis?: "default" | "primary";
/** Optional icon shown in the top-right corner. */
icon?: ReactNode;
onClick?: () => void;
className?: string;
}
function inferDirection(delta?: number): DeltaDirection {
if (delta === undefined || delta === 0) return "flat";
return delta > 0 ? "up" : "down";
}
function formatDelta(delta: number) {
const pct = Math.round(Math.abs(delta) * 100);
return `${pct}%`;
}
/**
* Stirling's standard KPI card. Used on Home, Sources, Documents, Audit and
* every Infrastructure tab — the prototype calls these the metric strip.
*/
export function MetricCard({
label,
value,
description,
delta,
deltaDirection,
emphasis = "default",
icon,
onClick,
className,
}: MetricCardProps) {
const dir = deltaDirection ?? inferDirection(delta);
const interactive = !!onClick;
const classes = [
"sui-metric",
emphasis === "primary" ? "sui-metric--primary" : "",
interactive ? "sui-metric--interactive" : "",
className ?? "",
]
.filter(Boolean)
.join(" ");
const body = (
<>
<div className="sui-metric__header">
<span className="sui-metric__label">{label}</span>
{icon && (
<span className="sui-metric__icon" aria-hidden>
{icon}
</span>
)}
</div>
<div className="sui-metric__value">{value}</div>
{(delta !== undefined || description) && (
<div className="sui-metric__footer">
{delta !== undefined && (
<span className={`sui-metric__delta sui-metric__delta--${dir}`}>
<span className="sui-metric__delta-arrow" aria-hidden>
{dir === "up" ? "↑" : dir === "down" ? "↓" : "·"}
</span>
{formatDelta(delta)}
</span>
)}
{description && (
<span className="sui-metric__desc">{description}</span>
)}
</div>
)}
</>
);
if (interactive) {
return (
<button type="button" className={classes} onClick={onClick}>
{body}
</button>
);
}
return <div className={classes}>{body}</div>;
}
+99
View File
@@ -0,0 +1,99 @@
.sui-modal__backdrop {
position: fixed;
inset: 0;
background: rgba(15, 23, 42, 0.55);
display: flex;
align-items: flex-start;
justify-content: center;
padding: 5rem 1.5rem 1.5rem;
z-index: 100;
animation: fadeIn 0.18s ease both;
overscroll-behavior: contain;
}
.sui-modal {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-xl);
box-shadow:
0 1.25rem 3rem rgba(15, 23, 42, 0.35),
0 0 0 1px var(--color-border-light);
display: flex;
flex-direction: column;
width: 100%;
max-height: calc(100vh - 6.5rem);
overflow: hidden;
animation: scaleIn 0.2s cubic-bezier(0.4, 0, 0.2, 1) both;
}
.sui-modal--sm {
max-width: 24rem;
}
.sui-modal--md {
max-width: 32rem;
}
.sui-modal--lg {
max-width: 48rem;
}
.sui-modal--xl {
max-width: 64rem;
}
.sui-modal__header {
display: flex;
align-items: flex-start;
gap: 0.75rem;
padding: 1rem 1.125rem 0.75rem;
border-bottom: 1px solid var(--color-border-light);
}
.sui-modal__header-text {
flex: 1 1 auto;
min-width: 0;
}
.sui-modal__title {
font-size: 0.9375rem;
font-weight: 600;
color: var(--color-text-1);
}
.sui-modal__sub {
margin-top: 0.125rem;
font-size: 0.8125rem;
color: var(--color-text-4);
}
.sui-modal__close {
flex: 0 0 auto;
width: 1.75rem;
height: 1.75rem;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: var(--radius-sm);
color: var(--color-text-4);
transition:
background var(--motion-fast),
color var(--motion-fast);
}
.sui-modal__close:hover {
background: var(--color-bg-hover);
color: var(--color-text-1);
}
.sui-modal__body {
flex: 1 1 auto;
overflow-y: auto;
padding: 1rem 1.125rem 1.125rem;
color: var(--color-text-2);
}
.sui-modal__footer {
padding: 0.875rem 1.125rem;
border-top: 1px solid var(--color-border-light);
display: flex;
justify-content: flex-end;
gap: 0.5rem;
}
+171
View File
@@ -0,0 +1,171 @@
import { useEffect, useId, useRef, type ReactNode } from "react";
import { createPortal } from "react-dom";
import "@shared/components/Modal.css";
export type ModalWidth = "sm" | "md" | "lg" | "xl";
export interface ModalProps {
open: boolean;
onClose: () => void;
/** Optional heading slot rendered above the body. */
title?: ReactNode;
/** Optional sub-heading rendered under the title. */
subtitle?: ReactNode;
/** Optional footer slot rendered below the body. */
footer?: ReactNode;
/** Width preset. sm=24rem, md=32rem, lg=48rem, xl=64rem. Defaults to md. */
width?: ModalWidth;
/** Disable click-on-backdrop dismissal. Defaults to false. */
disableBackdropClose?: boolean;
/** Disable Escape-key dismissal. Defaults to false. */
disableEscapeClose?: boolean;
/** Accessible name when no visible `title` is provided. */
ariaLabel?: string;
className?: string;
children?: ReactNode;
}
const FOCUSABLE_SELECTOR =
'button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';
/**
* Portal-rendered modal. Backdrop fades in, dialog scales in. ESC and
* click-on-backdrop close by default. Each instance is self-contained — the
* caller owns the open state and the close handler.
*
* Focus is trapped inside the dialog while open and returned to the
* previously-focused element on close.
*/
export function Modal({
open,
onClose,
title,
subtitle,
footer,
width = "md",
disableBackdropClose = false,
disableEscapeClose = false,
ariaLabel,
className,
children,
}: ModalProps) {
const dialogRef = useRef<HTMLDivElement | null>(null);
const titleId = useId();
useEffect(() => {
if (!open || disableEscapeClose) return;
function onKey(e: KeyboardEvent) {
if (e.key === "Escape") onClose();
}
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
}, [open, disableEscapeClose, onClose]);
useEffect(() => {
if (!open) return;
const previous = document.body.style.overflow;
document.body.style.overflow = "hidden";
return () => {
document.body.style.overflow = previous;
};
}, [open]);
useEffect(() => {
if (!open) return;
const previouslyFocused = document.activeElement as HTMLElement | null;
const dialog = dialogRef.current;
const first = dialog?.querySelector<HTMLElement>(FOCUSABLE_SELECTOR);
first?.focus();
function onKey(e: KeyboardEvent) {
if (e.key !== "Tab" || !dialog) return;
const focusables =
dialog.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR);
if (focusables.length === 0) {
e.preventDefault();
return;
}
const firstEl = focusables[0];
const lastEl = focusables[focusables.length - 1];
const active = document.activeElement;
if (e.shiftKey && active === firstEl) {
e.preventDefault();
lastEl.focus();
} else if (!e.shiftKey && active === lastEl) {
e.preventDefault();
firstEl.focus();
}
}
document.addEventListener("keydown", onKey);
return () => {
document.removeEventListener("keydown", onKey);
previouslyFocused?.focus?.();
};
}, [open]);
if (!open) return null;
function onBackdropClick() {
if (!disableBackdropClose) onClose();
}
const hasTitle = title !== undefined && title !== null;
return createPortal(
<div
className="sui-modal__backdrop"
onClick={onBackdropClick}
role="presentation"
>
<div
ref={dialogRef}
className={["sui-modal", `sui-modal--${width}`, className ?? ""]
.filter(Boolean)
.join(" ")}
onClick={(e) => e.stopPropagation()}
role="dialog"
aria-modal="true"
aria-labelledby={hasTitle ? titleId : undefined}
aria-label={!hasTitle ? ariaLabel : undefined}
>
{(title || subtitle) && (
<header className="sui-modal__header">
<div className="sui-modal__header-text">
{title && (
<div id={titleId} className="sui-modal__title">
{title}
</div>
)}
{subtitle && <div className="sui-modal__sub">{subtitle}</div>}
</div>
<button
type="button"
className="sui-modal__close"
onClick={onClose}
aria-label="Close"
>
<svg
viewBox="0 0 24 24"
width="16"
height="16"
fill="none"
stroke="currentColor"
strokeWidth={1.75}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden
>
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
</header>
)}
<div className="sui-modal__body">{children}</div>
{footer && <footer className="sui-modal__footer">{footer}</footer>}
</div>
</div>,
document.body,
);
}
+47
View File
@@ -0,0 +1,47 @@
.sui-navitem {
display: flex;
align-items: center;
gap: 0.625rem;
width: 100%;
padding: 0.4375rem 0.75rem;
margin: 0.0625rem 0.5rem;
border-radius: var(--radius-lg);
color: var(--color-nav-text);
font-size: 0.8125rem;
font-weight: 400;
transition:
background var(--motion-fast),
color var(--motion-fast);
text-align: left;
}
.sui-navitem:hover {
background: var(--color-nav-hover);
color: var(--color-nav-hover-text);
}
.sui-navitem.is-active {
background: var(--color-nav-active);
color: var(--color-nav-active-text);
font-weight: 500;
}
.sui-navitem.is-active:hover {
background: var(--color-nav-active);
}
.sui-navitem__icon {
width: 1rem;
height: 1rem;
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.sui-navitem__label {
flex: 1;
}
.sui-navitem__trailing {
display: inline-flex;
align-items: center;
}
.sui-navitem:focus-visible {
outline: 0.125rem solid var(--color-blue);
outline-offset: 0.125rem;
}
@@ -0,0 +1,106 @@
import { useState } from "react";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { NavItem } from "@shared/components/NavItem";
import { SectionDivider } from "@shared/components/SectionDivider";
function Dot({ color = "var(--color-blue)" }: { color?: string }) {
return (
<span
style={{
width: 14,
height: 14,
borderRadius: 3,
background: color,
display: "inline-block",
}}
/>
);
}
const meta: Meta<typeof NavItem> = {
title: "Primitives/NavItem",
component: NavItem,
tags: ["autodocs"],
parameters: { layout: "padded" },
args: { id: "home", label: "Home", isActive: false },
argTypes: { isActive: { control: "boolean" } },
decorators: [
(S) => (
<div
style={{
width: "15rem",
background: "var(--color-sidebar-bg)",
padding: 10,
border: "1px solid var(--color-sidebar-border)",
borderRadius: 6,
}}
>
<S />
</div>
),
],
};
export default meta;
type Story = StoryObj<typeof NavItem>;
/** Flip isActive / label / icon / trailing in controls. */
export const Playground: Story = {
args: { icon: <Dot /> },
};
export const WithTrailingBadge: Story = {
args: {
icon: <Dot />,
trailing: (
<span
style={{
fontSize: 11,
color: "var(--color-blue)",
background: "var(--color-blue-light)",
padding: "1px 6px",
borderRadius: 10,
}}
>
3
</span>
),
},
};
export const InContext_SidebarGroup: Story = {
render: () => {
function Bound() {
const [active, setActive] = useState("pipelines");
const items = [
{ id: "home", label: "Home" },
{ id: "editor", label: "Editor" },
{ id: "sources", label: "Sources" },
{ id: "pipelines", label: "Pipelines" },
{ id: "documents", label: "Documents" },
];
return (
<div>
<NavItem
id="home"
label="Home"
icon={<Dot />}
isActive={active === "home"}
onClick={setActive}
/>
<SectionDivider />
{items.slice(1).map((item) => (
<NavItem
key={item.id}
id={item.id}
label={item.label}
icon={<Dot color="var(--color-purple)" />}
isActive={active === item.id}
onClick={setActive}
/>
))}
</div>
);
}
return <Bound />;
},
};
+51
View File
@@ -0,0 +1,51 @@
import type { ReactNode } from "react";
import "@shared/components/NavItem.css";
export interface NavItemProps {
/** Stable view id passed to the click handler. */
id: string;
label: string;
icon?: ReactNode;
/** Show the active highlight (navActive background, navActiveText colour). */
isActive?: boolean;
/** Optional trailing badge (e.g. unread count, "new"). */
trailing?: ReactNode;
onClick?: (id: string) => void;
className?: string;
}
/**
* Sidebar navigation row matching the prototype's hover + active styling.
*
* Active styling: navActive background, navActiveText colour, weight 500.
* Hover styling: navHover background, navHoverText colour (only when not
* already active).
*/
export function NavItem({
id,
label,
icon,
isActive,
trailing,
onClick,
className,
}: NavItemProps) {
return (
<button
type="button"
onClick={() => onClick?.(id)}
className={["sui-navitem", isActive ? "is-active" : "", className ?? ""]
.filter(Boolean)
.join(" ")}
aria-current={isActive ? "page" : undefined}
>
{icon && (
<span className="sui-navitem__icon" aria-hidden>
{icon}
</span>
)}
<span className="sui-navitem__label">{label}</span>
{trailing && <span className="sui-navitem__trailing">{trailing}</span>}
</button>
);
}
@@ -0,0 +1,48 @@
.sui-panelhdr {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 1rem 1.25rem;
border-bottom: 1px solid var(--color-border);
}
.sui-panelhdr__left {
display: flex;
align-items: center;
gap: 0.75rem;
min-width: 0;
}
.sui-panelhdr__back {
display: inline-flex;
align-items: center;
justify-content: center;
width: 2rem;
height: 2rem;
border-radius: var(--radius-md);
color: var(--color-text-3);
transition:
background var(--motion-fast),
color var(--motion-fast);
}
.sui-panelhdr__back:hover {
background: var(--color-bg-hover);
color: var(--color-text-1);
}
.sui-panelhdr__text {
min-width: 0;
}
.sui-panelhdr__title {
font-size: 1.0625rem;
font-weight: 600;
color: var(--color-text-1);
}
.sui-panelhdr__sub {
font-size: 0.8125rem;
color: var(--color-text-4);
margin-top: 0.125rem;
}
.sui-panelhdr__actions {
display: inline-flex;
align-items: center;
gap: 0.5rem;
}
@@ -0,0 +1,61 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { PanelHeader } from "@shared/components/PanelHeader";
import { Button } from "@shared/components/Button";
import { StatusBadge } from "@shared/components/StatusBadge";
const meta: Meta<typeof PanelHeader> = {
title: "Primitives/PanelHeader",
component: PanelHeader,
tags: ["autodocs"],
parameters: { layout: "padded" },
args: {
title: "Pipeline detail",
subtitle: "COI Compliance · us-east-1",
},
argTypes: { onBack: { action: "back" } },
};
export default meta;
type Story = StoryObj<typeof PanelHeader>;
/** Toggle title / subtitle / onBack / actions in controls. */
export const Playground: Story = {};
export const WithActions: Story = {
args: {
subtitle: "Last deploy 14m ago · golden set 48/48",
actions: (
<>
<StatusBadge tone="success" pulse>
Healthy
</StatusBadge>
<Button size="sm" variant="outline">
Edit composition
</Button>
<Button size="sm" variant="gradient">
View runs
</Button>
</>
),
},
};
export const Everything: Story = {
args: {
title: "Pipeline detail — COI Compliance",
subtitle: "Forked from Compliance Pack · 1,287 docs / 24h",
onBack: () => {},
actions: (
<>
<StatusBadge tone="success" pulse>
Healthy
</StatusBadge>
<Button size="sm" variant="outline">
Edit composition
</Button>
<Button size="sm" variant="gradient">
View runs
</Button>
</>
),
},
};
@@ -0,0 +1,60 @@
import type { ReactNode } from "react";
import "@shared/components/PanelHeader.css";
export interface PanelHeaderProps {
title: ReactNode;
/** Sub-heading below the title. */
subtitle?: ReactNode;
/** Show a back chevron and trigger this callback when clicked. */
onBack?: () => void;
/** Right-aligned action buttons / chips. */
actions?: ReactNode;
className?: string;
}
/**
* Header strip used by drill-down panels (admin tabs, agent detail, settings
* sub-pages). Back chevron renders only when `onBack` is supplied.
*/
export function PanelHeader({
title,
subtitle,
onBack,
actions,
className,
}: PanelHeaderProps) {
return (
<div
className={["sui-panelhdr", className ?? ""].filter(Boolean).join(" ")}
>
<div className="sui-panelhdr__left">
{onBack && (
<button
type="button"
className="sui-panelhdr__back"
onClick={onBack}
aria-label="Back"
>
<svg
viewBox="0 0 24 24"
width={18}
height={18}
fill="none"
stroke="currentColor"
strokeWidth={1.75}
strokeLinecap="round"
strokeLinejoin="round"
>
<polyline points="15 18 9 12 15 6" />
</svg>
</button>
)}
<div className="sui-panelhdr__text">
<div className="sui-panelhdr__title">{title}</div>
{subtitle && <div className="sui-panelhdr__sub">{subtitle}</div>}
</div>
</div>
{actions && <div className="sui-panelhdr__actions">{actions}</div>}
</div>
);
}
@@ -0,0 +1,13 @@
.sui-progress {
width: 100%;
background: var(--color-usage-track);
border-radius: 62.4375rem;
overflow: hidden;
}
.sui-progress__fill {
height: 100%;
border-radius: 62.4375rem;
transition:
width 0.5s cubic-bezier(0.4, 0, 0.2, 1),
background var(--motion-base);
}
@@ -0,0 +1,95 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { ProgressBar } from "@shared/components/ProgressBar";
const meta: Meta<typeof ProgressBar> = {
title: "Primitives/ProgressBar",
component: ProgressBar,
tags: ["autodocs"],
parameters: { layout: "padded" },
args: { value: 0.5, height: 6, thresholded: false },
argTypes: {
value: { control: { type: "range", min: 0, max: 1, step: 0.01 } },
height: { control: { type: "number" } },
thresholded: { control: "boolean" },
},
decorators: [
(S) => (
<div style={{ width: "24rem" }}>
<S />
</div>
),
],
};
export default meta;
type Story = StoryObj<typeof ProgressBar>;
/** Drag the value slider, toggle thresholded, change height in controls. */
export const Playground: Story = {};
export const ThresholdLadder: Story = {
render: () => (
<div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
{[0, 0.25, 0.5, 0.79, 0.85, 0.95, 0.98, 1].map((v) => (
<div
key={v}
style={{
display: "grid",
gridTemplateColumns: "3rem 1fr",
gap: 12,
alignItems: "center",
}}
>
<span style={{ fontFamily: "var(--font-mono)", fontSize: 12 }}>
{Math.round(v * 100)}%
</span>
<ProgressBar value={v} thresholded />
</div>
))}
</div>
),
};
export const InContext_UsageMeter: Story = {
decorators: [
(S) => (
<div
style={{
width: "16rem",
padding: 14,
border: "1px solid var(--color-border)",
borderRadius: 8,
background: "var(--color-surface)",
}}
>
<S />
</div>
),
],
render: () => (
<div>
<div
style={{
display: "flex",
justifyContent: "space-between",
fontSize: 12,
marginBottom: 6,
}}
>
<span style={{ color: "var(--color-text-4)" }}>Docs processed</span>
<span style={{ color: "var(--color-text-2)", fontWeight: 500 }}>
412 / 500
</span>
</div>
<ProgressBar value={0.824} thresholded label="Docs processed" />
<div
style={{
marginTop: 8,
fontSize: 11,
color: "var(--color-amber-dark)",
}}
>
Approaching the free-plan cap
</div>
</div>
),
};
@@ -0,0 +1,70 @@
import "@shared/components/ProgressBar.css";
export interface ProgressBarProps {
/** 01. Values outside the range are clamped. */
value: number;
/** Height in pixels. Defaults to 6. */
height?: number;
/** When set, colour shifts to amber at 80% and red at 96% — the prototype's usage-meter behaviour. */
thresholded?: boolean;
/** Optional override colour (CSS gradient or solid). Disables threshold behaviour. */
color?: string;
className?: string;
/** Accessible label for screen readers. */
label?: string;
}
function clamp01(n: number) {
return Math.max(0, Math.min(1, n));
}
/**
* Progress bar with optional threshold-based colouring.
*
* The prototype's sidebar usage meter uses `thresholded` so the bar turns
* amber at 80% and red at 96% — a small visual hint that drives upgrade
* conversion. Pipeline progress / storage bars typically pass a fixed colour.
*/
export function ProgressBar({
value,
height = 6,
thresholded = false,
color,
className,
label,
}: ProgressBarProps) {
const v = clamp01(value);
let fill = color;
if (!fill) {
// Second stop is a lighter mix of the same token so the gradient keeps its
// sheen in both themes (a hardcoded hex here flattened to a solid bar in
// dark mode, where the token already equalled that hex).
if (thresholded) {
fill =
v >= 0.96
? "linear-gradient(90deg, var(--color-red), color-mix(in srgb, var(--color-red) 70%, white))"
: v >= 0.8
? "linear-gradient(90deg, var(--color-amber), color-mix(in srgb, var(--color-amber) 70%, white))"
: "linear-gradient(90deg, var(--color-blue), color-mix(in srgb, var(--color-blue) 70%, white))";
} else {
fill =
"linear-gradient(90deg, var(--color-blue), color-mix(in srgb, var(--color-blue) 70%, white))";
}
}
return (
<div
className={["sui-progress", className ?? ""].filter(Boolean).join(" ")}
style={{ height }}
role="progressbar"
aria-valuenow={Math.round(v * 100)}
aria-valuemin={0}
aria-valuemax={100}
aria-label={label}
>
<div
className="sui-progress__fill"
style={{ width: `${v * 100}%`, background: fill }}
/>
</div>
);
}
+77
View File
@@ -0,0 +1,77 @@
.sui-radio-group {
display: flex;
}
.sui-radio-group--vertical {
flex-direction: column;
gap: var(--space-2);
}
.sui-radio-group--horizontal {
flex-direction: row;
gap: var(--space-4);
flex-wrap: wrap;
}
.sui-radio {
display: inline-flex;
align-items: flex-start;
gap: var(--space-2);
cursor: pointer;
font-size: 0.8125rem;
color: var(--color-text-2);
}
.sui-radio--disabled {
opacity: 0.5;
cursor: not-allowed;
}
.sui-radio__input {
position: absolute;
opacity: 0;
width: 0;
height: 0;
}
.sui-radio__dot {
flex: 0 0 auto;
width: 1rem;
height: 1rem;
margin-top: 0.0625rem;
border-radius: 50%;
border: 1.5px solid var(--color-border-hover);
background: var(--color-surface);
display: inline-flex;
align-items: center;
justify-content: center;
transition: border-color var(--motion-fast);
position: relative;
}
.sui-radio__input:focus-visible + .sui-radio__dot {
outline: 2px solid var(--color-blue);
outline-offset: 2px;
}
.sui-radio__input:checked + .sui-radio__dot {
border-color: var(--color-blue);
}
.sui-radio__input:checked + .sui-radio__dot::after {
content: "";
width: 0.5rem;
height: 0.5rem;
border-radius: 50%;
background: var(--color-blue);
}
.sui-radio__text {
display: flex;
flex-direction: column;
}
.sui-radio__label {
color: var(--color-text-1);
font-weight: 500;
}
.sui-radio__desc {
font-size: 0.75rem;
color: var(--color-text-4);
}
+88
View File
@@ -0,0 +1,88 @@
import { type InputHTMLAttributes, type ReactNode } from "react";
import "@shared/components/Radio.css";
export interface RadioOption<V extends string = string> {
value: V;
label: ReactNode;
description?: ReactNode;
disabled?: boolean;
}
export interface RadioGroupProps<V extends string = string> {
name: string;
value: V;
onChange: (value: V) => void;
options: RadioOption<V>[];
/** Layout. `vertical` stacks options; `horizontal` lays them inline. */
direction?: "vertical" | "horizontal";
className?: string;
}
/** Standalone single radio — usually consumed via {@link RadioGroup}. */
export function Radio({
label,
description,
className,
...rest
}: Omit<InputHTMLAttributes<HTMLInputElement>, "type"> & {
label?: ReactNode;
description?: ReactNode;
}) {
return (
<label
className={[
"sui-radio",
rest.disabled ? "sui-radio--disabled" : "",
className ?? "",
]
.filter(Boolean)
.join(" ")}
>
<input type="radio" className="sui-radio__input" {...rest} />
<span className="sui-radio__dot" aria-hidden />
{(label || description) && (
<span className="sui-radio__text">
{label && <span className="sui-radio__label">{label}</span>}
{description && (
<span className="sui-radio__desc">{description}</span>
)}
</span>
)}
</label>
);
}
export function RadioGroup<V extends string = string>({
name,
value,
onChange,
options,
direction = "vertical",
className,
}: RadioGroupProps<V>) {
return (
<div
role="radiogroup"
className={[
"sui-radio-group",
`sui-radio-group--${direction}`,
className ?? "",
]
.filter(Boolean)
.join(" ")}
>
{options.map((opt) => (
<Radio
key={opt.value}
name={name}
value={opt.value}
checked={opt.value === value}
onChange={() => onChange(opt.value)}
disabled={opt.disabled}
label={opt.label}
description={opt.description}
/>
))}
</div>
);
}
@@ -0,0 +1,5 @@
.sui-divider {
height: 0.0625rem;
background: var(--color-sidebar-divider);
width: 100%;
}
@@ -0,0 +1,46 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { SectionDivider } from "@shared/components/SectionDivider";
const meta: Meta<typeof SectionDivider> = {
title: "Primitives/SectionDivider",
component: SectionDivider,
tags: ["autodocs"],
parameters: { layout: "padded" },
};
export default meta;
type Story = StoryObj<typeof SectionDivider>;
export const Default: Story = {
render: () => (
<div style={{ width: "24rem" }}>
<p style={{ color: "var(--color-text-3)" }}>Section above</p>
<SectionDivider />
<p style={{ color: "var(--color-text-3)" }}>Section below</p>
</div>
),
};
export const InContext_SidebarGroups: Story = {
render: () => (
<div
style={{
width: "15rem",
background: "var(--color-sidebar-bg)",
padding: 10,
border: "1px solid var(--color-sidebar-border)",
borderRadius: 6,
color: "var(--color-text-2)",
fontSize: 13,
}}
>
<div style={{ padding: "8px 10px" }}>Home</div>
<SectionDivider />
<div style={{ padding: "8px 10px" }}>Editor</div>
<div style={{ padding: "8px 10px" }}>Sources</div>
<div style={{ padding: "8px 10px" }}>Pipelines</div>
<SectionDivider />
<div style={{ padding: "8px 10px" }}>Infrastructure</div>
<div style={{ padding: "8px 10px" }}>Usage</div>
</div>
),
};
@@ -0,0 +1,25 @@
import "@shared/components/SectionDivider.css";
export interface SectionDividerProps {
/** Vertical margin in px. */
spacing?: number;
className?: string;
}
/**
* Hairline divider used between sidebar groups and inside settings panels.
* No section label — the prototype's deliberate Supabase pattern is to group
* via the divider alone, without forcing a category noun onto the surfaces.
*/
export function SectionDivider({
spacing = 12,
className,
}: SectionDividerProps) {
return (
<div
className={["sui-divider", className ?? ""].filter(Boolean).join(" ")}
style={{ margin: `${spacing}px 0` }}
role="separator"
/>
);
}
+60
View File
@@ -0,0 +1,60 @@
.sui-select {
position: relative;
display: inline-flex;
align-items: center;
background: var(--color-surface);
border: 1px solid var(--color-border-input);
border-radius: var(--radius-md);
color: var(--color-text-1);
transition:
border-color var(--motion-fast),
box-shadow var(--motion-fast);
}
.sui-select:focus-within {
border-color: var(--color-blue);
box-shadow: 0 0 0 2px color-mix(in srgb, var(--color-blue) 16%, transparent);
}
.sui-select--sm {
min-height: 1.75rem;
}
.sui-select--md {
min-height: 2.25rem;
}
.sui-select--invalid {
border-color: var(--color-red);
}
.sui-select--disabled {
opacity: 0.5;
cursor: not-allowed;
}
.sui-select__el {
flex: 1 1 auto;
font: inherit;
font-size: 0.875rem;
background: transparent;
border: none;
color: inherit;
outline: none;
padding: 0 var(--space-6) 0 var(--space-3);
appearance: none;
-webkit-appearance: none;
}
.sui-select--sm .sui-select__el {
font-size: 0.8125rem;
padding-left: var(--space-2);
padding-right: var(--space-5);
}
.sui-select__caret {
position: absolute;
right: var(--space-2);
display: inline-flex;
align-items: center;
pointer-events: none;
color: var(--color-text-4);
}
+69
View File
@@ -0,0 +1,69 @@
import { forwardRef, type SelectHTMLAttributes } from "react";
import "@shared/components/Select.css";
export interface SelectOption {
value: string;
label: string;
disabled?: boolean;
}
export type SelectSize = "sm" | "md";
export interface SelectProps extends Omit<
SelectHTMLAttributes<HTMLSelectElement>,
"size"
> {
inputSize?: SelectSize;
options: SelectOption[];
/** Optional placeholder rendered as a disabled first option. */
placeholder?: string;
invalid?: boolean;
}
export const Select = forwardRef<HTMLSelectElement, SelectProps>(
function Select(
{ inputSize = "md", options, placeholder, invalid, className, ...rest },
ref,
) {
return (
<span
className={[
"sui-select",
`sui-select--${inputSize}`,
invalid ? "sui-select--invalid" : "",
rest.disabled ? "sui-select--disabled" : "",
className ?? "",
]
.filter(Boolean)
.join(" ")}
>
<select ref={ref} className="sui-select__el" {...rest}>
{placeholder && (
<option value="" disabled hidden>
{placeholder}
</option>
)}
{options.map((opt) => (
<option key={opt.value} value={opt.value} disabled={opt.disabled}>
{opt.label}
</option>
))}
</select>
<span className="sui-select__caret" aria-hidden>
<svg
viewBox="0 0 24 24"
width="12"
height="12"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
>
<polyline points="6 9 12 15 18 9" />
</svg>
</span>
</span>
);
},
);
+30
View File
@@ -0,0 +1,30 @@
.sui-skel {
display: inline-block;
background: linear-gradient(
90deg,
var(--color-bg-muted) 0%,
var(--color-bg-hover) 50%,
var(--color-bg-muted) 100%
);
background-size: 200% 100%;
animation: shimmer 1.4s linear infinite;
vertical-align: middle;
}
.sui-skel--text {
border-radius: var(--radius-pill);
}
.sui-skel--rect {
border-radius: var(--radius-md);
}
.sui-skel--circle {
border-radius: 50%;
aspect-ratio: 1 / 1;
}
.sui-skel-lines {
display: flex;
flex-direction: column;
gap: var(--space-1);
width: 100%;
}
@@ -0,0 +1,62 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { Skeleton } from "@shared/components/Skeleton";
const meta: Meta<typeof Skeleton> = {
title: "Primitives/Skeleton",
component: Skeleton,
tags: ["autodocs"],
parameters: { layout: "padded" },
args: { width: "20rem", shape: "text", lines: 1 },
argTypes: {
shape: { control: "inline-radio", options: ["text", "rect", "circle"] },
lines: { control: { type: "number", min: 1, max: 8 } },
width: { control: "text" },
height: { control: "text" },
},
};
export default meta;
type Story = StoryObj<typeof Skeleton>;
/** Flip shape / lines / width / height in controls. */
export const Playground: Story = {};
export const InContext_TableRow: Story = {
render: () => (
<div
style={{
display: "grid",
gridTemplateColumns: "12rem 1fr 5rem 5rem",
gap: 12,
alignItems: "center",
width: "44rem",
}}
>
<Skeleton />
<Skeleton />
<Skeleton />
<Skeleton />
</div>
),
};
export const InContext_ActivityFeed: Story = {
render: () => (
<div
style={{
display: "flex",
flexDirection: "column",
gap: 12,
width: "26rem",
}}
>
{Array.from({ length: 4 }).map((_, i) => (
<div key={i} style={{ display: "flex", gap: 10 }}>
<Skeleton shape="circle" width="2rem" height="2rem" />
<div style={{ flex: 1 }}>
<Skeleton lines={2} />
</div>
</div>
))}
</div>
),
};
+67
View File
@@ -0,0 +1,67 @@
import "@shared/components/Skeleton.css";
export interface SkeletonProps {
/** Width as a CSS length (`100%`, `12rem`, …). Defaults to 100%. */
width?: string | number;
/** Height as a CSS length. Defaults to 0.75rem. */
height?: string | number;
/** Shape preset. `text` = pill-rounded line, `rect` = card / image area. */
shape?: "text" | "rect" | "circle";
/** For text shape, the number of stacked lines to render. */
lines?: number;
className?: string;
}
/**
* Shimmering placeholder for content that hasn't loaded yet. Replaces the
* ad-hoc `linear-gradient(...) shimmer 1.4s` blocks that were duplicated
* across the deployed-pipelines table, the activity feed, and the doc-type
* grid.
*/
export function Skeleton({
width = "100%",
height,
shape = "text",
lines = 1,
className,
}: SkeletonProps) {
const dim = (v: string | number | undefined) =>
typeof v === "number" ? `${v}px` : v;
if (shape === "text" && lines > 1) {
return (
<span
className={["sui-skel-lines", className ?? ""]
.filter(Boolean)
.join(" ")}
>
{Array.from({ length: lines }).map((_, i) => (
<span
key={i}
className="sui-skel sui-skel--text"
style={{
// Last line is slightly shorter — feels more natural.
width: i === lines - 1 ? "70%" : (dim(width) ?? "100%"),
height: dim(height) ?? "0.75rem",
}}
/>
))}
</span>
);
}
return (
<span
className={["sui-skel", `sui-skel--${shape}`, className ?? ""]
.filter(Boolean)
.join(" ")}
style={{
width: dim(width),
height:
dim(height) ??
(shape === "text" ? "0.75rem" : shape === "circle" ? "2rem" : "6rem"),
}}
aria-hidden
/>
);
}
+68
View File
@@ -0,0 +1,68 @@
.sui-slider {
display: inline-flex;
align-items: center;
gap: var(--space-3);
width: 100%;
}
.sui-slider--disabled {
opacity: 0.5;
}
.sui-slider__input {
flex: 1 1 auto;
appearance: none;
-webkit-appearance: none;
height: 0.5rem;
border-radius: var(--radius-pill);
background: linear-gradient(
90deg,
var(--color-blue) 0%,
var(--color-blue) var(--slider-pct),
var(--color-bg-muted) var(--slider-pct),
var(--color-bg-muted) 100%
);
outline: none;
}
.sui-slider__input::-webkit-slider-thumb {
appearance: none;
-webkit-appearance: none;
width: 1rem;
height: 1rem;
border-radius: 50%;
background: #fff;
border: 2px solid var(--color-blue);
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.15);
cursor: pointer;
transition: transform var(--motion-fast);
}
.sui-slider__input::-moz-range-thumb {
width: 1rem;
height: 1rem;
border-radius: 50%;
background: #fff;
border: 2px solid var(--color-blue);
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.15);
cursor: pointer;
}
.sui-slider__input:focus-visible::-webkit-slider-thumb {
outline: 2px solid var(--color-blue);
outline-offset: 2px;
}
.sui-slider__input:hover::-webkit-slider-thumb {
transform: scale(1.08);
}
.sui-slider__value {
flex: 0 0 auto;
min-width: 3rem;
text-align: right;
font-family: var(--font-mono);
font-size: 0.75rem;
font-weight: 500;
color: var(--color-text-2);
}
+63
View File
@@ -0,0 +1,63 @@
import { forwardRef, type InputHTMLAttributes } from "react";
import "@shared/components/Slider.css";
export interface SliderProps extends Omit<
InputHTMLAttributes<HTMLInputElement>,
"type" | "value" | "onChange"
> {
value: number;
min?: number;
max?: number;
step?: number;
onChange: (value: number) => void;
/** Optional formatter for the value pill (e.g. "0.85", "30 days"). */
formatValue?: (value: number) => string;
/** Show the right-aligned value badge. Defaults to true. */
showValue?: boolean;
}
export const Slider = forwardRef<HTMLInputElement, SliderProps>(function Slider(
{
value,
min = 0,
max = 1,
step = 0.01,
onChange,
formatValue,
showValue = true,
className,
...rest
},
ref,
) {
const pct = ((value - min) / (max - min)) * 100;
return (
<span
className={[
"sui-slider",
rest.disabled ? "sui-slider--disabled" : "",
className ?? "",
]
.filter(Boolean)
.join(" ")}
style={{ "--slider-pct": `${pct}%` } as React.CSSProperties}
>
<input
ref={ref}
type="range"
min={min}
max={max}
step={step}
value={value}
onChange={(e) => onChange(Number(e.target.value))}
className="sui-slider__input"
{...rest}
/>
{showValue && (
<span className="sui-slider__value" aria-hidden>
{formatValue ? formatValue(value) : value.toString()}
</span>
)}
</span>
);
});
+27
View File
@@ -0,0 +1,27 @@
.sui-spinner {
display: inline-block;
border-radius: 50%;
border: 2px solid currentColor;
border-right-color: transparent;
animation: spin 0.7s linear infinite;
vertical-align: middle;
}
.sui-spinner--xs {
width: 0.625rem;
height: 0.625rem;
border-width: 1.5px;
}
.sui-spinner--sm {
width: 0.875rem;
height: 0.875rem;
}
.sui-spinner--md {
width: 1.25rem;
height: 1.25rem;
}
.sui-spinner--lg {
width: 1.75rem;
height: 1.75rem;
border-width: 2.5px;
}
@@ -0,0 +1,45 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { Spinner } from "@shared/components/Spinner";
const meta: Meta<typeof Spinner> = {
title: "Primitives/Spinner",
component: Spinner,
tags: ["autodocs"],
parameters: { layout: "centered" },
args: { size: "md" },
argTypes: {
size: { control: "inline-radio", options: ["xs", "sm", "md", "lg"] },
},
};
export default meta;
type Story = StoryObj<typeof Spinner>;
/** Flip size in controls. */
export const Playground: Story = {};
export const SizeRow: Story = {
render: () => (
<div style={{ display: "flex", gap: 24, alignItems: "center" }}>
<Spinner size="xs" />
<Spinner size="sm" />
<Spinner size="md" />
<Spinner size="lg" />
</div>
),
};
export const InheritsColor: Story = {
render: () => (
<div style={{ display: "flex", gap: 24 }}>
<span style={{ color: "var(--color-blue)" }}>
<Spinner />
</span>
<span style={{ color: "var(--color-green)" }}>
<Spinner />
</span>
<span style={{ color: "var(--color-red)" }}>
<Spinner />
</span>
</div>
),
};
+28
View File
@@ -0,0 +1,28 @@
import "@shared/components/Spinner.css";
export type SpinnerSize = "xs" | "sm" | "md" | "lg";
export interface SpinnerProps {
size?: SpinnerSize;
/** Optional accessible label for screen readers. */
label?: string;
className?: string;
}
/**
* Circular spinner. Inherits `currentColor` so it picks up the surrounding
* text colour — drop it into a Button, a banner, or a code header without
* any extra styling.
*/
export function Spinner({ size = "md", label, className }: SpinnerProps) {
return (
<span
role="status"
aria-label={label}
aria-busy="true"
className={["sui-spinner", `sui-spinner--${size}`, className ?? ""]
.filter(Boolean)
.join(" ")}
/>
);
}
+76
View File
@@ -0,0 +1,76 @@
.sui-stack {
display: flex;
flex-direction: column;
min-width: 0;
}
.sui-stack--gap-0 {
gap: var(--space-0);
}
.sui-stack--gap-0_5 {
gap: var(--space-0_5);
}
.sui-stack--gap-1 {
gap: var(--space-1);
}
.sui-stack--gap-1_5 {
gap: var(--space-1_5);
}
.sui-stack--gap-2 {
gap: var(--space-2);
}
.sui-stack--gap-3 {
gap: var(--space-3);
}
.sui-stack--gap-4 {
gap: var(--space-4);
}
.sui-stack--gap-5 {
gap: var(--space-5);
}
.sui-stack--gap-6 {
gap: var(--space-6);
}
.sui-stack--gap-8 {
gap: var(--space-8);
}
.sui-stack--align-stretch {
align-items: stretch;
}
.sui-stack--align-start {
align-items: flex-start;
}
.sui-stack--align-center {
align-items: center;
}
.sui-stack--align-end {
align-items: flex-end;
}
.sui-stack--align-baseline {
align-items: baseline;
}
.sui-stack--justify-start {
justify-content: flex-start;
}
.sui-stack--justify-center {
justify-content: center;
}
.sui-stack--justify-end {
justify-content: flex-end;
}
.sui-stack--justify-between {
justify-content: space-between;
}
.sui-stack--justify-around {
justify-content: space-around;
}
.sui-stack--justify-evenly {
justify-content: space-evenly;
}
.sui-stack--fill {
flex: 1 1 auto;
min-height: 0;
}
@@ -0,0 +1,70 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { Stack } from "@shared/components/Stack";
import { Inline } from "@shared/components/Inline";
import { Card } from "@shared/components/Card";
const meta: Meta<typeof Stack> = {
title: "Primitives/Layout/Stack",
component: Stack,
tags: ["autodocs"],
parameters: { layout: "padded" },
};
export default meta;
type Story = StoryObj<typeof Stack>;
function Box({ children }: { children: React.ReactNode }) {
return (
<div
style={{
padding: 8,
background: "var(--color-bg-muted)",
borderRadius: 6,
fontSize: 12,
}}
>
{children}
</div>
);
}
export const Default: Story = {
render: () => (
<Stack gap="2" style={{ width: "20rem" }}>
<Box>One</Box>
<Box>Two</Box>
<Box>Three</Box>
</Stack>
),
};
export const GapSizes: Story = {
render: () => (
<Inline gap="6" align="start">
{(["1", "2", "4", "6"] as const).map((gap) => (
<Stack key={gap} gap={gap}>
<div style={{ fontSize: 11, color: "var(--color-text-4)" }}>
gap {gap}
</div>
<Box>A</Box>
<Box>B</Box>
<Box>C</Box>
</Stack>
))}
</Inline>
),
};
export const InCard: Story = {
render: () => (
<Card padding="loose" style={{ width: "20rem" }}>
<Stack gap="3">
<div style={{ fontWeight: 600 }}>Card title</div>
<div style={{ color: "var(--color-text-3)", fontSize: 13 }}>
Stack is the default vertical container it's how you compose every
card body, list, and form section.
</div>
<Box>Action row</Box>
</Stack>
</Card>
),
};
+67
View File
@@ -0,0 +1,67 @@
import type { ElementType, ReactNode, HTMLAttributes } from "react";
import "@shared/components/Stack.css";
export type StackGap =
| "0"
| "0_5"
| "1"
| "1_5"
| "2"
| "3"
| "4"
| "5"
| "6"
| "8";
export type StackAlign = "stretch" | "start" | "center" | "end" | "baseline";
export type StackJustify =
| "start"
| "center"
| "end"
| "between"
| "around"
| "evenly";
export interface StackProps extends HTMLAttributes<HTMLElement> {
/** Token-aligned gap between children (maps to `--space-*`). */
gap?: StackGap;
align?: StackAlign;
justify?: StackJustify;
/** Stretch to fill parent height. */
fill?: boolean;
/** Render as a custom element (defaults to div). */
as?: ElementType;
children?: ReactNode;
}
/**
* Vertical flex column with token-aligned gap. Replaces inline
* `style={{ display: 'flex', flexDirection: 'column', gap: ... }}` everywhere.
*/
export function Stack({
gap = "2",
align,
justify,
fill,
as,
className,
children,
...rest
}: StackProps) {
const Tag: ElementType = as ?? "div";
const classes = [
"sui-stack",
`sui-stack--gap-${gap}`,
align ? `sui-stack--align-${align}` : "",
justify ? `sui-stack--justify-${justify}` : "",
fill ? "sui-stack--fill" : "",
className ?? "",
]
.filter(Boolean)
.join(" ");
return (
<Tag className={classes} {...rest}>
{children}
</Tag>
);
}
@@ -0,0 +1,70 @@
.sui-status {
display: inline-flex;
align-items: center;
gap: 0.375rem;
border-radius: var(--radius-pill);
font-family: var(--font-sans);
font-weight: 500;
letter-spacing: 0.01em;
border: 1px solid transparent;
line-height: 1;
}
.sui-status--sm {
font-size: 0.6875rem;
padding: 0.125rem 0.5rem;
}
.sui-status--md {
font-size: 0.75rem;
padding: 0.1875rem 0.625rem;
}
.sui-status--lg {
font-size: 0.8125rem;
padding: 0.3125rem 0.75rem;
}
.sui-status__dot {
width: 0.375rem;
height: 0.375rem;
border-radius: 50%;
background: currentColor;
position: relative;
}
.sui-status__dot--pulse::after {
content: "";
position: absolute;
inset: -0.125rem;
border-radius: 50%;
border: 2px solid currentColor;
animation: pulseRing 1.4s ease-out infinite;
}
.sui-status--neutral {
color: var(--color-text-3);
background: var(--color-bg-muted);
border-color: var(--color-border-light);
}
.sui-status--success {
color: var(--color-green);
background: var(--color-green-light);
border-color: var(--color-green-border);
}
.sui-status--warning {
color: var(--color-amber-dark);
background: var(--color-amber-light);
border-color: var(--color-amber-border);
}
.sui-status--danger {
color: var(--color-red);
background: var(--color-red-light);
border-color: var(--color-red-border);
}
.sui-status--info {
color: var(--color-blue);
background: var(--color-blue-light);
border-color: var(--color-blue-border);
}
.sui-status--purple {
color: var(--color-purple);
background: var(--color-purple-light);
border-color: var(--color-purple-border);
}
@@ -0,0 +1,53 @@
import type { Meta, StoryObj } from "@storybook/react";
import { StatusBadge } from "@shared/components/StatusBadge";
const meta: Meta<typeof StatusBadge> = {
title: "Primitives/StatusBadge",
component: StatusBadge,
parameters: { layout: "centered" },
args: { children: "Healthy", tone: "success" },
argTypes: {
tone: {
control: "inline-radio",
options: ["neutral", "success", "warning", "danger", "info", "purple"],
},
size: { control: "inline-radio", options: ["sm", "md", "lg"] },
},
};
export default meta;
type Story = StoryObj<typeof StatusBadge>;
export const Default: Story = {};
export const AllTones: Story = {
render: () => (
<div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
<StatusBadge tone="success">Processed</StatusBadge>
<StatusBadge tone="warning">Needs review</StatusBadge>
<StatusBadge tone="danger">Escalated</StatusBadge>
<StatusBadge tone="info">In review</StatusBadge>
<StatusBadge tone="purple">Resolved</StatusBadge>
<StatusBadge tone="neutral">Paused</StatusBadge>
</div>
),
};
export const Live: Story = {
args: { tone: "success", pulse: true, children: "Live" },
};
export const Sizes: Story = {
render: () => (
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<StatusBadge tone="info" size="sm">
Small
</StatusBadge>
<StatusBadge tone="info" size="md">
Medium
</StatusBadge>
<StatusBadge tone="info" size="lg">
Large
</StatusBadge>
</div>
),
};
@@ -0,0 +1,56 @@
import type { ReactNode } from "react";
import "@shared/components/StatusBadge.css";
export type StatusTone =
| "neutral"
| "success"
| "warning"
| "danger"
| "info"
| "purple";
export type StatusSize = "sm" | "md" | "lg";
export interface StatusBadgeProps {
tone?: StatusTone;
size?: StatusSize;
/** Show a leading coloured dot. */
showDot?: boolean;
/** Render the dot with a pulse animation (active / live indicator). */
pulse?: boolean;
children?: ReactNode;
className?: string;
}
/**
* Inline status pill used across surfaces — pipeline rows, document status,
* deployments, audit logs. Tone maps to semantic meaning, not raw colour.
*/
export function StatusBadge({
tone = "neutral",
size = "md",
showDot = true,
pulse = false,
children,
className,
}: StatusBadgeProps) {
const cls = [
"sui-status",
`sui-status--${tone}`,
`sui-status--${size}`,
className ?? "",
]
.filter(Boolean)
.join(" ");
return (
<span className={cls}>
{showDot && (
<span
className={`sui-status__dot${pulse ? " sui-status__dot--pulse" : ""}`}
aria-hidden
/>
)}
<span className="sui-status__label">{children}</span>
</span>
);
}
+77
View File
@@ -0,0 +1,77 @@
.sui-tabs {
display: flex;
gap: var(--space-1);
flex-wrap: wrap;
}
.sui-tabs--pill {
}
.sui-tabs--underline {
border-bottom: 1px solid var(--color-border-light);
padding-bottom: var(--space-1);
gap: var(--space-3);
}
.sui-tabs__tab {
display: inline-flex;
align-items: center;
gap: var(--space-1_5);
padding: var(--space-1_5) var(--space-3);
font-size: 0.8125rem;
color: var(--color-text-3);
background: transparent;
border: 1px solid transparent;
transition:
color var(--motion-fast),
background var(--motion-fast),
border-color var(--motion-fast);
}
.sui-tabs__tab:hover:not(.is-disabled) {
color: var(--color-text-1);
background: var(--color-bg-hover);
}
.sui-tabs__tab.is-disabled {
opacity: 0.45;
cursor: not-allowed;
}
/* Pill variant — rounded chip, filled tint when active. */
.sui-tabs--pill .sui-tabs__tab {
border-radius: var(--radius-pill);
}
.sui-tabs--pill .sui-tabs__tab.is-active {
color: var(--sui-tab-accent, var(--color-blue));
background: var(--color-blue-light);
border-color: var(--sui-tab-accent, var(--color-blue-border));
font-weight: 500;
}
/* Underline variant — line-under, no background fill. */
.sui-tabs--underline .sui-tabs__tab {
border-radius: 0;
border-bottom: 2px solid transparent;
padding-bottom: var(--space-2);
margin-bottom: -1px;
}
.sui-tabs--underline .sui-tabs__tab.is-active {
color: var(--sui-tab-accent, var(--color-blue));
border-bottom-color: var(--sui-tab-accent, var(--color-blue));
font-weight: 500;
}
.sui-tabs__dot {
width: 0.4375rem;
height: 0.4375rem;
border-radius: 50%;
}
.sui-tabs__count {
font-size: 0.6875rem;
color: var(--color-text-5);
}
.sui-tabs__tab.is-active .sui-tabs__count {
color: inherit;
}
@@ -0,0 +1,93 @@
import { useState } from "react";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { Tabs, type TabItem } from "@shared/components/Tabs";
const baseItems: TabItem[] = [
{ key: "deployed", label: "Deployed", count: 6 },
{ key: "templates", label: "Templates", count: 4 },
{ key: "archive", label: "Archive", count: 0 },
];
function Bound({
items = baseItems,
variant = "pill" as const,
}: {
items?: TabItem[];
variant?: "pill" | "underline";
}) {
const [active, setActive] = useState(items[0].key);
return (
<Tabs
items={items}
activeKey={active}
onChange={setActive}
variant={variant}
/>
);
}
const meta: Meta<typeof Tabs> = {
title: "Primitives/Tabs",
component: Tabs,
tags: ["autodocs"],
parameters: { layout: "padded" },
args: { variant: "pill" },
argTypes: {
variant: { control: "inline-radio", options: ["pill", "underline"] },
},
render: (args) => <Bound items={baseItems} variant={args.variant} />,
};
export default meta;
type Story = StoryObj<typeof Tabs>;
/** Flip variant in controls. */
export const Playground: Story = {};
export const WithDisabledTab: Story = {
render: () => (
<Bound
items={[
{ key: "a", label: "Available" },
{ key: "b", label: "Coming soon", disabled: true },
]}
/>
),
};
export const InContext_DocumentVerticals: Story = {
render: () => (
<Bound
items={[
{ key: "all", label: "All" },
{
key: "insurance",
label: "Insurance",
count: 7,
accentColor: "#0ea5e9",
dotColor: "#0ea5e9",
},
{
key: "finance",
label: "Finance",
count: 7,
accentColor: "#10b981",
dotColor: "#10b981",
},
{
key: "legal",
label: "Legal",
count: 6,
accentColor: "#3B82F6",
dotColor: "#3B82F6",
},
{
key: "healthcare",
label: "Healthcare",
count: 6,
accentColor: "#8B5CF6",
dotColor: "#8B5CF6",
},
]}
/>
),
};
+88
View File
@@ -0,0 +1,88 @@
import type { ReactNode } from "react";
import "@shared/components/Tabs.css";
export interface TabItem<K extends string = string> {
/** Stable identifier for the tab. */
key: K;
label: ReactNode;
/** Optional count badge shown to the right of the label. */
count?: number;
/** Accent colour applied when this tab is active (override per-tab). */
accentColor?: string;
/** Leading dot before the label (used by Document type grid). */
dotColor?: string;
disabled?: boolean;
}
export interface TabsProps<K extends string = string> {
items: TabItem<K>[];
activeKey: K;
onChange: (key: K) => void;
/** Visual treatment. `pill` is rounded-chip style; `underline` is line-under tabs. */
variant?: "pill" | "underline";
/** Accessible label for the tablist. */
ariaLabel?: string;
className?: string;
}
/**
* Single-row tab strip. Generic over the key type so callers get strongly
* typed `onChange` handlers without casting.
*/
export function Tabs<K extends string = string>({
items,
activeKey,
onChange,
variant = "pill",
ariaLabel,
className,
}: TabsProps<K>) {
return (
// role="group" + aria-pressed is the right pattern for a filter strip
// that does not have paired tabpanels. Switching to ARIA tab roles would
// require ArrowLeft / ArrowRight handling and per-tab tabpanel wiring per
// the APG — out of scope for what this primitive actually does.
<div
className={["sui-tabs", `sui-tabs--${variant}`, className ?? ""]
.filter(Boolean)
.join(" ")}
role="group"
aria-label={ariaLabel}
>
{items.map((item) => {
const isActive = item.key === activeKey;
const styleVars =
isActive && item.accentColor
? ({ "--sui-tab-accent": item.accentColor } as React.CSSProperties)
: undefined;
return (
<button
key={item.key}
type="button"
aria-pressed={isActive}
disabled={item.disabled}
className={
"sui-tabs__tab" +
(isActive ? " is-active" : "") +
(item.disabled ? " is-disabled" : "")
}
style={styleVars}
onClick={() => onChange(item.key)}
>
{item.dotColor && (
<span
className="sui-tabs__dot"
style={{ background: item.dotColor }}
aria-hidden
/>
)}
<span className="sui-tabs__label">{item.label}</span>
{item.count !== undefined && (
<span className="sui-tabs__count">{item.count}</span>
)}
</button>
);
})}
</div>
);
}
+70
View File
@@ -0,0 +1,70 @@
.sui-toast-viewport {
position: fixed;
bottom: var(--space-6);
right: var(--space-6);
z-index: var(--z-toast);
display: flex;
flex-direction: column;
gap: var(--space-2);
pointer-events: none;
max-width: 24rem;
}
.sui-toast {
pointer-events: auto;
display: flex;
align-items: flex-start;
gap: var(--space-3);
padding: var(--space-3) var(--space-4);
background: var(--color-surface);
border: 1px solid var(--color-border);
border-left-width: 3px;
border-radius: var(--radius-md);
box-shadow: var(--shadow-lg);
animation: fadeInUp var(--motion-enter) both;
}
.sui-toast--info {
border-left-color: var(--color-blue);
}
.sui-toast--success {
border-left-color: var(--color-green);
}
.sui-toast--warning {
border-left-color: var(--color-amber);
}
.sui-toast--danger {
border-left-color: var(--color-red);
}
.sui-toast__body {
flex: 1 1 auto;
min-width: 0;
font-size: 0.8125rem;
}
.sui-toast__title {
font-weight: 600;
color: var(--color-text-1);
}
.sui-toast__desc {
color: var(--color-text-3);
margin-top: 0.125rem;
line-height: 1.45;
}
.sui-toast__close {
flex: 0 0 auto;
width: 1.25rem;
height: 1.25rem;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: var(--radius-sm);
color: var(--color-text-4);
font-size: 1rem;
line-height: 1;
}
.sui-toast__close:hover {
background: var(--color-bg-hover);
color: var(--color-text-1);
}
@@ -0,0 +1,71 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { ToastProvider, useToast } from "@shared/components/Toast";
import { Button } from "@shared/components/Button";
function Trigger() {
const { toast } = useToast();
return (
<div style={{ display: "flex", gap: 8 }}>
<Button
onClick={() =>
toast({
tone: "info",
title: "Info",
description: "Just so you know.",
})
}
>
Info
</Button>
<Button
onClick={() =>
toast({
tone: "success",
title: "Deployed",
description: "Prior Auth v3.1.0",
})
}
>
Success
</Button>
<Button
onClick={() =>
toast({
tone: "warning",
title: "Approaching cap",
description: "389k / 500k",
})
}
>
Warning
</Button>
<Button
onClick={() =>
toast({
tone: "danger",
title: "Run failed",
description: "Schema mismatch",
})
}
>
Danger
</Button>
</div>
);
}
const meta: Meta = {
title: "Primitives/Toast",
parameters: { layout: "padded" },
decorators: [
(S) => (
<ToastProvider>
<S />
</ToastProvider>
),
],
};
export default meta;
type Story = StoryObj;
export const Triggers: Story = { render: () => <Trigger /> };
+141
View File
@@ -0,0 +1,141 @@
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
type ReactNode,
} from "react";
import { createPortal } from "react-dom";
import "@shared/components/Toast.css";
export type ToastTone = "info" | "success" | "warning" | "danger";
export interface ToastOptions {
tone?: ToastTone;
title?: ReactNode;
description?: ReactNode;
/** Auto-dismiss timeout in ms. Defaults to 4000. Pass 0 to keep open. */
durationMs?: number;
}
interface ToastEntry extends ToastOptions {
id: number;
}
interface ToastContextValue {
toast: (options: ToastOptions) => number;
dismiss: (id: number) => void;
}
const ToastContext = createContext<ToastContextValue | null>(null);
export function useToast(): ToastContextValue {
const ctx = useContext(ToastContext);
if (!ctx) throw new Error("useToast must be used inside <ToastProvider>");
return ctx;
}
export function ToastProvider({ children }: { children: ReactNode }) {
const [entries, setEntries] = useState<ToastEntry[]>([]);
const nextIdRef = useRef(1);
const dismiss = useCallback((id: number) => {
setEntries((prev) => prev.filter((e) => e.id !== id));
}, []);
const toast = useCallback(
(options: ToastOptions) => {
const id = nextIdRef.current++;
const entry: ToastEntry = {
tone: "info",
durationMs: 4000,
...options,
id,
};
setEntries((prev) => [...prev, entry]);
if ((entry.durationMs ?? 0) > 0) {
window.setTimeout(() => dismiss(id), entry.durationMs);
}
return id;
},
[dismiss],
);
const value = useMemo<ToastContextValue>(
() => ({ toast, dismiss }),
[toast, dismiss],
);
return (
<ToastContext.Provider value={value}>
{children}
<ToastViewport entries={entries} onDismiss={dismiss} />
</ToastContext.Provider>
);
}
function ToastViewport({
entries,
onDismiss,
}: {
entries: ToastEntry[];
onDismiss: (id: number) => void;
}) {
// Render through a portal so toasts always sit above any in-flow stacking
// context. SSR-safe by checking for document existence.
if (typeof document === "undefined") return null;
return createPortal(
<div
className="sui-toast-viewport"
role="region"
aria-label="Notifications"
>
{entries.map((entry) => (
<ToastItem key={entry.id} entry={entry} onDismiss={onDismiss} />
))}
</div>,
document.body,
);
}
function ToastItem({
entry,
onDismiss,
}: {
entry: ToastEntry;
onDismiss: (id: number) => void;
}) {
useEffect(() => {
// Allow Escape to dismiss the most recently focused toast.
function onKey(e: KeyboardEvent) {
if (e.key === "Escape") onDismiss(entry.id);
}
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
}, [entry.id, onDismiss]);
return (
<div
role="alert"
className={`sui-toast sui-toast--${entry.tone ?? "info"}`}
>
<div className="sui-toast__body">
{entry.title && <div className="sui-toast__title">{entry.title}</div>}
{entry.description && (
<div className="sui-toast__desc">{entry.description}</div>
)}
</div>
<button
type="button"
className="sui-toast__close"
onClick={() => onDismiss(entry.id)}
aria-label="Dismiss"
>
×
</button>
</div>
);
}
@@ -0,0 +1,78 @@
.sui-toggle {
display: inline-flex;
align-items: center;
gap: 0.625rem;
cursor: pointer;
user-select: none;
}
.sui-toggle input {
position: absolute;
opacity: 0;
width: 0.0625rem;
height: 0.0625rem;
}
.sui-toggle__track {
display: inline-block;
width: 2.25rem;
height: 1.25rem;
background: var(--color-toggle-off);
border-radius: 0.75rem;
position: relative;
transition: background var(--motion-base);
flex-shrink: 0;
}
.sui-toggle__thumb {
position: absolute;
top: 0.125rem;
left: 0.125rem;
width: 1rem;
height: 1rem;
background: #fff;
border-radius: 50%;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.25);
transition: transform var(--motion-base);
}
.sui-toggle input:checked + .sui-toggle__track {
background: var(--color-blue);
}
.sui-toggle input:checked + .sui-toggle__track .sui-toggle__thumb {
transform: translateX(1rem);
}
.sui-toggle--sm .sui-toggle__track {
width: 1.875rem;
height: 1rem;
}
.sui-toggle--sm .sui-toggle__thumb {
width: 0.75rem;
height: 0.75rem;
}
.sui-toggle--sm input:checked + .sui-toggle__track .sui-toggle__thumb {
transform: translateX(0.875rem);
}
.sui-toggle.is-disabled {
cursor: not-allowed;
opacity: 0.5;
}
.sui-toggle__text {
display: inline-flex;
flex-direction: column;
gap: 0.125rem;
}
.sui-toggle__label {
font-size: 0.8125rem;
font-weight: 500;
color: var(--color-text-2);
}
.sui-toggle__desc {
font-size: 0.75rem;
color: var(--color-text-4);
}
.sui-toggle input:focus-visible + .sui-toggle__track {
outline: 2px solid var(--color-blue);
outline-offset: 2px;
}
@@ -0,0 +1,89 @@
import { useState } from "react";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { ToggleSwitch } from "@shared/components/ToggleSwitch";
const meta: Meta<typeof ToggleSwitch> = {
title: "Primitives/ToggleSwitch",
component: ToggleSwitch,
tags: ["autodocs"],
parameters: { layout: "centered" },
args: {
checked: true,
label: "Encryption at rest",
size: "md",
disabled: false,
},
argTypes: {
size: { control: "inline-radio", options: ["sm", "md"] },
disabled: { control: "boolean" },
checked: { control: "boolean" },
},
render: (args) => {
function Bound() {
const [on, setOn] = useState(args.checked);
return <ToggleSwitch {...args} checked={on} onChange={setOn} />;
}
return <Bound />;
},
};
export default meta;
type Story = StoryObj<typeof ToggleSwitch>;
/** Flip size / checked / disabled / label / description in controls. */
export const Playground: Story = {};
export const WithDescription: Story = {
args: {
label: "Auto-promote on golden set pass",
description:
"When the eval set passes, the new version is promoted automatically.",
},
};
export const InContext_SettingsRows: Story = {
parameters: { layout: "padded" },
render: () => {
function Bound() {
const [a, setA] = useState(true);
const [b, setB] = useState(true);
const [c, setC] = useState(false);
const [d, setD] = useState(false);
return (
<div
style={{
display: "flex",
flexDirection: "column",
gap: 16,
maxWidth: "32rem",
}}
>
<ToggleSwitch
checked={a}
onChange={setA}
label="Encryption at rest"
description="AES-256 with Stirling-managed keys"
/>
<ToggleSwitch
checked={b}
onChange={setB}
label="Sign every output"
description="Tamper-evident manifest covers artifact + run metadata"
/>
<ToggleSwitch
checked={c}
onChange={setC}
label="Mirror to S3"
description="Copy sealed artifacts to your bucket for compliance archival"
/>
<ToggleSwitch
checked={d}
onChange={setD}
label="Send low-confidence to review"
description="Confidence under 0.85 triggers human review"
/>
</div>
);
}
return <Bound />;
},
};
@@ -0,0 +1,59 @@
import { useId } from "react";
import "@shared/components/ToggleSwitch.css";
export interface ToggleSwitchProps {
checked: boolean;
onChange: (checked: boolean) => void;
/** Accessible label associated to the control. */
label?: string;
/** Optional helper text rendered next to the label. */
description?: string;
disabled?: boolean;
size?: "sm" | "md";
id?: string;
}
/**
* 36×20 pill toggle, matched to the prototype's SettingsRow control.
*
* Pair with the {@link SettingsRow} component (or a custom row) when you need
* a label + description layout. Standalone, this is just the switch itself.
*/
export function ToggleSwitch({
checked,
onChange,
label,
description,
disabled,
size = "md",
id,
}: ToggleSwitchProps) {
const autoId = useId();
const controlId = id ?? autoId;
return (
<label
className={`sui-toggle sui-toggle--${size}${disabled ? " is-disabled" : ""}`}
htmlFor={controlId}
>
<input
id={controlId}
type="checkbox"
role="switch"
checked={checked}
disabled={disabled}
onChange={(e) => onChange(e.target.checked)}
/>
<span className="sui-toggle__track" aria-hidden>
<span className="sui-toggle__thumb" />
</span>
{(label || description) && (
<span className="sui-toggle__text">
{label && <span className="sui-toggle__label">{label}</span>}
{description && (
<span className="sui-toggle__desc">{description}</span>
)}
</span>
)}
</label>
);
}
+38
View File
@@ -0,0 +1,38 @@
export * from "@shared/components/Button";
export * from "@shared/components/StatusBadge";
export * from "@shared/components/MethodBadge";
export * from "@shared/components/ToggleSwitch";
export * from "@shared/components/ProgressBar";
export * from "@shared/components/MetricCard";
export * from "@shared/components/NavItem";
export * from "@shared/components/PanelHeader";
export * from "@shared/components/CodeBlock";
export * from "@shared/components/SectionDivider";
export * from "@shared/components/Card";
export * from "@shared/components/Modal";
// Layout
export * from "@shared/components/Stack";
export * from "@shared/components/Inline";
// Feedback
export * from "@shared/components/Spinner";
export * from "@shared/components/Skeleton";
export * from "@shared/components/Avatar";
export * from "@shared/components/Chip";
export * from "@shared/components/EmptyState";
export * from "@shared/components/Banner";
export * from "@shared/components/Toast";
// Compound
export * from "@shared/components/Tabs";
export * from "@shared/components/Dropdown";
export * from "@shared/components/Drawer";
// Forms
export * from "@shared/components/FormField";
export * from "@shared/components/Input";
export * from "@shared/components/Select";
export * from "@shared/components/Checkbox";
export * from "@shared/components/Radio";
export * from "@shared/components/Slider";