mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-14 10:34:06 +02:00
feat(policies): backend-driven policy enforcement (frontend) (#6598)
## Summary Adds the **Policies** feature (proprietary, behind the `POLICIES_ENABLED` flag): backend-driven enforcement that runs a fixed tool pipeline on documents, docked in the right tool sidebar alongside Tools. ## Highlights - **Policy catalog** — 5 categories; **Security** is wired (redact PII + sanitize), the others are marked "Coming soon". - **Backend as source of truth** — policies persist via the Policies engine (`/api/v1/policies`), one policy per category, with a local cache + offline fallback. - **Auto-run** — enabled policies run on every uploaded file: dispatch → poll → import outputs into the workspace. - **Security redact config** — PII preset dropdown + custom word/regex entry + advanced options; tool params map to the backend endpoint fields. - **Activity feed** with retry on failures; **file badges** showing which policies ran on a file (sidebar + files page), tinted to the policy colour. - Reuses the **Watched Folders** engine for each policy's backing folder; policy-owned folders are filtered out of the Watched Folders UI. ## Notes - Gated by `POLICIES_ENABLED` (true in proprietary, false in core) — unreachable in the open-source build. - Frontend-only diff; depends on the backend Policies engine and the merged Watched Folders feature.
This commit is contained in:
@@ -75,6 +75,12 @@
|
||||
display: block;
|
||||
}
|
||||
|
||||
.sui-check__icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
color: var(--color-text-4);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.sui-check__text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -7,13 +7,15 @@ export interface CheckboxProps extends Omit<
|
||||
> {
|
||||
label?: ReactNode;
|
||||
description?: ReactNode;
|
||||
/** Optional glyph shown between the box and the label text. */
|
||||
leadingIcon?: 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 },
|
||||
{ label, description, leadingIcon, indeterminate, className, id, ...rest },
|
||||
ref,
|
||||
) {
|
||||
return (
|
||||
@@ -57,6 +59,11 @@ export const Checkbox = forwardRef<HTMLInputElement, CheckboxProps>(
|
||||
</svg>
|
||||
<span className="sui-check__dash" />
|
||||
</span>
|
||||
{leadingIcon && (
|
||||
<span className="sui-check__icon" aria-hidden>
|
||||
{leadingIcon}
|
||||
</span>
|
||||
)}
|
||||
{(label || description) && (
|
||||
<span className="sui-check__text">
|
||||
{label && <span className="sui-check__label">{label}</span>}
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
.sui-chipflow {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
.sui-chipflow__sep {
|
||||
color: var(--color-text-4);
|
||||
font-size: 0.75rem;
|
||||
line-height: 1;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { ChipFlow } from "@shared/components/ChipFlow";
|
||||
|
||||
const meta: Meta<typeof ChipFlow> = {
|
||||
title: "Primitives/ChipFlow",
|
||||
component: ChipFlow,
|
||||
tags: ["autodocs"],
|
||||
parameters: { layout: "padded" },
|
||||
args: {
|
||||
items: ["Classify", "Extract", "Name", "Normalize"],
|
||||
separator: "arrow",
|
||||
tone: "neutral",
|
||||
size: "sm",
|
||||
},
|
||||
argTypes: {
|
||||
separator: { control: "inline-radio", options: ["arrow", "none"] },
|
||||
},
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof ChipFlow>;
|
||||
|
||||
export const Pipeline: Story = { args: { separator: "arrow" } };
|
||||
export const Plain: Story = {
|
||||
args: { separator: "none", items: ["HIPAA", "GDPR", "SOC 2"] },
|
||||
};
|
||||
@@ -0,0 +1,47 @@
|
||||
import { Fragment } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import { Chip } from "@shared/components/Chip";
|
||||
import type { ChipTone, ChipSize } from "@shared/components/Chip";
|
||||
import "@shared/components/ChipFlow.css";
|
||||
|
||||
export interface ChipFlowProps {
|
||||
/** Items rendered as chips, in order. */
|
||||
items: ReactNode[];
|
||||
/** `arrow` joins chips with a → connector (pipeline look); `none` just wraps. */
|
||||
separator?: "arrow" | "none";
|
||||
tone?: ChipTone;
|
||||
size?: ChipSize;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A sequence of {@link Chip}s, optionally joined by arrows to read as a
|
||||
* pipeline (A → B → C). Use `separator="arrow"` for flows, `none` for a plain
|
||||
* wrapped chip list.
|
||||
*/
|
||||
export function ChipFlow({
|
||||
items,
|
||||
separator = "none",
|
||||
tone = "neutral",
|
||||
size = "sm",
|
||||
className,
|
||||
}: ChipFlowProps) {
|
||||
return (
|
||||
<div
|
||||
className={["sui-chipflow", className ?? ""].filter(Boolean).join(" ")}
|
||||
>
|
||||
{items.map((item, i) => (
|
||||
<Fragment key={i}>
|
||||
{i > 0 && separator === "arrow" && (
|
||||
<span className="sui-chipflow__sep" aria-hidden>
|
||||
→
|
||||
</span>
|
||||
)}
|
||||
<Chip tone={tone} size={size}>
|
||||
{item}
|
||||
</Chip>
|
||||
</Fragment>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
.sui-datarow {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.sui-datarow--center {
|
||||
align-items: center;
|
||||
}
|
||||
.sui-datarow--top {
|
||||
align-items: flex-start;
|
||||
}
|
||||
.sui-datarow__label {
|
||||
flex-shrink: 0;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
color: var(--color-text-4);
|
||||
}
|
||||
.sui-datarow__value {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-1);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { DataRow } from "@shared/components/DataRow";
|
||||
import { ChipFlow } from "@shared/components/ChipFlow";
|
||||
import { Card } from "@shared/components/Card";
|
||||
|
||||
const meta: Meta<typeof DataRow> = {
|
||||
title: "Primitives/DataRow",
|
||||
component: DataRow,
|
||||
tags: ["autodocs"],
|
||||
parameters: { layout: "padded" },
|
||||
decorators: [
|
||||
(S) => (
|
||||
<div style={{ maxWidth: "24rem" }}>
|
||||
<S />
|
||||
</div>
|
||||
),
|
||||
],
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof DataRow>;
|
||||
|
||||
export const Single: Story = {
|
||||
args: { label: "Reviewer", children: "[email protected]" },
|
||||
};
|
||||
|
||||
export const Summary: Story = {
|
||||
render: () => (
|
||||
<Card padding="default">
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: "0.5rem" }}>
|
||||
<DataRow label="Enforces" align="top">
|
||||
<ChipFlow items={["Classify", "Extract", "Name"]} />
|
||||
</DataRow>
|
||||
<DataRow label="Sources">3 selected</DataRow>
|
||||
<DataRow label="Reviewer">matt@stirlingpdf.com</DataRow>
|
||||
</div>
|
||||
</Card>
|
||||
),
|
||||
};
|
||||
@@ -0,0 +1,41 @@
|
||||
import type { ReactNode } from "react";
|
||||
import "@shared/components/DataRow.css";
|
||||
|
||||
export interface DataRowProps {
|
||||
/** Left-aligned key. */
|
||||
label: ReactNode;
|
||||
/** Value — text, chips, or any node. */
|
||||
children: ReactNode;
|
||||
/** Fixed key-column width (CSS length). Defaults to 4.5rem. */
|
||||
labelWidth?: string;
|
||||
/** Vertical alignment of label vs value. `top` suits multi-line values. */
|
||||
align?: "center" | "top";
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A key/value row for read-only detail/summary read-outs (a single line of a
|
||||
* description list). Compose several inside a Card. Use `align="top"` when the
|
||||
* value wraps (e.g. a chip flow).
|
||||
*/
|
||||
export function DataRow({
|
||||
label,
|
||||
children,
|
||||
labelWidth = "4.5rem",
|
||||
align = "center",
|
||||
className,
|
||||
}: DataRowProps) {
|
||||
return (
|
||||
<div
|
||||
className={["sui-datarow", `sui-datarow--${align}`, className ?? ""]
|
||||
.filter(Boolean)
|
||||
.join(" ")}
|
||||
role="group"
|
||||
>
|
||||
<span className="sui-datarow__label" style={{ width: labelWidth }}>
|
||||
{label}
|
||||
</span>
|
||||
<div className="sui-datarow__value">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
.sui-iconbadge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: var(--radius-md);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.sui-iconbadge--sm {
|
||||
width: 1.75rem;
|
||||
height: 1.75rem;
|
||||
}
|
||||
.sui-iconbadge--md {
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
}
|
||||
.sui-iconbadge--blue {
|
||||
color: var(--color-blue);
|
||||
background: color-mix(in srgb, var(--color-blue) 14%, transparent);
|
||||
}
|
||||
.sui-iconbadge--purple {
|
||||
color: var(--color-purple);
|
||||
background: color-mix(in srgb, var(--color-purple) 14%, transparent);
|
||||
}
|
||||
.sui-iconbadge--green {
|
||||
color: var(--color-green);
|
||||
background: color-mix(in srgb, var(--color-green) 14%, transparent);
|
||||
}
|
||||
.sui-iconbadge--amber {
|
||||
color: var(--color-amber);
|
||||
background: color-mix(in srgb, var(--color-amber) 14%, transparent);
|
||||
}
|
||||
.sui-iconbadge--red {
|
||||
color: var(--color-red);
|
||||
background: color-mix(in srgb, var(--color-red) 14%, transparent);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { IconBadge } from "@shared/components/IconBadge";
|
||||
|
||||
function Glyph() {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" width={16} height={16} fill="currentColor">
|
||||
<rect x="3" y="4" width="18" height="4" rx="1" />
|
||||
<rect x="3" y="10" width="18" height="4" rx="1" />
|
||||
<rect x="3" y="16" width="18" height="4" rx="1" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
const meta: Meta<typeof IconBadge> = {
|
||||
title: "Primitives/IconBadge",
|
||||
component: IconBadge,
|
||||
tags: ["autodocs"],
|
||||
parameters: { layout: "centered" },
|
||||
args: { accent: "blue", size: "md", children: <Glyph /> },
|
||||
argTypes: {
|
||||
accent: {
|
||||
control: "inline-radio",
|
||||
options: ["blue", "purple", "green", "amber", "red"],
|
||||
},
|
||||
size: { control: "inline-radio", options: ["sm", "md"] },
|
||||
},
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof IconBadge>;
|
||||
|
||||
export const Blue: Story = {};
|
||||
export const Accents: Story = {
|
||||
render: () => (
|
||||
<div style={{ display: "flex", gap: 12 }}>
|
||||
{(["blue", "purple", "green", "amber", "red"] as const).map((a) => (
|
||||
<IconBadge key={a} accent={a}>
|
||||
<Glyph />
|
||||
</IconBadge>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
};
|
||||
@@ -0,0 +1,40 @@
|
||||
import type { ReactNode } from "react";
|
||||
import "@shared/components/IconBadge.css";
|
||||
|
||||
export type IconBadgeAccent = "blue" | "purple" | "green" | "amber" | "red";
|
||||
|
||||
export interface IconBadgeProps {
|
||||
children: ReactNode;
|
||||
/** Tone tint. Defaults to blue. */
|
||||
accent?: IconBadgeAccent;
|
||||
size?: "sm" | "md";
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A glyph in a rounded, tone-tinted square — the recurring "category icon box"
|
||||
* motif used by panel headers, summaries and list leads. Centralises the tint
|
||||
* so every consumer shares one treatment.
|
||||
*/
|
||||
export function IconBadge({
|
||||
children,
|
||||
accent = "blue",
|
||||
size = "md",
|
||||
className,
|
||||
}: IconBadgeProps) {
|
||||
return (
|
||||
<span
|
||||
className={[
|
||||
"sui-iconbadge",
|
||||
`sui-iconbadge--${accent}`,
|
||||
`sui-iconbadge--${size}`,
|
||||
className ?? "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ")}
|
||||
aria-hidden
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
.sui-listrow {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.625rem;
|
||||
width: 100%;
|
||||
padding: 0.7rem 0.875rem;
|
||||
text-align: left;
|
||||
background: transparent;
|
||||
color: var(--color-text-1);
|
||||
}
|
||||
.sui-listrow--divider {
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
.sui-listrow--interactive {
|
||||
cursor: pointer;
|
||||
transition: background var(--motion-fast);
|
||||
}
|
||||
.sui-listrow--interactive:hover {
|
||||
background: var(--color-bg-hover);
|
||||
}
|
||||
.sui-listrow--interactive:focus-visible {
|
||||
outline: 0.125rem solid var(--color-blue);
|
||||
outline-offset: -0.125rem;
|
||||
}
|
||||
|
||||
.sui-listrow__leading {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 1.6rem;
|
||||
height: 1.6rem;
|
||||
border-radius: var(--radius-md);
|
||||
flex-shrink: 0;
|
||||
margin-top: 0.05rem;
|
||||
color: var(--color-text-4);
|
||||
background: var(--color-bg-muted);
|
||||
}
|
||||
.sui-listrow__leading[data-tone="success"] {
|
||||
color: var(--color-green);
|
||||
background: color-mix(in srgb, var(--color-green) 14%, transparent);
|
||||
}
|
||||
.sui-listrow__leading[data-tone="warning"] {
|
||||
color: var(--color-amber);
|
||||
background: color-mix(in srgb, var(--color-amber) 14%, transparent);
|
||||
}
|
||||
.sui-listrow__leading[data-tone="danger"] {
|
||||
color: var(--color-red);
|
||||
background: color-mix(in srgb, var(--color-red) 14%, transparent);
|
||||
}
|
||||
.sui-listrow__leading[data-tone="info"] {
|
||||
color: var(--color-blue);
|
||||
background: color-mix(in srgb, var(--color-blue) 14%, transparent);
|
||||
}
|
||||
.sui-listrow__leading[data-tone="purple"] {
|
||||
color: var(--color-purple);
|
||||
background: color-mix(in srgb, var(--color-purple) 14%, transparent);
|
||||
}
|
||||
|
||||
.sui-listrow__text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.sui-listrow__title {
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
color: var(--color-text-1);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.sui-listrow__desc {
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-4);
|
||||
margin-top: 0.125rem;
|
||||
}
|
||||
.sui-listrow__meta {
|
||||
font-size: 0.6875rem;
|
||||
color: var(--color-text-4);
|
||||
margin-top: 0.125rem;
|
||||
}
|
||||
.sui-listrow__trailing {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
margin-left: auto;
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { ListRow } from "@shared/components/ListRow";
|
||||
import { Card } from "@shared/components/Card";
|
||||
import { StatusBadge } from "@shared/components/StatusBadge";
|
||||
|
||||
const meta: Meta<typeof ListRow> = {
|
||||
title: "Primitives/ListRow",
|
||||
component: ListRow,
|
||||
tags: ["autodocs"],
|
||||
parameters: { layout: "padded" },
|
||||
decorators: [
|
||||
(S) => (
|
||||
<div style={{ maxWidth: "24rem" }}>
|
||||
<S />
|
||||
</div>
|
||||
),
|
||||
],
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof ListRow>;
|
||||
|
||||
function Dot({ ch }: { ch: string }) {
|
||||
return <span style={{ fontSize: 12, fontWeight: 700 }}>{ch}</span>;
|
||||
}
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
leading: <Dot ch="✓" />,
|
||||
leadingTone: "success",
|
||||
title: "MSA_Acme_2026.pdf",
|
||||
description: "Classified as Contract • 3 tables extracted",
|
||||
meta: "2h ago",
|
||||
},
|
||||
};
|
||||
|
||||
/** A divided list inside a padding-none Card — the canonical usage. */
|
||||
export const InCard: Story = {
|
||||
render: () => (
|
||||
<Card padding="none">
|
||||
<ListRow
|
||||
leading={<Dot ch="✓" />}
|
||||
leadingTone="success"
|
||||
title="MSA_Acme_2026.pdf"
|
||||
description="Classified as Contract • 3 tables extracted"
|
||||
meta="2h ago"
|
||||
/>
|
||||
<ListRow
|
||||
divider
|
||||
leading={<Dot ch="!" />}
|
||||
leadingTone="warning"
|
||||
title="scan_002.pdf"
|
||||
description="Low confidence (62%) • flagged for review"
|
||||
meta="Yesterday"
|
||||
trailing={
|
||||
<StatusBadge tone="warning" size="sm">
|
||||
Flagged
|
||||
</StatusBadge>
|
||||
}
|
||||
/>
|
||||
<ListRow
|
||||
divider
|
||||
leading={<Dot ch="✓" />}
|
||||
leadingTone="success"
|
||||
title="Invoice_4471.pdf"
|
||||
description="Classified as Invoice • renamed to standard"
|
||||
meta="5h ago"
|
||||
/>
|
||||
</Card>
|
||||
),
|
||||
};
|
||||
|
||||
export const Interactive: Story = {
|
||||
args: {
|
||||
leading: <Dot ch="→" />,
|
||||
leadingTone: "info",
|
||||
title: "Clickable row",
|
||||
description: "The whole row is a button",
|
||||
onClick: () => {},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,79 @@
|
||||
import type { ReactNode } from "react";
|
||||
import "@shared/components/ListRow.css";
|
||||
import type { StatusTone } from "@shared/components/StatusBadge";
|
||||
|
||||
export interface ListRowProps {
|
||||
/** Leading visual (icon/avatar), shown in a tone-tinted square. */
|
||||
leading?: ReactNode;
|
||||
/** Tint for the leading square. Defaults to neutral. */
|
||||
leadingTone?: StatusTone;
|
||||
/** Primary line. */
|
||||
title: ReactNode;
|
||||
/** Secondary line. */
|
||||
description?: ReactNode;
|
||||
/** Tertiary line (e.g. timestamp). */
|
||||
meta?: ReactNode;
|
||||
/** Right-aligned content (badge, chevron, action). */
|
||||
trailing?: ReactNode;
|
||||
/** Makes the whole row a button. */
|
||||
onClick?: () => void;
|
||||
/** Draw a top hairline — set on every row after the first inside a list. */
|
||||
divider?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A single list row: a tone-tinted leading glyph + a title/description/meta
|
||||
* stack + an optional trailing slot. Compose a divided list by wrapping rows in
|
||||
* a {@code Card padding="none"} and setting {@code divider} on all but the first.
|
||||
*/
|
||||
export function ListRow({
|
||||
leading,
|
||||
leadingTone = "neutral",
|
||||
title,
|
||||
description,
|
||||
meta,
|
||||
trailing,
|
||||
onClick,
|
||||
divider,
|
||||
className,
|
||||
}: ListRowProps) {
|
||||
const classes = [
|
||||
"sui-listrow",
|
||||
divider ? "sui-listrow--divider" : "",
|
||||
onClick ? "sui-listrow--interactive" : "",
|
||||
className ?? "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
|
||||
const content = (
|
||||
<>
|
||||
{leading && (
|
||||
<span
|
||||
className="sui-listrow__leading"
|
||||
data-tone={leadingTone}
|
||||
aria-hidden
|
||||
>
|
||||
{leading}
|
||||
</span>
|
||||
)}
|
||||
<span className="sui-listrow__text">
|
||||
<span className="sui-listrow__title">{title}</span>
|
||||
{description && (
|
||||
<span className="sui-listrow__desc">{description}</span>
|
||||
)}
|
||||
{meta && <span className="sui-listrow__meta">{meta}</span>}
|
||||
</span>
|
||||
{trailing && <span className="sui-listrow__trailing">{trailing}</span>}
|
||||
</>
|
||||
);
|
||||
|
||||
return onClick ? (
|
||||
<button type="button" className={classes} onClick={onClick}>
|
||||
{content}
|
||||
</button>
|
||||
) : (
|
||||
<div className={classes}>{content}</div>
|
||||
);
|
||||
}
|
||||
@@ -17,6 +17,18 @@
|
||||
color: inherit;
|
||||
text-align: left;
|
||||
}
|
||||
/* Compact density for narrow rails / metric strips. */
|
||||
.sui-metric--sm {
|
||||
gap: var(--space-1);
|
||||
padding: var(--space-2) var(--space-3);
|
||||
min-width: 0;
|
||||
}
|
||||
.sui-metric--sm .sui-metric__value {
|
||||
font-size: 1.125rem;
|
||||
}
|
||||
.sui-metric--sm .sui-metric__label {
|
||||
white-space: nowrap;
|
||||
}
|
||||
.sui-metric--primary {
|
||||
background: var(--color-bg-subtle);
|
||||
}
|
||||
|
||||
@@ -14,6 +14,8 @@ export interface MetricCardProps {
|
||||
deltaDirection?: DeltaDirection;
|
||||
/** Visual emphasis. `primary` = darker surface, used for hero metrics. */
|
||||
emphasis?: "default" | "primary";
|
||||
/** Density. `sm` tightens padding + value size for narrow rails/strips. */
|
||||
size?: "sm" | "md";
|
||||
/** Optional icon shown in the top-right corner. */
|
||||
icon?: ReactNode;
|
||||
onClick?: () => void;
|
||||
@@ -41,6 +43,7 @@ export function MetricCard({
|
||||
delta,
|
||||
deltaDirection,
|
||||
emphasis = "default",
|
||||
size = "md",
|
||||
icon,
|
||||
onClick,
|
||||
className,
|
||||
@@ -50,6 +53,7 @@ export function MetricCard({
|
||||
|
||||
const classes = [
|
||||
"sui-metric",
|
||||
`sui-metric--${size}`,
|
||||
emphasis === "primary" ? "sui-metric--primary" : "",
|
||||
interactive ? "sui-metric--interactive" : "",
|
||||
className ?? "",
|
||||
|
||||
@@ -40,8 +40,53 @@
|
||||
.sui-navitem__trailing {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
margin-left: auto;
|
||||
}
|
||||
.sui-navitem:focus-visible {
|
||||
outline: 0.125rem solid var(--color-blue);
|
||||
outline-offset: 0.125rem;
|
||||
}
|
||||
|
||||
/* ---- Status accent (optional) ---- */
|
||||
.sui-navitem--accent {
|
||||
position: relative;
|
||||
}
|
||||
.sui-navitem--accent::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0.3125rem;
|
||||
bottom: 0.3125rem;
|
||||
width: 3px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
.sui-navitem[data-accent="blue"]::before {
|
||||
background: var(--color-blue);
|
||||
}
|
||||
.sui-navitem[data-accent="purple"]::before {
|
||||
background: var(--color-purple);
|
||||
}
|
||||
.sui-navitem[data-accent="green"]::before {
|
||||
background: var(--color-green);
|
||||
}
|
||||
.sui-navitem[data-accent="amber"]::before {
|
||||
background: var(--color-amber);
|
||||
}
|
||||
.sui-navitem[data-accent="red"]::before {
|
||||
background: var(--color-red);
|
||||
}
|
||||
.sui-navitem[data-accent="blue"] .sui-navitem__icon {
|
||||
color: var(--color-blue);
|
||||
}
|
||||
.sui-navitem[data-accent="purple"] .sui-navitem__icon {
|
||||
color: var(--color-purple);
|
||||
}
|
||||
.sui-navitem[data-accent="green"] .sui-navitem__icon {
|
||||
color: var(--color-green);
|
||||
}
|
||||
.sui-navitem[data-accent="amber"] .sui-navitem__icon {
|
||||
color: var(--color-amber);
|
||||
}
|
||||
.sui-navitem[data-accent="red"] .sui-navitem__icon {
|
||||
color: var(--color-red);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { ReactNode } from "react";
|
||||
import "@shared/components/NavItem.css";
|
||||
|
||||
export type NavItemAccent = "blue" | "purple" | "green" | "amber" | "red";
|
||||
|
||||
export interface NavItemProps {
|
||||
/** Stable view id passed to the click handler. */
|
||||
id: string;
|
||||
@@ -8,6 +10,11 @@ export interface NavItemProps {
|
||||
icon?: ReactNode;
|
||||
/** Show the active highlight (navActive background, navActiveText colour). */
|
||||
isActive?: boolean;
|
||||
/**
|
||||
* Optional status accent: draws a left edge bar in the tone colour and tints
|
||||
* the leading icon to match. For listing live/paused/etc. entities.
|
||||
*/
|
||||
accent?: NavItemAccent;
|
||||
/** Optional trailing badge (e.g. unread count, "new"). */
|
||||
trailing?: ReactNode;
|
||||
onClick?: (id: string) => void;
|
||||
@@ -19,13 +26,14 @@ export interface NavItemProps {
|
||||
*
|
||||
* Active styling: navActive background, navActiveText colour, weight 500.
|
||||
* Hover styling: navHover background, navHoverText colour (only when not
|
||||
* already active).
|
||||
* already active). An optional `accent` adds a status edge bar + icon tint.
|
||||
*/
|
||||
export function NavItem({
|
||||
id,
|
||||
label,
|
||||
icon,
|
||||
isActive,
|
||||
accent,
|
||||
trailing,
|
||||
onClick,
|
||||
className,
|
||||
@@ -34,9 +42,15 @@ export function NavItem({
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onClick?.(id)}
|
||||
className={["sui-navitem", isActive ? "is-active" : "", className ?? ""]
|
||||
className={[
|
||||
"sui-navitem",
|
||||
isActive ? "is-active" : "",
|
||||
accent ? "sui-navitem--accent" : "",
|
||||
className ?? "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ")}
|
||||
data-accent={accent}
|
||||
aria-current={isActive ? "page" : undefined}
|
||||
>
|
||||
{icon && (
|
||||
|
||||
@@ -16,16 +16,22 @@
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--color-text-3);
|
||||
flex-shrink: 0;
|
||||
width: 1.875rem;
|
||||
height: 1.875rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 50%;
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text-2);
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background var(--motion-fast),
|
||||
color var(--motion-fast);
|
||||
color var(--motion-fast),
|
||||
border-color var(--motion-fast);
|
||||
}
|
||||
.sui-panelhdr__back:hover {
|
||||
background: var(--color-bg-hover);
|
||||
border-color: var(--color-text-4);
|
||||
color: var(--color-text-1);
|
||||
}
|
||||
.sui-panelhdr__text {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { ReactNode } from "react";
|
||||
import "@shared/components/PanelHeader.css";
|
||||
import { IconBadge } from "@shared/components/IconBadge";
|
||||
|
||||
export interface PanelHeaderProps {
|
||||
title: ReactNode;
|
||||
@@ -7,6 +8,10 @@ export interface PanelHeaderProps {
|
||||
subtitle?: ReactNode;
|
||||
/** Show a back chevron and trigger this callback when clicked. */
|
||||
onBack?: () => void;
|
||||
/** Optional leading visual (e.g. a category glyph) shown in a tinted box. */
|
||||
icon?: ReactNode;
|
||||
/** Accent tint for the leading icon box. Defaults to blue. */
|
||||
iconAccent?: "blue" | "purple" | "green" | "amber" | "red";
|
||||
/** Right-aligned action buttons / chips. */
|
||||
actions?: ReactNode;
|
||||
className?: string;
|
||||
@@ -14,12 +19,15 @@ export interface PanelHeaderProps {
|
||||
|
||||
/**
|
||||
* Header strip used by drill-down panels (admin tabs, agent detail, settings
|
||||
* sub-pages). Back chevron renders only when `onBack` is supplied.
|
||||
* sub-pages). Back chevron renders only when `onBack` is supplied; an optional
|
||||
* leading `icon` renders in a tinted box before the title.
|
||||
*/
|
||||
export function PanelHeader({
|
||||
title,
|
||||
subtitle,
|
||||
onBack,
|
||||
icon,
|
||||
iconAccent = "blue",
|
||||
actions,
|
||||
className,
|
||||
}: PanelHeaderProps) {
|
||||
@@ -49,6 +57,11 @@ export function PanelHeader({
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
{icon && (
|
||||
<IconBadge accent={iconAccent} size="md">
|
||||
{icon}
|
||||
</IconBadge>
|
||||
)}
|
||||
<div className="sui-panelhdr__text">
|
||||
<div className="sui-panelhdr__title">{title}</div>
|
||||
{subtitle && <div className="sui-panelhdr__sub">{subtitle}</div>}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
.sui-sectionhdr {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
width: 100%;
|
||||
padding: 0.4rem 0;
|
||||
background: transparent;
|
||||
border: none;
|
||||
text-align: left;
|
||||
}
|
||||
button.sui-sectionhdr {
|
||||
cursor: pointer;
|
||||
}
|
||||
.sui-sectionhdr__title {
|
||||
flex: 1;
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--color-text-4);
|
||||
}
|
||||
.sui-sectionhdr__count {
|
||||
font-size: 0.6875rem;
|
||||
color: var(--color-text-4);
|
||||
}
|
||||
.sui-sectionhdr__chevron {
|
||||
color: var(--color-text-4);
|
||||
flex-shrink: 0;
|
||||
transition: transform var(--motion-fast);
|
||||
}
|
||||
.sui-sectionhdr__chevron[data-collapsed="true"] {
|
||||
transform: rotate(-90deg);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { useState } from "react";
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { SectionHeader } from "@shared/components/SectionHeader";
|
||||
|
||||
const meta: Meta<typeof SectionHeader> = {
|
||||
title: "Primitives/SectionHeader",
|
||||
component: SectionHeader,
|
||||
tags: ["autodocs"],
|
||||
parameters: { layout: "padded" },
|
||||
decorators: [
|
||||
(S) => (
|
||||
<div style={{ maxWidth: "20rem" }}>
|
||||
<S />
|
||||
</div>
|
||||
),
|
||||
],
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof SectionHeader>;
|
||||
|
||||
export const Static: Story = { args: { title: "Policies", count: "3 active" } };
|
||||
|
||||
export const Collapsible: Story = {
|
||||
render: () => {
|
||||
const [open, setOpen] = useState(true);
|
||||
return (
|
||||
<SectionHeader
|
||||
title="Policies"
|
||||
count="3 active"
|
||||
collapsible
|
||||
expanded={open}
|
||||
onToggle={() => setOpen((v) => !v)}
|
||||
/>
|
||||
);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,66 @@
|
||||
import type { ReactNode } from "react";
|
||||
import "@shared/components/SectionHeader.css";
|
||||
|
||||
export interface SectionHeaderProps {
|
||||
/** Uppercase eyebrow title. */
|
||||
title: ReactNode;
|
||||
/** Optional right-aligned tally/count. */
|
||||
count?: ReactNode;
|
||||
/** Render as a button with a disclosure chevron. */
|
||||
collapsible?: boolean;
|
||||
/** When collapsible, whether the section is expanded. */
|
||||
expanded?: boolean;
|
||||
onToggle?: () => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* An uppercase section eyebrow with an optional trailing count and, when
|
||||
* `collapsible`, a disclosure chevron + toggle. Use above a group of rows/cards.
|
||||
*/
|
||||
export function SectionHeader({
|
||||
title,
|
||||
count,
|
||||
collapsible,
|
||||
expanded = true,
|
||||
onToggle,
|
||||
className,
|
||||
}: SectionHeaderProps) {
|
||||
const classes = ["sui-sectionhdr", className ?? ""].filter(Boolean).join(" ");
|
||||
const inner = (
|
||||
<>
|
||||
<span className="sui-sectionhdr__title">{title}</span>
|
||||
{count != null && <span className="sui-sectionhdr__count">{count}</span>}
|
||||
{collapsible && (
|
||||
<svg
|
||||
className="sui-sectionhdr__chevron"
|
||||
data-collapsed={!expanded}
|
||||
viewBox="0 0 24 24"
|
||||
width={14}
|
||||
height={14}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden
|
||||
>
|
||||
<polyline points="6 9 12 15 18 9" />
|
||||
</svg>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
return collapsible ? (
|
||||
<button
|
||||
type="button"
|
||||
className={classes}
|
||||
onClick={onToggle}
|
||||
aria-expanded={expanded}
|
||||
>
|
||||
{inner}
|
||||
</button>
|
||||
) : (
|
||||
<div className={classes}>{inner}</div>
|
||||
);
|
||||
}
|
||||
@@ -44,6 +44,25 @@
|
||||
-webkit-appearance: none;
|
||||
}
|
||||
|
||||
/*
|
||||
* The native option popup is OS-rendered: without explicit colours it falls
|
||||
* back to the UA scheme (white) while the option text inherits the themed
|
||||
* (light) colour — i.e. white-on-white in dark mode wherever the surrounding
|
||||
* app keeps color-scheme: light (e.g. the editor, whose dark mode is driven by
|
||||
* Mantine, not color-scheme). Theme the options explicitly and pin the
|
||||
* select's color-scheme to the active SUI theme so the popup is always legible.
|
||||
*/
|
||||
.sui-select__el option {
|
||||
background-color: var(--color-surface);
|
||||
color: var(--color-text-1);
|
||||
}
|
||||
[data-theme="dark"] .sui-select__el {
|
||||
color-scheme: dark;
|
||||
}
|
||||
[data-theme="light"] .sui-select__el {
|
||||
color-scheme: light;
|
||||
}
|
||||
|
||||
.sui-select--sm .sui-select__el {
|
||||
font-size: 0.8125rem;
|
||||
padding-left: var(--space-2);
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
.sui-settingsrow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
width: 100%;
|
||||
}
|
||||
.sui-settingsrow__text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
}
|
||||
.sui-settingsrow__label {
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
color: var(--color-text-1);
|
||||
}
|
||||
.sui-settingsrow__desc {
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-4);
|
||||
margin-top: 0.125rem;
|
||||
}
|
||||
.sui-settingsrow__control {
|
||||
flex: 0 0 auto;
|
||||
min-width: 0;
|
||||
max-width: 11rem;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { SettingsRow } from "@shared/components/SettingsRow";
|
||||
import { ToggleSwitch } from "@shared/components/ToggleSwitch";
|
||||
import { Select } from "@shared/components/Select";
|
||||
import { Card } from "@shared/components/Card";
|
||||
|
||||
const meta: Meta<typeof SettingsRow> = {
|
||||
title: "Primitives/SettingsRow",
|
||||
component: SettingsRow,
|
||||
tags: ["autodocs"],
|
||||
parameters: { layout: "padded" },
|
||||
decorators: [
|
||||
(S) => (
|
||||
<div style={{ maxWidth: "24rem" }}>
|
||||
<S />
|
||||
</div>
|
||||
),
|
||||
],
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof SettingsRow>;
|
||||
|
||||
export const Toggle: Story = {
|
||||
args: {
|
||||
label: "Auto-classify",
|
||||
control: <ToggleSwitch checked onChange={() => {}} size="sm" />,
|
||||
},
|
||||
};
|
||||
|
||||
export const WithDescription: Story = {
|
||||
args: {
|
||||
label: "Detect PII",
|
||||
description: "Scan documents for sensitive fields on save",
|
||||
control: <ToggleSwitch checked onChange={() => {}} size="sm" />,
|
||||
},
|
||||
};
|
||||
|
||||
/** A settings list inside a padding-none Card. */
|
||||
export const List: Story = {
|
||||
render: () => (
|
||||
<Card padding="none">
|
||||
{[
|
||||
{ label: "Auto-classify", on: true },
|
||||
{ label: "Extract tables", on: true },
|
||||
{ label: "Strip blank pages", on: false },
|
||||
].map((r, i) => (
|
||||
<div
|
||||
key={r.label}
|
||||
style={{
|
||||
padding: "0.7rem 0.875rem",
|
||||
borderTop: i > 0 ? "1px solid var(--color-border)" : undefined,
|
||||
}}
|
||||
>
|
||||
<SettingsRow
|
||||
label={r.label}
|
||||
control={
|
||||
<ToggleSwitch checked={r.on} onChange={() => {}} size="sm" />
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</Card>
|
||||
),
|
||||
};
|
||||
|
||||
export const SelectControl: Story = {
|
||||
args: {
|
||||
label: "OCR level",
|
||||
control: (
|
||||
<Select
|
||||
inputSize="sm"
|
||||
value="high"
|
||||
onChange={() => {}}
|
||||
options={[
|
||||
{ value: "standard", label: "Standard" },
|
||||
{ value: "high", label: "High" },
|
||||
{ value: "max", label: "Maximum" },
|
||||
]}
|
||||
/>
|
||||
),
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,39 @@
|
||||
import type { ReactNode } from "react";
|
||||
import "@shared/components/SettingsRow.css";
|
||||
|
||||
export interface SettingsRowProps {
|
||||
/** Left-aligned setting name. */
|
||||
label: ReactNode;
|
||||
/** Optional secondary line under the label. */
|
||||
description?: ReactNode;
|
||||
/** Right-aligned control (toggle / select / input / button). */
|
||||
control: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Horizontal settings row: label (+ optional description) on the left, control
|
||||
* right-aligned. The companion to vertical {@link FormField} — use this for the
|
||||
* label-left/control-right settings pattern (the layout ToggleSwitch's docs
|
||||
* point at). Stack rows inside a {@code Card padding="none"} for a settings list.
|
||||
*/
|
||||
export function SettingsRow({
|
||||
label,
|
||||
description,
|
||||
control,
|
||||
className,
|
||||
}: SettingsRowProps) {
|
||||
return (
|
||||
<div
|
||||
className={["sui-settingsrow", className ?? ""].filter(Boolean).join(" ")}
|
||||
>
|
||||
<div className="sui-settingsrow__text">
|
||||
<span className="sui-settingsrow__label">{label}</span>
|
||||
{description && (
|
||||
<span className="sui-settingsrow__desc">{description}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="sui-settingsrow__control">{control}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
.sui-steps {
|
||||
display: flex;
|
||||
gap: 0.3rem;
|
||||
width: 100%;
|
||||
}
|
||||
.sui-steps__bar {
|
||||
flex: 1;
|
||||
border-radius: 999px;
|
||||
background: var(--color-border);
|
||||
transition: background var(--motion-fast);
|
||||
}
|
||||
.sui-steps--md .sui-steps__bar {
|
||||
height: 0.3rem;
|
||||
}
|
||||
.sui-steps--sm .sui-steps__bar {
|
||||
height: 0.2rem;
|
||||
}
|
||||
/* Completed steps: solid accent. */
|
||||
.sui-steps__bar[data-state="done"] {
|
||||
background: var(--color-blue);
|
||||
}
|
||||
/* Current step: solid accent + a soft ring so it reads as "you are here". */
|
||||
.sui-steps__bar[data-state="current"] {
|
||||
background: var(--color-blue);
|
||||
box-shadow: 0 0 0 0.1875rem
|
||||
color-mix(in srgb, var(--color-blue) 22%, transparent);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { StepIndicator } from "@shared/components/StepIndicator";
|
||||
|
||||
const meta: Meta<typeof StepIndicator> = {
|
||||
title: "Primitives/StepIndicator",
|
||||
component: StepIndicator,
|
||||
tags: ["autodocs"],
|
||||
parameters: { layout: "padded" },
|
||||
args: { total: 3, current: 2, size: "md" },
|
||||
argTypes: {
|
||||
current: { control: { type: "number", min: 1, max: 5 } },
|
||||
total: { control: { type: "number", min: 1, max: 5 } },
|
||||
size: { control: "inline-radio", options: ["sm", "md"] },
|
||||
},
|
||||
decorators: [
|
||||
(S) => (
|
||||
<div style={{ maxWidth: "20rem" }}>
|
||||
<S />
|
||||
</div>
|
||||
),
|
||||
],
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof StepIndicator>;
|
||||
|
||||
export const Step1: Story = { args: { total: 3, current: 1 } };
|
||||
export const Step2: Story = { args: { total: 3, current: 2 } };
|
||||
export const Step3: Story = { args: { total: 3, current: 3 } };
|
||||
export const Small: Story = { args: { total: 4, current: 2, size: "sm" } };
|
||||
@@ -0,0 +1,43 @@
|
||||
import "@shared/components/StepIndicator.css";
|
||||
|
||||
export interface StepIndicatorProps {
|
||||
/** Total number of steps. */
|
||||
total: number;
|
||||
/** Current step, 1-based. */
|
||||
current: number;
|
||||
size?: "sm" | "md";
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A segmented step/progress rail for multi-step flows (wizards, onboarding).
|
||||
* Segments before `current` read as completed, the `current` segment is
|
||||
* emphasised, and the rest are upcoming.
|
||||
*/
|
||||
export function StepIndicator({
|
||||
total,
|
||||
current,
|
||||
size = "md",
|
||||
className,
|
||||
}: StepIndicatorProps) {
|
||||
return (
|
||||
<div
|
||||
className={["sui-steps", `sui-steps--${size}`, className ?? ""]
|
||||
.filter(Boolean)
|
||||
.join(" ")}
|
||||
role="progressbar"
|
||||
aria-valuemin={1}
|
||||
aria-valuemax={total}
|
||||
aria-valuenow={current}
|
||||
>
|
||||
{Array.from({ length: total }, (_, i) => {
|
||||
const step = i + 1;
|
||||
const state =
|
||||
step < current ? "done" : step === current ? "current" : "upcoming";
|
||||
return (
|
||||
<span key={step} className="sui-steps__bar" data-state={state} />
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user