1205 lines
51 KiB
TypeScript
1205 lines
51 KiB
TypeScript
import {
|
|
Badge,
|
|
Button,
|
|
Card,
|
|
CardContent,
|
|
CardDescription,
|
|
CardHeader,
|
|
CardTitle,
|
|
DataTable,
|
|
Dialog,
|
|
DialogContent,
|
|
DialogDescription,
|
|
DialogFooter,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
DropdownMenu,
|
|
DropdownMenuContent,
|
|
DropdownMenuItem,
|
|
DropdownMenuTrigger,
|
|
EmptyState,
|
|
EmptyStateActions,
|
|
EmptyStateDescription,
|
|
EmptyStateEyebrow,
|
|
EmptyStateHeader,
|
|
EmptyStateMedia,
|
|
EmptyStateTitle,
|
|
Form,
|
|
FormControl,
|
|
FormDescription,
|
|
FormItem,
|
|
FormLabel,
|
|
FormMessage,
|
|
Input,
|
|
Popover,
|
|
PopoverArrow,
|
|
PopoverContent,
|
|
PopoverTrigger,
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
Sheet,
|
|
SheetContent,
|
|
SheetDescription,
|
|
SheetFooter,
|
|
SheetHeader,
|
|
SheetTitle,
|
|
Switch,
|
|
Tabs,
|
|
TabsContent,
|
|
TabsList,
|
|
TabsTrigger,
|
|
Textarea,
|
|
Toast,
|
|
ToastAction,
|
|
ToastClose,
|
|
ToastDescription,
|
|
ToastProvider,
|
|
ToastTitle,
|
|
ToastViewport,
|
|
Tooltip,
|
|
TooltipContent,
|
|
TooltipProvider,
|
|
TooltipTrigger
|
|
} from "@ai-ui/ui";
|
|
import type { DataTableColumn, DataTableSort } from "@ai-ui/ui";
|
|
import type { Meta, StoryObj } from "@storybook/react";
|
|
import { Controller, useForm } from "react-hook-form";
|
|
import { useState } from "react";
|
|
|
|
type RoutingValues = {
|
|
lane: string;
|
|
notifications: boolean;
|
|
ownerEmail: string;
|
|
summary: string;
|
|
};
|
|
|
|
type ReleaseWorkspaceProps = {
|
|
quietMode?: boolean;
|
|
};
|
|
|
|
type RoutingLaneState = "Ready" | "Watching" | "Quiet" | "Holding";
|
|
|
|
type RoutingLaneRow = {
|
|
audience: string;
|
|
id: string;
|
|
lane: "Editorial" | "Engineering" | "Support";
|
|
nextGate: string;
|
|
note: string;
|
|
owner: string;
|
|
ownerEmail: string;
|
|
signalScore: number;
|
|
state: RoutingLaneState;
|
|
};
|
|
|
|
type RoutingFilter = "all" | "holding" | "quiet" | "watching";
|
|
|
|
const routingLaneRows: RoutingLaneRow[] = [
|
|
{
|
|
audience: "Narrative lock",
|
|
id: "editorial-copy-lock",
|
|
lane: "Editorial",
|
|
nextGate: "18:40",
|
|
note: "Copy is locked. Waiting on final legal phrasing for the migration footnote.",
|
|
owner: "Mae Kurata",
|
|
ownerEmail: "mae.kurata@cadence.dev",
|
|
signalScore: 8,
|
|
state: "Ready"
|
|
},
|
|
{
|
|
audience: "10% canary",
|
|
id: "engineering-canary",
|
|
lane: "Engineering",
|
|
nextGate: "19:15",
|
|
note: "Canary checks are green. Queue the 10% wave after route owners sign off.",
|
|
owner: "Dorian Vale",
|
|
ownerEmail: "dorian.vale@cadence.dev",
|
|
signalScore: 14,
|
|
state: "Watching"
|
|
},
|
|
{
|
|
audience: "Support queue",
|
|
id: "support-queue",
|
|
lane: "Support",
|
|
nextGate: "19:32",
|
|
note: "No escalations yet. Macro pack and customer note are staged for handoff.",
|
|
owner: "Lia Sato",
|
|
ownerEmail: "lia.sato@cadence.dev",
|
|
signalScore: 5,
|
|
state: "Quiet"
|
|
},
|
|
{
|
|
audience: "Legal footnote",
|
|
id: "editorial-legal-note",
|
|
lane: "Editorial",
|
|
nextGate: "19:05",
|
|
note: "One migration sentence still needs counsel review before the customer note can publish.",
|
|
owner: "Mae Kurata",
|
|
ownerEmail: "mae.kurata@cadence.dev",
|
|
signalScore: 17,
|
|
state: "Holding"
|
|
},
|
|
{
|
|
audience: "Wave brief",
|
|
id: "engineering-wave-brief",
|
|
lane: "Engineering",
|
|
nextGate: "19:44",
|
|
note: "Rollback thresholds are staged. Keep the fallback owner present during the first quiet cycle.",
|
|
owner: "Dorian Vale",
|
|
ownerEmail: "dorian.vale@cadence.dev",
|
|
signalScore: 11,
|
|
state: "Ready"
|
|
},
|
|
{
|
|
audience: "Customer note",
|
|
id: "support-customer-note",
|
|
lane: "Support",
|
|
nextGate: "20:05",
|
|
note: "Keep the external note unpublished until the queue stays quiet for one more pass.",
|
|
owner: "Lia Sato",
|
|
ownerEmail: "lia.sato@cadence.dev",
|
|
signalScore: 13,
|
|
state: "Watching"
|
|
},
|
|
{
|
|
audience: "Rollback watch",
|
|
id: "engineering-rollback-watch",
|
|
lane: "Engineering",
|
|
nextGate: "20:18",
|
|
note: "Guardrails are live. Stay quiet unless conversion drops or route owners lose visibility.",
|
|
owner: "Dorian Vale",
|
|
ownerEmail: "dorian.vale@cadence.dev",
|
|
signalScore: 6,
|
|
state: "Quiet"
|
|
}
|
|
];
|
|
|
|
const timelineStops = [
|
|
{
|
|
title: "Narrative lock",
|
|
window: "18:40",
|
|
note: "Release notes, migration callouts, and support macros align on one framing."
|
|
},
|
|
{
|
|
title: "10% canary",
|
|
window: "19:15",
|
|
note: "Routing lane owners watch live traffic, rollback metrics, and conversion deltas."
|
|
},
|
|
{
|
|
title: "Customer notice",
|
|
window: "20:05",
|
|
note: "Send the public note only after the queue remains stable for one quiet cycle."
|
|
}
|
|
] as const;
|
|
|
|
const routingFilterOptions: Array<{ label: string; value: RoutingFilter }> = [
|
|
{ label: "All lanes", value: "all" },
|
|
{ label: "Watching", value: "watching" },
|
|
{ label: "Quiet", value: "quiet" },
|
|
{ label: "Holding", value: "holding" }
|
|
];
|
|
|
|
function SignalGlyph() {
|
|
return (
|
|
<div className="grid gap-2">
|
|
<div className="grid grid-cols-[0.8rem_2.8rem_1.4rem] gap-2">
|
|
<span className="h-4 rounded-[var(--radius-full)] bg-[var(--color-primary)]" />
|
|
<span className="h-4 rounded-[var(--radius-full)] bg-[var(--color-surface-strong)]" />
|
|
<span className="h-4 rounded-[var(--radius-full)] bg-[var(--color-accent)]" />
|
|
</div>
|
|
<div className="grid grid-cols-[5.2rem] gap-2">
|
|
<span className="h-2 rounded-[var(--radius-full)] bg-[var(--color-border-strong)]" />
|
|
<span className="h-2 rounded-[var(--radius-full)] bg-[var(--color-border)]" />
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function MetricPanel({
|
|
eyebrow,
|
|
tone = "default",
|
|
value,
|
|
children
|
|
}: {
|
|
children: React.ReactNode;
|
|
eyebrow: string;
|
|
tone?: "default" | "accent";
|
|
value: string;
|
|
}) {
|
|
return (
|
|
<div
|
|
className={[
|
|
"rounded-[var(--radius-lg)] border px-4 py-4 shadow-[var(--shadow-xs)]",
|
|
tone === "accent"
|
|
? "border-[color-mix(in_oklch,var(--color-primary)_30%,var(--color-border))] bg-[color-mix(in_oklch,var(--color-primary)_10%,var(--color-card))]"
|
|
: "border-[var(--color-border)] bg-[color-mix(in_oklch,var(--color-card)_88%,white_12%)]"
|
|
].join(" ")}
|
|
>
|
|
<p className="text-xs uppercase tracking-[var(--tracking-caps)] text-[var(--color-muted-foreground)]">
|
|
{eyebrow}
|
|
</p>
|
|
<p className="mt-3 font-semibold tracking-[var(--tracking-tight)] text-[clamp(1.7rem,2vw,2.4rem)] text-[var(--color-foreground)]">
|
|
{value}
|
|
</p>
|
|
<p className="mt-2 text-sm leading-6 text-[var(--color-muted-foreground)]">{children}</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function getLaneBadgeClassName(state: RoutingLaneState) {
|
|
switch (state) {
|
|
case "Watching":
|
|
return "border-[color-mix(in_oklch,var(--color-primary)_28%,var(--color-border))] bg-[color-mix(in_oklch,var(--color-primary)_12%,var(--color-card))] text-[var(--color-foreground)]";
|
|
case "Quiet":
|
|
return "border-[var(--color-border)] bg-[color-mix(in_oklch,var(--color-surface)_88%,white_12%)] text-[var(--color-muted-foreground)]";
|
|
case "Holding":
|
|
return "border-[color-mix(in_oklch,var(--color-accent)_28%,var(--color-border))] bg-[color-mix(in_oklch,var(--color-accent)_14%,var(--color-card))] text-[var(--color-foreground)]";
|
|
case "Ready":
|
|
default:
|
|
return "border-[var(--color-border)] bg-[var(--color-card)] text-[var(--color-foreground)]";
|
|
}
|
|
}
|
|
|
|
function getLaneBadgeVariant(state: RoutingLaneState) {
|
|
switch (state) {
|
|
case "Watching":
|
|
return "solid" as const;
|
|
case "Quiet":
|
|
return "outline" as const;
|
|
case "Holding":
|
|
return undefined;
|
|
case "Ready":
|
|
default:
|
|
return "outline" as const;
|
|
}
|
|
}
|
|
|
|
function toRoutingValues(row?: RoutingLaneRow): RoutingValues {
|
|
return {
|
|
lane: row?.lane.toLowerCase() ?? "engineering",
|
|
notifications: row ? row.state !== "Quiet" : true,
|
|
ownerEmail: row?.ownerEmail ?? "routing@cadence.dev",
|
|
summary:
|
|
row?.note ??
|
|
"Hold the customer note until the support lane stays quiet for one full cycle."
|
|
};
|
|
}
|
|
|
|
function RoutingRowActions({
|
|
onOpenSettings,
|
|
onQueueGate,
|
|
onSendDigest,
|
|
row
|
|
}: {
|
|
onOpenSettings: (row: RoutingLaneRow) => void;
|
|
onQueueGate: (row: RoutingLaneRow) => void;
|
|
onSendDigest: (row: RoutingLaneRow) => void;
|
|
row: RoutingLaneRow;
|
|
}) {
|
|
return (
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger asChild>
|
|
<Button size="sm" variant="ghost">
|
|
Actions
|
|
</Button>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent align="end" size="sm">
|
|
<DropdownMenuItem
|
|
onSelect={() => {
|
|
onOpenSettings(row);
|
|
}}
|
|
>
|
|
Open lane settings
|
|
</DropdownMenuItem>
|
|
<DropdownMenuItem
|
|
onSelect={() => {
|
|
onQueueGate(row);
|
|
}}
|
|
>
|
|
Queue next gate
|
|
</DropdownMenuItem>
|
|
<DropdownMenuItem
|
|
onSelect={() => {
|
|
onSendDigest(row);
|
|
}}
|
|
>
|
|
Send digest
|
|
</DropdownMenuItem>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
);
|
|
}
|
|
|
|
function ReleaseWorkspaceScene({ quietMode = false }: ReleaseWorkspaceProps) {
|
|
const [activeLane, setActiveLane] = useState<RoutingLaneRow | null>(null);
|
|
const [dialogOpen, setDialogOpen] = useState(false);
|
|
const [dialogTarget, setDialogTarget] = useState<RoutingLaneRow | null>(null);
|
|
const [routingFilter, setRoutingFilter] = useState<RoutingFilter>(quietMode ? "quiet" : "all");
|
|
const [routingLoading, setRoutingLoading] = useState(false);
|
|
const [routingSearch, setRoutingSearch] = useState("");
|
|
const [routingSelection, setRoutingSelection] = useState<Record<string, boolean>>({});
|
|
const [routingSorting, setRoutingSorting] = useState<DataTableSort[]>([
|
|
{ desc: false, id: "lane" }
|
|
]);
|
|
const [sheetOpen, setSheetOpen] = useState(false);
|
|
const [toastCopy, setToastCopy] = useState({
|
|
description: "The lane owner and release note are staged for the next quiet cycle.",
|
|
title: "Routing updated"
|
|
});
|
|
const [toastOpen, setToastOpen] = useState(false);
|
|
const form = useForm<RoutingValues>({
|
|
defaultValues: toRoutingValues()
|
|
});
|
|
|
|
const visibleRoutingRows = routingLaneRows.filter((row) => {
|
|
if (routingFilter === "all") {
|
|
return true;
|
|
}
|
|
|
|
return row.state.toLowerCase() === routingFilter;
|
|
});
|
|
const selectedRoutingRows = routingLaneRows.filter((row) => routingSelection[row.id]);
|
|
const selectedRoutingCount = selectedRoutingRows.length;
|
|
const visibleWatchCount = visibleRoutingRows.filter(
|
|
(row) => row.state === "Watching" || row.state === "Holding"
|
|
).length;
|
|
const nextOwner = selectedRoutingRows[0]?.owner ?? visibleRoutingRows[0]?.owner ?? "Routing lead";
|
|
|
|
function showToast(title: string, description: string) {
|
|
setToastCopy({ description, title });
|
|
setToastOpen(true);
|
|
}
|
|
|
|
function openRoutingSheet(row?: RoutingLaneRow) {
|
|
setActiveLane(row ?? null);
|
|
form.reset(toRoutingValues(row));
|
|
setSheetOpen(true);
|
|
}
|
|
|
|
function queueRoutingGate(row?: RoutingLaneRow) {
|
|
setDialogTarget(row ?? null);
|
|
setDialogOpen(true);
|
|
}
|
|
|
|
function refreshRoutingSignals() {
|
|
setRoutingLoading(true);
|
|
|
|
window.setTimeout(() => {
|
|
setRoutingLoading(false);
|
|
showToast(
|
|
"Signals refreshed",
|
|
"Fresh lane telemetry confirms the desk is still readable enough for the next quiet cycle."
|
|
);
|
|
}, 900);
|
|
}
|
|
|
|
const routingColumns: DataTableColumn<RoutingLaneRow>[] = [
|
|
{
|
|
accessor: "lane",
|
|
cell: (row: RoutingLaneRow) => (
|
|
<div className="grid gap-1">
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
<span className="text-sm font-medium text-[var(--color-foreground)]">{row.lane}</span>
|
|
<Badge variant="outline">{row.audience}</Badge>
|
|
</div>
|
|
<p className="text-xs uppercase tracking-[var(--tracking-caps)] text-[var(--color-muted-foreground)]">
|
|
{row.nextGate} next gate
|
|
</p>
|
|
</div>
|
|
),
|
|
header: "Lane",
|
|
id: "lane",
|
|
sortable: true
|
|
},
|
|
{
|
|
accessor: "owner",
|
|
cell: (row: RoutingLaneRow) => (
|
|
<div className="grid gap-1">
|
|
<span className="text-sm font-medium text-[var(--color-foreground)]">{row.owner}</span>
|
|
<span className="text-xs text-[var(--color-muted-foreground)]">{row.ownerEmail}</span>
|
|
</div>
|
|
),
|
|
header: "Owner",
|
|
id: "owner",
|
|
sortable: true
|
|
},
|
|
{
|
|
accessor: "state",
|
|
cell: (row: RoutingLaneRow) => (
|
|
<div className="flex items-center gap-3">
|
|
<Badge className={getLaneBadgeClassName(row.state)} variant={getLaneBadgeVariant(row.state)}>
|
|
{row.state}
|
|
</Badge>
|
|
<span className="text-xs uppercase tracking-[var(--tracking-caps)] text-[var(--color-muted-foreground)]">
|
|
Risk {row.signalScore}
|
|
</span>
|
|
</div>
|
|
),
|
|
header: "Signal",
|
|
id: "state",
|
|
sortable: true
|
|
},
|
|
{
|
|
accessor: "note",
|
|
cell: (row: RoutingLaneRow) => (
|
|
<p className="max-w-[34rem] text-sm leading-6 text-[var(--color-muted-foreground)]">
|
|
{row.note}
|
|
</p>
|
|
),
|
|
header: "Routing note",
|
|
id: "note"
|
|
}
|
|
];
|
|
|
|
return (
|
|
<ToastProvider swipeDirection="right">
|
|
<TooltipProvider delayDuration={120}>
|
|
<div className="min-h-screen overflow-hidden bg-[radial-gradient(circle_at_top_left,color-mix(in_oklch,var(--color-accent)_14%,transparent),transparent_34%),radial-gradient(circle_at_85%_18%,color-mix(in_oklch,var(--color-primary)_12%,transparent),transparent_28%),var(--color-background)] text-[var(--color-foreground)]">
|
|
<div className="mx-auto flex w-full max-w-[92rem] flex-col gap-8 px-6 py-8 sm:px-10 lg:gap-10 lg:px-12">
|
|
<section className="relative overflow-hidden rounded-[calc(var(--radius-xl)+0.35rem)] border border-[color-mix(in_oklch,var(--color-border)_78%,transparent)] bg-[linear-gradient(145deg,color-mix(in_oklch,var(--color-card)_84%,white_16%),color-mix(in_oklch,var(--color-surface)_88%,white_12%))] p-6 shadow-[var(--shadow-md)] sm:p-8">
|
|
<div className="absolute -right-16 top-0 h-40 w-40 rounded-full bg-[color-mix(in_oklch,var(--color-primary)_14%,transparent)] blur-3xl" />
|
|
<div className="absolute bottom-0 left-0 h-px w-full bg-[linear-gradient(90deg,transparent,color-mix(in_oklch,var(--color-border-strong)_58%,transparent),transparent)]" />
|
|
|
|
<div className="relative grid gap-8 xl:grid-cols-[minmax(0,1.25fr)_20rem]">
|
|
<div className="space-y-6">
|
|
<div className="flex flex-wrap items-center gap-3">
|
|
<Badge>March 24 / Controlled rollout</Badge>
|
|
<Badge variant="outline">Workspace / Ops editorial</Badge>
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<button
|
|
className="inline-flex items-center gap-2 rounded-[var(--radius-full)] border border-[color-mix(in_oklch,var(--color-primary)_30%,var(--color-border))] bg-[color-mix(in_oklch,var(--color-primary)_12%,var(--color-card))] px-3 py-1 text-xs font-medium tracking-[var(--tracking-caps)] text-[var(--color-foreground)]"
|
|
type="button"
|
|
>
|
|
Risk 14
|
|
</button>
|
|
</TooltipTrigger>
|
|
<TooltipContent size="sm">
|
|
Risk remains contained because support is quiet and rollback guardrails are staged.
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
</div>
|
|
|
|
<div className="max-w-4xl space-y-4">
|
|
<p className="text-sm uppercase tracking-[0.26em] text-[var(--color-muted-foreground)]">
|
|
Cadence / Release operations board
|
|
</p>
|
|
<h1
|
|
className="max-w-4xl font-semibold tracking-[var(--tracking-tight)] text-[clamp(2.7rem,5vw,5.4rem)]"
|
|
style={{
|
|
fontFamily: "var(--font-display)",
|
|
lineHeight: "0.94"
|
|
}}
|
|
>
|
|
The launch is calm enough to move fast, but only if routing stays disciplined.
|
|
</h1>
|
|
<p className="max-w-3xl text-[var(--text-lg)] leading-[var(--leading-loose)] text-[var(--color-muted-foreground)]">
|
|
This workspace treats release ops like an editorial desk: one narrative,
|
|
one routing owner, one quiet signal loop. The goal is not to ship more
|
|
controls. The goal is to keep a high-stakes rollout readable.
|
|
</p>
|
|
</div>
|
|
|
|
<div className="flex flex-wrap items-center gap-3">
|
|
<Button
|
|
onClick={() => {
|
|
queueRoutingGate();
|
|
}}
|
|
>
|
|
Queue rollout wave
|
|
</Button>
|
|
<Button
|
|
variant="secondary"
|
|
onClick={() => {
|
|
openRoutingSheet();
|
|
}}
|
|
>
|
|
Edit routing lane
|
|
</Button>
|
|
<Popover>
|
|
<PopoverTrigger asChild>
|
|
<Button variant="ghost">Signal blend</Button>
|
|
</PopoverTrigger>
|
|
<PopoverContent className="grid gap-3" side="bottom" size="sm">
|
|
<p className="text-sm font-medium text-[var(--color-foreground)]">
|
|
Current signal blend
|
|
</p>
|
|
<div className="grid gap-2 text-sm leading-6 text-[var(--color-muted-foreground)]">
|
|
<p>Canary checks: stable for 28 minutes.</p>
|
|
<p>Support queue: zero new escalations in the current pass.</p>
|
|
<p>Legal note: one copy footnote remains under review.</p>
|
|
</div>
|
|
<PopoverArrow />
|
|
</PopoverContent>
|
|
</Popover>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid gap-3">
|
|
<MetricPanel eyebrow="Next gate" tone="accent" value="19:15">
|
|
Queue the first wave after engineering and support both stay green.
|
|
</MetricPanel>
|
|
<MetricPanel eyebrow="Reviewers" value="6 / 7">
|
|
Legal is the only lane still holding a sentence-level note.
|
|
</MetricPanel>
|
|
<MetricPanel eyebrow="Quiet window" value={quietMode ? "46m" : "18m"}>
|
|
{quietMode
|
|
? "The board is almost empty, which is when routing discipline matters most."
|
|
: "Support is quiet, but the customer note should stay staged until the next check."}
|
|
</MetricPanel>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<div className="grid gap-8 xl:grid-cols-[minmax(0,1fr)_21rem]">
|
|
<div className="grid gap-8">
|
|
<Tabs className="grid gap-6" defaultValue="overview">
|
|
<div className="flex flex-wrap items-center justify-between gap-4">
|
|
<TabsList>
|
|
<TabsTrigger value="overview">Overview</TabsTrigger>
|
|
<TabsTrigger value="routing">Routing</TabsTrigger>
|
|
<TabsTrigger value="audience">Audience</TabsTrigger>
|
|
</TabsList>
|
|
<p className="text-sm leading-6 text-[var(--color-muted-foreground)]">
|
|
One workspace for the story, the gate, and the next decision.
|
|
</p>
|
|
</div>
|
|
|
|
<TabsContent value="overview" className="grid gap-6">
|
|
<div className="grid gap-6 xl:grid-cols-[minmax(0,1.08fr)_minmax(18rem,0.92fr)]">
|
|
<article className="min-w-0 overflow-hidden rounded-[calc(var(--radius-lg)+0.15rem)] border border-[var(--color-border)] bg-[linear-gradient(180deg,color-mix(in_oklch,var(--color-card)_88%,white_12%),var(--color-card))] shadow-[var(--shadow-sm)]">
|
|
<div className="grid gap-6 p-6 sm:p-7">
|
|
<div className="flex flex-wrap items-start justify-between gap-4">
|
|
<div className="space-y-2">
|
|
<p className="text-xs uppercase tracking-[var(--tracking-caps)] text-[var(--color-muted-foreground)]">
|
|
Release narrative
|
|
</p>
|
|
<h2 className="text-2xl font-semibold tracking-[var(--tracking-tight)]">
|
|
The migration is technically ready, but the customer story should still feel measured.
|
|
</h2>
|
|
</div>
|
|
<Badge variant="solid">10% wave pending</Badge>
|
|
</div>
|
|
|
|
<p className="max-w-3xl text-sm leading-7 text-[var(--color-muted-foreground)]">
|
|
Engineering has reduced the technical risk. What remains is message
|
|
discipline: route the launch through one owner, keep the customer
|
|
note staged, and avoid turning a calm rollout into a noisy one.
|
|
</p>
|
|
|
|
<div className="grid gap-4 sm:grid-cols-2 2xl:grid-cols-3">
|
|
{timelineStops.map((item) => (
|
|
<div
|
|
key={item.title}
|
|
className="grid min-w-0 gap-3 rounded-[var(--radius-md)] border border-[color-mix(in_oklch,var(--color-border)_84%,transparent)] bg-[color-mix(in_oklch,var(--color-background)_74%,white_26%)] p-4"
|
|
>
|
|
<div className="grid grid-cols-[minmax(0,1fr)_auto] items-start gap-3">
|
|
<p className="min-w-0 text-sm font-medium text-[var(--color-foreground)]">
|
|
{item.title}
|
|
</p>
|
|
<span className="text-xs uppercase tracking-[var(--tracking-caps)] text-[var(--color-muted-foreground)]">
|
|
{item.window}
|
|
</span>
|
|
</div>
|
|
<p className="text-sm leading-6 text-[var(--color-muted-foreground)]">
|
|
{item.note}
|
|
</p>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</article>
|
|
|
|
{quietMode ? (
|
|
<EmptyState className="h-full justify-center" tone="subtle">
|
|
<EmptyStateMedia>
|
|
<SignalGlyph />
|
|
</EmptyStateMedia>
|
|
<EmptyStateHeader>
|
|
<EmptyStateEyebrow>Quiet shift</EmptyStateEyebrow>
|
|
<EmptyStateTitle>No escalations are waiting on this desk.</EmptyStateTitle>
|
|
<EmptyStateDescription>
|
|
Keep the board sparse, hold the customer note, and use the next
|
|
quiet cycle to confirm routing before the wave is queued.
|
|
</EmptyStateDescription>
|
|
</EmptyStateHeader>
|
|
<EmptyStateActions>
|
|
<Button
|
|
onClick={() => {
|
|
openRoutingSheet();
|
|
}}
|
|
>
|
|
Review routing
|
|
</Button>
|
|
<Button variant="ghost">Open checklist</Button>
|
|
</EmptyStateActions>
|
|
</EmptyState>
|
|
) : (
|
|
<Card className="min-w-0">
|
|
<CardHeader>
|
|
<CardTitle>Support pulse</CardTitle>
|
|
<CardDescription>
|
|
The queue is calm, but not calm enough to go unsupervised.
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent className="grid gap-4">
|
|
<div className="grid gap-3">
|
|
<div className="rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-background)] p-4">
|
|
<div className="flex items-center justify-between gap-3">
|
|
<p className="text-sm font-medium text-[var(--color-foreground)]">
|
|
Customer note
|
|
</p>
|
|
<Badge>Staged</Badge>
|
|
</div>
|
|
<p className="mt-2 text-sm leading-6 text-[var(--color-muted-foreground)]">
|
|
Keep the note unpublished until the canary stays quiet for one
|
|
more cycle.
|
|
</p>
|
|
</div>
|
|
<div className="rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-background)] p-4">
|
|
<div className="flex items-center justify-between gap-3">
|
|
<p className="text-sm font-medium text-[var(--color-foreground)]">
|
|
Macro pack
|
|
</p>
|
|
<Badge variant="outline">Ready</Badge>
|
|
</div>
|
|
<p className="mt-2 text-sm leading-6 text-[var(--color-muted-foreground)]">
|
|
Support copy aligns with the release narrative and rollback path.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<EmptyState tone="subtle">
|
|
<EmptyStateHeader>
|
|
<EmptyStateEyebrow>Escalations</EmptyStateEyebrow>
|
|
<EmptyStateTitle>No active escalations</EmptyStateTitle>
|
|
<EmptyStateDescription>
|
|
Stay disciplined anyway. Quiet boards are where rushed launches
|
|
usually create avoidable noise.
|
|
</EmptyStateDescription>
|
|
</EmptyStateHeader>
|
|
</EmptyState>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
</div>
|
|
</TabsContent>
|
|
|
|
<TabsContent value="routing" className="grid gap-6">
|
|
<div className="grid gap-6 lg:grid-cols-[minmax(0,1.08fr)_minmax(17rem,0.92fr)]">
|
|
<article className="rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-card)] p-6 shadow-[var(--shadow-sm)]">
|
|
<div className="flex flex-wrap items-center justify-between gap-4">
|
|
<div className="space-y-2">
|
|
<p className="text-xs uppercase tracking-[var(--tracking-caps)] text-[var(--color-muted-foreground)]">
|
|
Routing principle
|
|
</p>
|
|
<h2 className="text-2xl font-semibold tracking-[var(--tracking-tight)]">
|
|
Keep one owner visible, one fallback explicit, and one customer note staged.
|
|
</h2>
|
|
</div>
|
|
<div className="flex flex-wrap items-center gap-3">
|
|
<Badge variant="outline">{visibleRoutingRows.length} visible lanes</Badge>
|
|
<Button
|
|
variant="secondary"
|
|
onClick={() => {
|
|
openRoutingSheet(selectedRoutingRows[0]);
|
|
}}
|
|
>
|
|
Adjust lane settings
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
<p className="mt-4 max-w-3xl text-sm leading-7 text-[var(--color-muted-foreground)]">
|
|
The routing desk stops being narrative-only here. Search narrows the live
|
|
lane list, sorting reshapes the order of attention, selection supports a
|
|
quiet multi-lane brief, and row actions keep deeper edits in overlays
|
|
instead of forcing the table to become an app inside itself.
|
|
</p>
|
|
</article>
|
|
|
|
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-1">
|
|
<MetricPanel eyebrow="Needs eyes" tone="accent" value={String(visibleWatchCount)}>
|
|
Watching and holding lanes should stay visible until the next quiet cycle clears.
|
|
</MetricPanel>
|
|
<MetricPanel eyebrow="Selected lanes" value={String(selectedRoutingCount)}>
|
|
{selectedRoutingCount > 0
|
|
? `${nextOwner} currently anchors the first selected lane.`
|
|
: "Use row selection to brief multiple owners without leaving the desk."}
|
|
</MetricPanel>
|
|
</div>
|
|
</div>
|
|
|
|
<section className="overflow-hidden rounded-[calc(var(--radius-lg)+0.15rem)] border border-[var(--color-border)] bg-[linear-gradient(180deg,color-mix(in_oklch,var(--color-card)_90%,white_10%),var(--color-card))] shadow-[var(--shadow-sm)]">
|
|
<div className="border-b border-[color-mix(in_oklch,var(--color-border)_84%,transparent)] px-6 py-5">
|
|
<div className="flex flex-wrap items-start justify-between gap-4">
|
|
<div className="space-y-2">
|
|
<p className="text-xs uppercase tracking-[var(--tracking-caps)] text-[var(--color-muted-foreground)]">
|
|
Routing lanes
|
|
</p>
|
|
<h3 className="text-xl font-semibold tracking-[var(--tracking-tight)]">
|
|
One table for owners, notes, gates, and the next quiet-cycle decision.
|
|
</h3>
|
|
</div>
|
|
<div className="flex flex-wrap items-center gap-3">
|
|
<Badge>Search, sort, select, paginate</Badge>
|
|
<Badge variant="outline">Row actions stay out of the grid</Badge>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<DataTable
|
|
columns={routingColumns}
|
|
empty={
|
|
<EmptyState
|
|
className="min-h-72 justify-center border-0 bg-transparent shadow-none"
|
|
tone="subtle"
|
|
>
|
|
<EmptyStateMedia>
|
|
<SignalGlyph />
|
|
</EmptyStateMedia>
|
|
<EmptyStateHeader>
|
|
<EmptyStateEyebrow>No visible routing lanes</EmptyStateEyebrow>
|
|
<EmptyStateTitle>The current desk view is intentionally sparse.</EmptyStateTitle>
|
|
<EmptyStateDescription>
|
|
Clear the search or switch the lane filter if you need a wider view before queueing.
|
|
</EmptyStateDescription>
|
|
</EmptyStateHeader>
|
|
<EmptyStateActions>
|
|
<Button
|
|
onClick={() => {
|
|
setRoutingFilter("all");
|
|
setRoutingSearch("");
|
|
setRoutingSelection({});
|
|
}}
|
|
>
|
|
Reset table view
|
|
</Button>
|
|
</EmptyStateActions>
|
|
</EmptyState>
|
|
}
|
|
getRowId={(row: RoutingLaneRow) => row.id}
|
|
loading={routingLoading}
|
|
onSearchValueChange={setRoutingSearch}
|
|
onSelectionChange={setRoutingSelection}
|
|
onSortingChange={setRoutingSorting}
|
|
pageSize={4}
|
|
pageSizeOptions={[4]}
|
|
searchLabel="Search routing lanes"
|
|
searchPlaceholder="Search lanes, owners, notes"
|
|
selectionLabel={(selectedRows) =>
|
|
`${selectedRows.length} lane${selectedRows.length === 1 ? "" : "s"} selected for a quiet-cycle brief.`
|
|
}
|
|
selectionActions={() => (
|
|
<>
|
|
<Button
|
|
size="sm"
|
|
variant="secondary"
|
|
onClick={() => {
|
|
openRoutingSheet(selectedRoutingRows[0]);
|
|
}}
|
|
>
|
|
Reassign first owner
|
|
</Button>
|
|
<Button
|
|
size="sm"
|
|
variant="ghost"
|
|
onClick={() => {
|
|
showToast(
|
|
"Digest queued",
|
|
`${selectedRoutingCount} routing lane${selectedRoutingCount === 1 ? "" : "s"} will be summarized for the next quiet cycle.`
|
|
);
|
|
}}
|
|
>
|
|
Queue digest
|
|
</Button>
|
|
</>
|
|
)}
|
|
enableSelection
|
|
renderRowActions={(row: RoutingLaneRow) => (
|
|
<RoutingRowActions
|
|
onOpenSettings={openRoutingSheet}
|
|
onQueueGate={queueRoutingGate}
|
|
onSendDigest={(targetRow) => {
|
|
showToast(
|
|
`${targetRow.lane} digest sent`,
|
|
`${targetRow.owner} now has the latest routing note and quiet-cycle gate in their inbox.`
|
|
);
|
|
}}
|
|
row={row}
|
|
/>
|
|
)}
|
|
rows={visibleRoutingRows}
|
|
searchValue={routingSearch}
|
|
selection={routingSelection}
|
|
sorting={routingSorting}
|
|
toolbarActions={
|
|
<>
|
|
<Select
|
|
value={routingFilter}
|
|
onValueChange={(value) => {
|
|
setRoutingFilter(value as RoutingFilter);
|
|
setRoutingSelection({});
|
|
}}
|
|
>
|
|
<SelectTrigger aria-label="Routing lane filter" className="w-[11rem]">
|
|
<SelectValue placeholder="Filter lanes" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{routingFilterOptions.map((option) => (
|
|
<SelectItem key={option.value} value={option.value}>
|
|
{option.label}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
<Button
|
|
variant="ghost"
|
|
onClick={() => {
|
|
setRoutingFilter(quietMode ? "quiet" : "all");
|
|
setRoutingSearch("");
|
|
setRoutingSelection({});
|
|
setRoutingSorting([{ desc: false, id: "lane" }]);
|
|
}}
|
|
>
|
|
Reset desk
|
|
</Button>
|
|
<Button
|
|
variant="secondary"
|
|
onClick={() => {
|
|
refreshRoutingSignals();
|
|
}}
|
|
>
|
|
Refresh signals
|
|
</Button>
|
|
</>
|
|
}
|
|
/>
|
|
</section>
|
|
</TabsContent>
|
|
|
|
<TabsContent
|
|
value="audience"
|
|
className="grid gap-6 lg:grid-cols-[minmax(0,1fr)_19rem]"
|
|
>
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>Audience framing</CardTitle>
|
|
<CardDescription>
|
|
The internal narrative should be more detailed than the external one.
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent className="grid gap-4">
|
|
<div className="grid gap-4 md:grid-cols-2">
|
|
<div className="rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-background)] p-4">
|
|
<p className="text-sm font-medium text-[var(--color-foreground)]">
|
|
Internal viewers
|
|
</p>
|
|
<p className="mt-2 text-sm leading-6 text-[var(--color-muted-foreground)]">
|
|
Mention rollback thresholds, reviewer routing, and support macro timing.
|
|
</p>
|
|
</div>
|
|
<div className="rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-background)] p-4">
|
|
<p className="text-sm font-medium text-[var(--color-foreground)]">
|
|
Customers
|
|
</p>
|
|
<p className="mt-2 text-sm leading-6 text-[var(--color-muted-foreground)]">
|
|
Keep the message short, calm, and reversible. Explain the value, not the pipeline.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card className="bg-[color-mix(in_oklch,var(--color-surface)_90%,white_10%)]">
|
|
<CardHeader>
|
|
<CardTitle>Presenter note</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<p className="text-sm leading-6 text-[var(--color-muted-foreground)]">
|
|
If the launch feels quiet in this tab, that is success. The board should
|
|
only get louder when a decision actually changes.
|
|
</p>
|
|
</CardContent>
|
|
</Card>
|
|
</TabsContent>
|
|
</Tabs>
|
|
</div>
|
|
|
|
<aside className="grid gap-4 self-start xl:sticky xl:top-6">
|
|
<Card className="bg-[color-mix(in_oklch,var(--color-card)_88%,white_12%)]">
|
|
<CardHeader>
|
|
<CardTitle>Wave checklist</CardTitle>
|
|
<CardDescription>
|
|
A calm rollout still needs a visible last-mile checklist.
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent className="grid gap-3">
|
|
{[
|
|
"Legal footnote approved",
|
|
"Support queue stays quiet for one more pass",
|
|
"Customer note remains staged",
|
|
"Fallback owner is visible in routing"
|
|
].map((item, index) => (
|
|
<div
|
|
key={item}
|
|
className="flex items-start gap-3 rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-background)] px-3 py-3"
|
|
>
|
|
<span
|
|
className={[
|
|
"mt-1 inline-flex size-4 shrink-0 rounded-full border",
|
|
index < 2
|
|
? "border-[var(--color-primary)] bg-[var(--color-primary)]"
|
|
: "border-[var(--color-border-strong)] bg-transparent"
|
|
].join(" ")}
|
|
/>
|
|
<p className="text-sm leading-6 text-[var(--color-muted-foreground)]">
|
|
{item}
|
|
</p>
|
|
</div>
|
|
))}
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>Desk note</CardTitle>
|
|
<CardDescription>Operational restraint is the product here.</CardDescription>
|
|
</CardHeader>
|
|
<CardContent className="grid gap-3 text-sm leading-6 text-[var(--color-muted-foreground)]">
|
|
<p>
|
|
A healthy release workspace should help someone decide what to do next in one
|
|
glance.
|
|
</p>
|
|
<p>
|
|
If the board starts repeating the same information in three places, the page
|
|
is becoming nervous.
|
|
</p>
|
|
</CardContent>
|
|
</Card>
|
|
</aside>
|
|
</div>
|
|
</div>
|
|
|
|
<Dialog
|
|
onOpenChange={(open) => {
|
|
setDialogOpen(open);
|
|
if (!open) {
|
|
setDialogTarget(null);
|
|
}
|
|
}}
|
|
open={dialogOpen}
|
|
>
|
|
<DialogContent size="sm">
|
|
<DialogHeader>
|
|
<DialogTitle>
|
|
{dialogTarget
|
|
? `Queue ${dialogTarget.lane} / ${dialogTarget.audience}?`
|
|
: "Queue the 10% wave?"}
|
|
</DialogTitle>
|
|
<DialogDescription>
|
|
{dialogTarget
|
|
? `This hands ${dialogTarget.owner} the ${dialogTarget.nextGate} gate. Use it only if the note is readable, the owner is live, and the desk is still quiet enough to stay disciplined.`
|
|
: "This sends the first controlled wave, not the full customer note. Use it only if support stays quiet and the routing owner is live."}
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
<DialogFooter>
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
onClick={() => {
|
|
setDialogOpen(false);
|
|
setDialogTarget(null);
|
|
}}
|
|
>
|
|
Keep staged
|
|
</Button>
|
|
<Button
|
|
type="button"
|
|
onClick={() => {
|
|
setDialogOpen(false);
|
|
showToast(
|
|
dialogTarget ? `${dialogTarget.lane} gate queued` : "Wave queued",
|
|
dialogTarget
|
|
? `${dialogTarget.owner} now owns the ${dialogTarget.nextGate} gate for the ${dialogTarget.audience.toLowerCase()} pass.`
|
|
: "The lane owner and release note are staged for the next quiet cycle."
|
|
);
|
|
setDialogTarget(null);
|
|
}}
|
|
>
|
|
{dialogTarget ? "Queue gate" : "Queue wave"}
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
<Sheet
|
|
onOpenChange={(open) => {
|
|
setSheetOpen(open);
|
|
if (!open) {
|
|
setActiveLane(null);
|
|
}
|
|
}}
|
|
open={sheetOpen}
|
|
>
|
|
<SheetContent side="right" size="lg">
|
|
<SheetHeader>
|
|
<SheetTitle>
|
|
{activeLane ? `${activeLane.lane} lane settings` : "Routing lane settings"}
|
|
</SheetTitle>
|
|
<SheetDescription>
|
|
{activeLane
|
|
? `Update the owner, note, and digest posture for ${activeLane.audience.toLowerCase()} before the next gate.`
|
|
: "Keep the lane owner explicit and the customer note disciplined before the wave is queued."}
|
|
</SheetDescription>
|
|
</SheetHeader>
|
|
|
|
<Form {...form}>
|
|
<form
|
|
className="grid gap-5"
|
|
onSubmit={form.handleSubmit(() => {
|
|
setSheetOpen(false);
|
|
showToast(
|
|
activeLane ? `${activeLane.lane} lane updated` : "Routing updated",
|
|
activeLane
|
|
? `${activeLane.owner} now has a refreshed routing brief for ${activeLane.audience.toLowerCase()}.`
|
|
: "The lane owner and release note are staged for the next quiet cycle."
|
|
);
|
|
setActiveLane(null);
|
|
})}
|
|
>
|
|
<FormItem name="ownerEmail" required>
|
|
<FormLabel>Lane owner email</FormLabel>
|
|
<FormControl>
|
|
<Input
|
|
placeholder="routing@cadence.dev"
|
|
{...form.register("ownerEmail", {
|
|
required: "Lane owner email is required."
|
|
})}
|
|
/>
|
|
</FormControl>
|
|
<FormDescription>This person owns the final routing decision.</FormDescription>
|
|
<FormMessage />
|
|
</FormItem>
|
|
|
|
<Controller
|
|
control={form.control}
|
|
name="lane"
|
|
render={({ field }) => (
|
|
<FormItem name="lane">
|
|
<FormLabel>Primary lane</FormLabel>
|
|
<FormControl>
|
|
<Select value={field.value} onValueChange={field.onChange}>
|
|
<SelectTrigger aria-label="Primary lane">
|
|
<SelectValue placeholder="Choose a lane" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="engineering">Engineering</SelectItem>
|
|
<SelectItem value="editorial">Editorial</SelectItem>
|
|
<SelectItem value="support">Support</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</FormControl>
|
|
<FormDescription>
|
|
The highlighted lane stays visible on the release desk.
|
|
</FormDescription>
|
|
<FormMessage />
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
|
|
<FormItem name="summary">
|
|
<FormLabel>Routing note</FormLabel>
|
|
<FormControl>
|
|
<Textarea
|
|
placeholder="Capture the condition that must remain true before queueing."
|
|
{...form.register("summary")}
|
|
/>
|
|
</FormControl>
|
|
<FormDescription>
|
|
Use this for the one sentence the next reviewer needs to trust.
|
|
</FormDescription>
|
|
</FormItem>
|
|
|
|
<Controller
|
|
control={form.control}
|
|
name="notifications"
|
|
render={({ field }) => (
|
|
<FormItem name="notifications">
|
|
<div className="flex items-start justify-between gap-4 rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-surface)] p-4">
|
|
<div className="space-y-1">
|
|
<FormLabel>Escalation digest</FormLabel>
|
|
<FormDescription>
|
|
Send a quiet-cycle summary to the routing owner before queueing.
|
|
</FormDescription>
|
|
</div>
|
|
<FormControl className="gap-0">
|
|
<Switch
|
|
aria-label="Escalation digest"
|
|
checked={field.value}
|
|
onCheckedChange={field.onChange}
|
|
/>
|
|
</FormControl>
|
|
</div>
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
|
|
<SheetFooter>
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
onClick={() => {
|
|
setSheetOpen(false);
|
|
setActiveLane(null);
|
|
}}
|
|
>
|
|
Cancel
|
|
</Button>
|
|
<Button type="submit">Save routing</Button>
|
|
</SheetFooter>
|
|
</form>
|
|
</Form>
|
|
</SheetContent>
|
|
</Sheet>
|
|
|
|
<Toast onOpenChange={setToastOpen} open={toastOpen} variant="success">
|
|
<ToastTitle>{toastCopy.title}</ToastTitle>
|
|
<ToastDescription>{toastCopy.description}</ToastDescription>
|
|
<ToastAction altText="Open desk">Open desk</ToastAction>
|
|
<ToastClose />
|
|
</Toast>
|
|
<ToastViewport />
|
|
</div>
|
|
</TooltipProvider>
|
|
</ToastProvider>
|
|
);
|
|
}
|
|
|
|
const meta = {
|
|
title: "Scenes/Release Workspace",
|
|
component: ReleaseWorkspaceScene,
|
|
parameters: {
|
|
docs: {
|
|
description: {
|
|
component:
|
|
"Release Workspace is a realistic composition story that shows how Cadence UI behaves when the design system stops being a component shelf and becomes an actual operations surface. It now uses a real routing table workflow to test search, sorting, selection, pagination, row actions, and overlay handoffs inside one believable release desk."
|
|
}
|
|
},
|
|
layout: "fullscreen"
|
|
},
|
|
tags: ["autodocs"]
|
|
} satisfies Meta<typeof ReleaseWorkspaceScene>;
|
|
|
|
export default meta;
|
|
|
|
type Story = StoryObj<typeof meta>;
|
|
|
|
export const Playground: Story = {};
|
|
|
|
export const QuietShift: Story = {
|
|
args: {
|
|
quietMode: true
|
|
}
|
|
};
|