+
{
@@ -326,7 +326,7 @@ function SchemaBuilderShowcase() {
Control plane
-
+
+ Update
+
+
+
+ Delete account
+
+
+
+
+ Delete account?
+
+ This permanently removes the workspace, its schemas and all data. This action cannot be undone.
+
+
+
+ Cancel
+ Delete account
+
+
+
+ >
+ }
+ >
+
+
+
+
+
+
+
+ );
+}
diff --git a/apps/blocks/src/components/theme-playground/canvas/cards/active-connections-card.tsx b/apps/blocks/src/components/theme-playground/canvas/cards/active-connections-card.tsx
new file mode 100644
index 00000000..1625e1da
--- /dev/null
+++ b/apps/blocks/src/components/theme-playground/canvas/cards/active-connections-card.tsx
@@ -0,0 +1,47 @@
+import { Badge } from '@constructive-io/ui/badge';
+
+import { Area } from '../charts/area';
+import { SinkCard } from './sink-card';
+
+const POOLED = [30, 34, 32, 40, 44, 42, 50, 54, 52, 60, 62, 66];
+const DIRECT = [22, 20, 24, 26, 24, 30, 28, 32, 34, 33, 38, 40];
+
+export function ActiveConnectionsCard() {
+ return (
+
+ +12%
+
+ }
+ contentClassName="flex flex-col gap-4"
+ >
+
+
+
+
+ Pooled
+
+
+
+ Direct
+
+
+
+ );
+}
diff --git a/apps/blocks/src/components/theme-playground/canvas/cards/activity-prefs-card.tsx b/apps/blocks/src/components/theme-playground/canvas/cards/activity-prefs-card.tsx
new file mode 100644
index 00000000..0622a8df
--- /dev/null
+++ b/apps/blocks/src/components/theme-playground/canvas/cards/activity-prefs-card.tsx
@@ -0,0 +1,42 @@
+import { Button } from '@constructive-io/ui/button';
+import { Checkbox } from '@constructive-io/ui/checkbox';
+import { FieldLabel } from '@constructive-io/ui/field';
+import { Separator } from '@constructive-io/ui/separator';
+
+import { SinkCard } from './sink-card';
+
+const PREFS = [
+ { id: 'team-feed', label: 'Show my activity on the team feed', on: true },
+ { id: 'migration-fail', label: 'Email me when a migration fails', on: true },
+ { id: 'weekly-digest', label: 'Weekly digest', on: false },
+];
+
+export function ActivityPrefsCard() {
+ return (
+
+ Save
+
+ }
+ >
+ {PREFS.map((pref) => (
+
+
+
+ {pref.label}
+
+
+ ))}
+
+
+
+
+ Include staging projects
+
+
+
+ );
+}
diff --git a/apps/blocks/src/components/theme-playground/canvas/cards/alerts-card.tsx b/apps/blocks/src/components/theme-playground/canvas/cards/alerts-card.tsx
new file mode 100644
index 00000000..3c2b4185
--- /dev/null
+++ b/apps/blocks/src/components/theme-playground/canvas/cards/alerts-card.tsx
@@ -0,0 +1,51 @@
+import { AlertCircle, CheckCircle2, Info, TriangleAlert, type LucideIcon } from 'lucide-react';
+
+import { Alert, AlertDescription, AlertTitle } from '@constructive-io/ui/alert';
+
+import { SinkCard } from './sink-card';
+
+const ALERTS: {
+ variant: 'destructive' | 'info' | 'success' | 'warning';
+ title: string;
+ body: string;
+ icon: LucideIcon;
+}[] = [
+ {
+ variant: 'info',
+ title: 'Scheduled upgrade',
+ body: 'Postgres 17 rolls out to this project on Friday.',
+ icon: Info,
+ },
+ {
+ variant: 'success',
+ title: 'Backup verified',
+ body: 'Nightly snapshot completed in 41 seconds.',
+ icon: CheckCircle2,
+ },
+ {
+ variant: 'warning',
+ title: 'Approaching limit',
+ body: 'Bandwidth is at 82% of the monthly quota.',
+ icon: TriangleAlert,
+ },
+ {
+ variant: 'destructive',
+ title: 'Deploy failed',
+ body: 'Migration 0042 conflicts with constraint users_email_key.',
+ icon: AlertCircle,
+ },
+];
+
+export function AlertsCard() {
+ return (
+
+ {ALERTS.map((alert) => (
+
+
+ {alert.title}
+ {alert.body}
+
+ ))}
+
+ );
+}
diff --git a/apps/blocks/src/components/theme-playground/canvas/cards/analytics-card.tsx b/apps/blocks/src/components/theme-playground/canvas/cards/analytics-card.tsx
new file mode 100644
index 00000000..8008cefe
--- /dev/null
+++ b/apps/blocks/src/components/theme-playground/canvas/cards/analytics-card.tsx
@@ -0,0 +1,42 @@
+import { Badge } from '@constructive-io/ui/badge';
+import { Button } from '@constructive-io/ui/button';
+
+import { Area } from '../charts/area';
+import { SinkCard } from './sink-card';
+
+const KPIS = [
+ { label: 'Requests', value: '2.4M', delta: '+8.1%' },
+ { label: 'Errors', value: '0.12%', delta: '-0.04' },
+ { label: 'p95', value: '48 ms', delta: '-6 ms' },
+];
+
+const REQUESTS = [42, 38, 45, 51, 48, 55, 60, 57, 63, 58, 66, 71, 68, 74, 70, 77, 82, 78, 74, 80, 85, 79, 83, 88];
+
+export function AnalyticsCard() {
+ return (
+ Live}
+ contentClassName="flex flex-col gap-4"
+ footer={
+ <>
+ Last 24 h
+
+ Export
+
+ >
+ }
+ >
+
+ {KPIS.map((kpi) => (
+
+
{kpi.label}
+
{kpi.value}
+
{kpi.delta}
+
+ ))}
+
+
+
+ );
+}
diff --git a/apps/blocks/src/components/theme-playground/canvas/cards/anomaly-alert-card.tsx b/apps/blocks/src/components/theme-playground/canvas/cards/anomaly-alert-card.tsx
new file mode 100644
index 00000000..04fb75ee
--- /dev/null
+++ b/apps/blocks/src/components/theme-playground/canvas/cards/anomaly-alert-card.tsx
@@ -0,0 +1,30 @@
+import { Activity } from 'lucide-react';
+
+import { Button } from '@constructive-io/ui/button';
+import { Empty, EmptyContent, EmptyDescription, EmptyHeader, EmptyTitle } from '@constructive-io/ui/empty';
+
+import { SinkCard } from './sink-card';
+
+export function AnomalyAlertCard() {
+ return (
+
+
+
+
+ Anomaly detected
+
+ Write latency on orders is 3.2× the 7-day baseline.
+
+
+
+ Investigate
+
+ Dismiss
+
+
+
+
+ );
+}
diff --git a/apps/blocks/src/components/theme-playground/canvas/cards/api-keys-card.tsx b/apps/blocks/src/components/theme-playground/canvas/cards/api-keys-card.tsx
new file mode 100644
index 00000000..53024ffa
--- /dev/null
+++ b/apps/blocks/src/components/theme-playground/canvas/cards/api-keys-card.tsx
@@ -0,0 +1,65 @@
+import { MoreHorizontal } from 'lucide-react';
+
+import { Badge } from '@constructive-io/ui/badge';
+import { Button } from '@constructive-io/ui/button';
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger,
+} from '@constructive-io/ui/dropdown-menu';
+import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@constructive-io/ui/table';
+
+import { SinkCard } from './sink-card';
+
+const KEYS = [
+ { name: 'service_role', lastUsed: '2m', active: true },
+ { name: 'anon_read', lastUsed: '14m', active: true },
+ { name: 'ci_deploy', lastUsed: '3d', active: false },
+];
+
+export function ApiKeysCard() {
+ return (
+
+
+
+
+ Name
+ Last used
+ Status
+
+ Actions
+
+
+
+
+ {KEYS.map((key) => (
+
+ {key.name}
+ {key.lastUsed}
+
+ {key.active ? 'Active' : 'Revoked'}
+
+
+
+
+
+
+
+
+
+ Copy key
+ Rotate
+
+ Revoke
+
+
+
+
+ ))}
+
+
+
+ );
+}
diff --git a/apps/blocks/src/components/theme-playground/canvas/cards/assign-issue-card.tsx b/apps/blocks/src/components/theme-playground/canvas/cards/assign-issue-card.tsx
new file mode 100644
index 00000000..877a04d0
--- /dev/null
+++ b/apps/blocks/src/components/theme-playground/canvas/cards/assign-issue-card.tsx
@@ -0,0 +1,74 @@
+import { Avatar, AvatarFallback } from '@constructive-io/ui/avatar';
+import { Button } from '@constructive-io/ui/button';
+import {
+ Combobox,
+ ComboboxEmpty,
+ ComboboxInput,
+ ComboboxItem,
+ ComboboxList,
+ ComboboxPopup,
+} from '@constructive-io/ui/combobox';
+import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@constructive-io/ui/tooltip';
+
+import { SinkCard } from './sink-card';
+
+const PEOPLE = [
+ { value: 'akim', label: 'Ana Kim', initials: 'AK' },
+ { value: 'jmeyer', label: 'Jonas Meyer', initials: 'JM' },
+ { value: 'rsingh', label: 'Riya Singh', initials: 'RS' },
+ { value: 'lpark', label: 'Lee Park', initials: 'LP' },
+];
+
+const WATCHERS = PEOPLE.slice(0, 3);
+
+export function AssignIssueCard() {
+ return (
+
+ Assign
+
+ Snooze
+
+ >
+ }
+ >
+
+
+
+ No people found.
+
+ {(person: (typeof PEOPLE)[number]) => (
+
+
+
+ {person.initials}
+
+ {person.label}
+
+
+ )}
+
+
+
+
+
Watchers
+
+
+ {WATCHERS.map((watcher) => (
+
+ }>
+ {watcher.initials}
+
+ {watcher.label}
+
+ ))}
+
+
+
+
+ );
+}
diff --git a/apps/blocks/src/components/theme-playground/canvas/cards/assistant-card.tsx b/apps/blocks/src/components/theme-playground/canvas/cards/assistant-card.tsx
new file mode 100644
index 00000000..6a2a8c85
--- /dev/null
+++ b/apps/blocks/src/components/theme-playground/canvas/cards/assistant-card.tsx
@@ -0,0 +1,34 @@
+import { CodeBlock, Message, MessageContent, PromptInput, PromptInputTextarea } from '@constructive-io/ui/ai';
+
+import { SinkCard } from './sink-card';
+
+const SNIPPET = `const { data } = await db
+ .from('orders')
+ .select('*');`;
+
+export function AssistantCard() {
+ return (
+
+
+ List this month's orders.
+
+
+
+
+ Query the orders table filtered by the current billing period:
+
+
+
+
+ {}} onSubmit={() => {}} className="rounded-md">
+
+
+
+ );
+}
diff --git a/apps/blocks/src/components/theme-playground/canvas/cards/audit-events-card.tsx b/apps/blocks/src/components/theme-playground/canvas/cards/audit-events-card.tsx
new file mode 100644
index 00000000..593158c7
--- /dev/null
+++ b/apps/blocks/src/components/theme-playground/canvas/cards/audit-events-card.tsx
@@ -0,0 +1,107 @@
+import { ChevronDown } from 'lucide-react';
+
+import { Avatar, AvatarFallback } from '@constructive-io/ui/avatar';
+import { Button } from '@constructive-io/ui/button';
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+} from '@constructive-io/ui/dropdown-menu';
+import {
+ Pagination,
+ PaginationContent,
+ PaginationItem,
+ PaginationLink,
+ PaginationNext,
+ PaginationPrevious,
+} from '@constructive-io/ui/pagination';
+import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@constructive-io/ui/table';
+
+import { SinkCard } from './sink-card';
+
+const EVENTS = [
+ { actor: 'AK', name: 'A. Kim', event: 'policy.updated', target: 'orders', time: '12:04' },
+ { actor: 'JM', name: 'J. Meyer', event: 'table.created', target: 'invoices', time: '11:52' },
+ { actor: 'RS', name: 'R. Singh', event: 'secret.rotated', target: 'stripe', time: '11:38' },
+ { actor: 'LP', name: 'L. Park', event: 'member.invited', target: 'ops team', time: '10:57' },
+ { actor: 'AK', name: 'A. Kim', event: 'deploy.promoted', target: 'main', time: '10:21' },
+ { actor: 'TW', name: 'T. Wolf', event: 'bucket.created', target: 'exports', time: '09:44' },
+ { actor: 'JM', name: 'J. Meyer', event: 'key.revoked', target: 'ci runner', time: '09:02' },
+ { actor: 'RS', name: 'R. Singh', event: 'schema.migrated', target: 'public', time: '08:30' },
+];
+
+export function AuditEventsCard() {
+ return (
+
+
+
+ Filter
+
+
+
+ All
+ Schema
+ Policy
+ Billing
+
+
+ }
+ contentClassName="px-0 pt-0"
+ footer={
+
+
+
+
+
+
+
+ 1
+
+
+
+ 2
+
+
+
+
+
+
+ }
+ >
+
+
+
+ Actor
+ Event
+ Target
+ Time
+
+
+
+ {EVENTS.map((row, index) => (
+
+
+
+
+ {row.actor}
+
+ {row.name}
+
+
+
+ {row.event}
+
+ {row.target}
+ {row.time}
+
+ ))}
+
+
+
+ );
+}
diff --git a/apps/blocks/src/components/theme-playground/canvas/cards/autoscaling-card.tsx b/apps/blocks/src/components/theme-playground/canvas/cards/autoscaling-card.tsx
new file mode 100644
index 00000000..3836aaa9
--- /dev/null
+++ b/apps/blocks/src/components/theme-playground/canvas/cards/autoscaling-card.tsx
@@ -0,0 +1,32 @@
+import { Button } from '@constructive-io/ui/button';
+import { Progress } from '@constructive-io/ui/progress';
+
+import { SinkCard } from './sink-card';
+
+export function AutoscalingCard() {
+ return (
+
+ Configure
+
+ Learn more
+
+ >
+ }
+ >
+
+ Compute scales to zero when idle and bursts up to your ceiling under load — you only pay for the vCPU-hours you
+ actually serve.
+
+
+
+ Current
+ 2 / 8 vCPU
+
+
+
+
+ );
+}
diff --git a/apps/blocks/src/components/theme-playground/canvas/cards/backup-destination-card.tsx b/apps/blocks/src/components/theme-playground/canvas/cards/backup-destination-card.tsx
new file mode 100644
index 00000000..de0ad10d
--- /dev/null
+++ b/apps/blocks/src/components/theme-playground/canvas/cards/backup-destination-card.tsx
@@ -0,0 +1,63 @@
+import { Cloud, Database, HardDrive } from 'lucide-react';
+
+import { Button } from '@constructive-io/ui/button';
+import { Field } from '@constructive-io/ui/field';
+import { Input } from '@constructive-io/ui/input';
+import { Item, ItemContent, ItemDescription, ItemMedia, ItemTitle } from '@constructive-io/ui/item';
+import { Radio, RadioGroup } from '@constructive-io/ui/radio-group';
+
+import { SinkCard } from './sink-card';
+
+const DESTINATIONS = [
+ { value: 's3', icon: Cloud, title: 'Amazon S3', description: 'Your own bucket, full control.' },
+ { value: 'gcs', icon: HardDrive, title: 'Google Cloud Storage', description: 'Bring your GCP bucket.' },
+ { value: 'managed', icon: Database, title: 'Constructive managed', description: 'We hold snapshots for 30 days.' },
+];
+
+export function BackupDestinationCard() {
+ return (
+
+
+ Test connection
+
+
+ Save
+
+ >
+ }
+ >
+
+ {DESTINATIONS.map((destination) => (
+ -
+
+
+
+
+ {destination.title}
+ {destination.description}
+
+
+
+ ))}
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/apps/blocks/src/components/theme-playground/canvas/cards/billing-address-card.tsx b/apps/blocks/src/components/theme-playground/canvas/cards/billing-address-card.tsx
new file mode 100644
index 00000000..be75d85b
--- /dev/null
+++ b/apps/blocks/src/components/theme-playground/canvas/cards/billing-address-card.tsx
@@ -0,0 +1,64 @@
+import { Button } from '@constructive-io/ui/button';
+import { Checkbox } from '@constructive-io/ui/checkbox';
+import { Field, FieldLabel } from '@constructive-io/ui/field';
+import { Input } from '@constructive-io/ui/input';
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@constructive-io/ui/select';
+
+import { SinkCard } from './sink-card';
+
+const COUNTRIES = {
+ de: 'Germany',
+ at: 'Austria',
+ ch: 'Switzerland',
+ us: 'United States',
+};
+
+export function BillingAddressCard() {
+ return (
+
+ Save address
+
+ }
+ >
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {Object.keys(COUNTRIES).map((id) => (
+
+ {id}
+
+ ))}
+
+
+
+
+
+
+ Same as legal address
+
+
+
+ );
+}
diff --git a/apps/blocks/src/components/theme-playground/canvas/cards/chat-column.tsx b/apps/blocks/src/components/theme-playground/canvas/cards/chat-column.tsx
new file mode 100644
index 00000000..8cdb83f2
--- /dev/null
+++ b/apps/blocks/src/components/theme-playground/canvas/cards/chat-column.tsx
@@ -0,0 +1,78 @@
+import type { ReactNode } from 'react';
+import { ArrowUp, Paperclip } from 'lucide-react';
+
+import {
+ ChatContainer,
+ ChatContainerContent,
+ PromptInput,
+ PromptInputAction,
+ PromptInputActions,
+ PromptInputBody,
+ PromptInputTextarea,
+ ScrollButton,
+} from '@constructive-io/ui/ai';
+import { Badge } from '@constructive-io/ui/badge';
+import { Button } from '@constructive-io/ui/button';
+
+import { SinkCard } from './sink-card';
+
+/**
+ * Full-height chat card for Wall 03 — header carries the model badge, the
+ * transcript scrolls inside the card, and the composer is pinned to the
+ * card's footer hairline.
+ */
+export function ChatColumn({
+ title,
+ model,
+ children,
+ prompt,
+}: {
+ title: string;
+ model: string;
+ children: ReactNode;
+ prompt?: ReactNode;
+}) {
+ return (
+ {model}}
+ className="flex h-[760px] flex-col overflow-hidden"
+ contentClassName="flex min-h-0 flex-1 flex-col p-0"
+ footer={prompt ?? }
+ >
+
+
+
+ {children}
+
+
+
+
+ );
+}
+
+/** Static composer — attach + send actions, optional slot above the input. */
+export function ChatPrompt({ placeholder, top }: { placeholder: string; top?: ReactNode }) {
+ return (
+
+ {top}
+
undefined}>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/apps/blocks/src/components/theme-playground/canvas/cards/client-share-card.tsx b/apps/blocks/src/components/theme-playground/canvas/cards/client-share-card.tsx
new file mode 100644
index 00000000..a913a330
--- /dev/null
+++ b/apps/blocks/src/components/theme-playground/canvas/cards/client-share-card.tsx
@@ -0,0 +1,54 @@
+import type { CSSProperties } from 'react';
+import { Badge } from '@constructive-io/ui/badge';
+import { Progress } from '@constructive-io/ui/progress';
+
+import { Donut } from '../charts/donut';
+import { SinkCard } from './sink-card';
+
+const CHANNELS = [
+ { name: 'GraphQL', pct: 46, color: 'var(--chart-1)' },
+ { name: 'REST', pct: 28, color: 'var(--chart-2)' },
+ { name: 'Realtime', pct: 18, color: 'var(--chart-3)' },
+ { name: 'CLI', pct: 8, color: 'var(--chart-4)' },
+];
+
+export function ClientShareCard() {
+ return (
+ +2.4%}
+ contentClassName="flex flex-col gap-4"
+ >
+
+
+
({ value: c.pct, color: c.color }))}
+ size={128}
+ label="Client share by channel"
+ />
+
+ 46%
+ GraphQL
+
+
+
+ {CHANNELS.map((channel) => (
+
+
+ {channel.name}
+ {channel.pct}%
+
+
+
+ ))}
+
+
+
+ );
+}
diff --git a/apps/blocks/src/components/theme-playground/canvas/cards/command-palette-card.tsx b/apps/blocks/src/components/theme-playground/canvas/cards/command-palette-card.tsx
new file mode 100644
index 00000000..e772706e
--- /dev/null
+++ b/apps/blocks/src/components/theme-playground/canvas/cards/command-palette-card.tsx
@@ -0,0 +1,51 @@
+import {
+ Command,
+ CommandEmpty,
+ CommandGroup,
+ CommandInput,
+ CommandItem,
+ CommandList,
+ CommandShortcut,
+} from '@constructive-io/ui/command';
+
+import { SinkCard } from './sink-card';
+
+const GROUPS = [
+ {
+ heading: 'Navigate',
+ items: [
+ { label: 'Go to dashboard', shortcut: '⌘D' },
+ { label: 'Open settings', shortcut: '⌘,' },
+ ],
+ },
+ {
+ heading: 'Actions',
+ items: [
+ { label: 'Create branch', shortcut: '⌘B' },
+ { label: 'Invite teammate', shortcut: '⌘I' },
+ ],
+ },
+];
+
+export function CommandPaletteCard() {
+ return (
+
+
+
+
+ No results found.
+ {GROUPS.map((group) => (
+
+ {group.items.map((item) => (
+
+ {item.label}
+ {item.shortcut}
+
+ ))}
+
+ ))}
+
+
+
+ );
+}
diff --git a/apps/blocks/src/components/theme-playground/canvas/cards/compute-balance-card.tsx b/apps/blocks/src/components/theme-playground/canvas/cards/compute-balance-card.tsx
new file mode 100644
index 00000000..1b1fbcd6
--- /dev/null
+++ b/apps/blocks/src/components/theme-playground/canvas/cards/compute-balance-card.tsx
@@ -0,0 +1,33 @@
+import { Badge } from '@constructive-io/ui/badge';
+import { Button } from '@constructive-io/ui/button';
+
+import { Area } from '../charts/area';
+import { SinkCard } from './sink-card';
+
+const TREND = [62, 70, 66, 78, 74, 88, 84, 96, 91, 100];
+
+export function ComputeBalanceCard() {
+ return (
+
+
+
Compute balance
+
+
1,240
+
credits
+
+ +120 this week
+
+
+
+
+
+
+ Top up
+
+
+ Auto-refill
+
+
+
+ );
+}
diff --git a/apps/blocks/src/components/theme-playground/canvas/cards/compute-spend-card.tsx b/apps/blocks/src/components/theme-playground/canvas/cards/compute-spend-card.tsx
new file mode 100644
index 00000000..8abf46ad
--- /dev/null
+++ b/apps/blocks/src/components/theme-playground/canvas/cards/compute-spend-card.tsx
@@ -0,0 +1,49 @@
+import { Button } from '@constructive-io/ui/button';
+import { Item, ItemContent, ItemDescription, ItemTitle } from '@constructive-io/ui/item';
+
+import { Bars } from '../charts/bars';
+import { SinkCard } from './sink-card';
+
+const MONTHS = [
+ { label: 'Dec', value: 812 },
+ { label: 'Jan', value: 1104 },
+ { label: 'Feb', value: 938 },
+ { label: 'Mar', value: 1287 },
+ { label: 'Apr', value: 764 },
+ { label: 'May', value: 1410 },
+];
+
+export function ComputeSpendCard() {
+ return (
+ View full report}
+ >
+ m.value)}
+ labels={MONTHS.map((m) => m.label)}
+ color="var(--chart-2)"
+ highlightLast
+ className="h-24"
+ label="Monthly compute spend, December through May"
+ />
+
+ -
+
+ Next invoice
+ Jun 1, 2026
+ $1,240 estimated
+
+
+ -
+
+ Autoscale plan
+ Burst
+ Scales to 8 vCPU
+
+
+
+
+ );
+}
diff --git a/apps/blocks/src/components/theme-playground/canvas/cards/compute-usage-card.tsx b/apps/blocks/src/components/theme-playground/canvas/cards/compute-usage-card.tsx
new file mode 100644
index 00000000..bcb04419
--- /dev/null
+++ b/apps/blocks/src/components/theme-playground/canvas/cards/compute-usage-card.tsx
@@ -0,0 +1,47 @@
+import type { CSSProperties } from 'react';
+import { Progress } from '@constructive-io/ui/progress';
+import { Separator } from '@constructive-io/ui/separator';
+
+import { RadialBars } from '../charts/radial-bars';
+import { SinkCard } from './sink-card';
+
+const USAGE = [
+ { name: 'API', pct: 62, color: 'var(--chart-1)' },
+ { name: 'Workers', pct: 38, color: 'var(--chart-2)' },
+ { name: 'Cron', pct: 12, color: 'var(--chart-3)' },
+];
+
+export function ComputeUsageCard() {
+ return (
+
+
+
({ value: u.pct, max: 100, color: u.color }))}
+ size={128}
+ label="Compute usage by service"
+ />
+
+ {USAGE.map((u) => (
+
+
+ {u.name}
+ {u.pct}%
+
+
+
+ ))}
+
+
+
+
+ Total
+ 3.4 vCPU-hours
+
+
+ );
+}
diff --git a/apps/blocks/src/components/theme-playground/canvas/cards/connect-cli-card.tsx b/apps/blocks/src/components/theme-playground/canvas/cards/connect-cli-card.tsx
new file mode 100644
index 00000000..fdb16ce1
--- /dev/null
+++ b/apps/blocks/src/components/theme-playground/canvas/cards/connect-cli-card.tsx
@@ -0,0 +1,85 @@
+'use client';
+
+import { useEffect, useState } from 'react';
+import { Check, Copy } from 'lucide-react';
+
+import { Button } from '@constructive-io/ui/button';
+
+import { SinkCard } from './sink-card';
+
+const GRID = 21;
+
+/** Deterministic pseudo-random fill for the QR-like tile. */
+function seeded(row: number, col: number): boolean {
+ const x = Math.sin(row * 127.1 + col * 311.7) * 43758.5453;
+ return x - Math.floor(x) > 0.5;
+}
+
+/** Inside a 7×7 finder square (plus its 1-cell separator) at a corner? */
+function finderFill(row: number, col: number): boolean | null {
+ for (const [r0, c0] of [
+ [0, 0],
+ [0, GRID - 7],
+ [GRID - 7, 0],
+ ] as const) {
+ const dr = row - r0;
+ const dc = col - c0;
+ if (dr >= -1 && dr <= 7 && dc >= -1 && dc <= 7) {
+ if (dr === -1 || dr === 7 || dc === -1 || dc === 7) return false;
+ return dr === 0 || dr === 6 || dc === 0 || dc === 6 || (dr >= 2 && dr <= 4 && dc >= 2 && dc <= 4);
+ }
+ }
+ return null;
+}
+
+function QrTile() {
+ return (
+
+ {Array.from({ length: GRID * GRID }, (_, index) => {
+ const row = Math.floor(index / GRID);
+ const col = index % GRID;
+ const filled = finderFill(row, col) ?? seeded(row, col);
+ return
;
+ })}
+
+ );
+}
+
+export function ConnectCliCard() {
+ const [copied, setCopied] = useState(false);
+
+ useEffect(() => {
+ if (!copied) return;
+ const timeout = window.setTimeout(() => setCopied(false), 1600);
+ return () => window.clearTimeout(timeout);
+ }, [copied]);
+
+ return (
+
+ setCopied(true)}>
+ {copied ? : }
+ {copied ? 'Copied' : 'Copy code'}
+
+ Open docs
+ >
+ }
+ >
+
+
+
+
QK7-3M9P
+
Expires in 09:41
+
+ npx constructive login --device
+
+
+
+
+ );
+}
diff --git a/apps/blocks/src/components/theme-playground/canvas/cards/connect-payment-empty-card.tsx b/apps/blocks/src/components/theme-playground/canvas/cards/connect-payment-empty-card.tsx
new file mode 100644
index 00000000..129a5cc6
--- /dev/null
+++ b/apps/blocks/src/components/theme-playground/canvas/cards/connect-payment-empty-card.tsx
@@ -0,0 +1,28 @@
+import { CreditCard } from 'lucide-react';
+
+import { Button } from '@constructive-io/ui/button';
+import { Empty, EmptyContent, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from '@constructive-io/ui/empty';
+
+import { SinkCard } from './sink-card';
+
+export function ConnectPaymentEmptyCard() {
+ return (
+
+
+
+
+
+
+ Connect a payment method
+ Add a card to move past the free tier.
+
+
+ Add card
+
+ Learn about billing
+
+
+
+
+ );
+}
diff --git a/apps/blocks/src/components/theme-playground/canvas/cards/connection-pool-card.tsx b/apps/blocks/src/components/theme-playground/canvas/cards/connection-pool-card.tsx
new file mode 100644
index 00000000..95cddf95
--- /dev/null
+++ b/apps/blocks/src/components/theme-playground/canvas/cards/connection-pool-card.tsx
@@ -0,0 +1,85 @@
+'use client';
+
+import { useState } from 'react';
+import { Gauge, Hourglass, Timer, Waves } from 'lucide-react';
+
+import { Item, ItemActions, ItemContent, ItemGroup, ItemMedia, ItemTitle } from '@constructive-io/ui/item';
+import { Slider } from '@constructive-io/ui/slider';
+import { Switch } from '@constructive-io/ui/switch';
+import { ToggleGroup, ToggleGroupItem } from '@constructive-io/ui/toggle-group';
+
+import { SinkCard } from './sink-card';
+
+type Scene = 'balanced' | 'burst' | 'night' | 'maintenance';
+
+const SCENES: Record = {
+ balanced: [40, 30, 60, 45],
+ burst: [80, 15, 30, 20],
+ night: [20, 60, 90, 60],
+ maintenance: [10, 5, 15, 10],
+};
+
+const CONTROLS = [
+ { key: 'pool', title: 'Pool size', icon: Waves },
+ { key: 'idle', title: 'Idle timeout', icon: Hourglass },
+ { key: 'statement', title: 'Statement timeout', icon: Timer },
+ { key: 'lifetime', title: 'Max lifetime', icon: Gauge },
+] as const;
+
+export function ConnectionPoolCard() {
+ const [enabled, setEnabled] = useState(true);
+ const [scene, setScene] = useState('balanced');
+ const [values, setValues] = useState(SCENES.balanced);
+
+ return (
+ setEnabled(v === true)} aria-label="Enable connection pool" />
+ }
+ contentClassName="flex flex-col gap-4"
+ >
+ {
+ const id = next[0] as Scene | undefined;
+ if (!id) return;
+ setScene(id);
+ setValues(SCENES[id]);
+ }}
+ variant="outline"
+ spacing={1}
+ disabled={!enabled}
+ className="flex-wrap"
+ aria-label="Pool scene"
+ >
+ Balanced
+ Burst
+ Night
+ Maintenance
+
+
+ {CONTROLS.map((control, index) => (
+ -
+
+
+
+
+ {control.title}
+
+
+ setValues((prev) => prev.map((p, i) => (i === index ? (v as number) : p)))}
+ disabled={!enabled}
+ aria-label={control.title}
+ className="w-full"
+ />
+
+
+ ))}
+
+
+ );
+}
diff --git a/apps/blocks/src/components/theme-playground/canvas/cards/create-alert-rule-card.tsx b/apps/blocks/src/components/theme-playground/canvas/cards/create-alert-rule-card.tsx
new file mode 100644
index 00000000..5f0f402d
--- /dev/null
+++ b/apps/blocks/src/components/theme-playground/canvas/cards/create-alert-rule-card.tsx
@@ -0,0 +1,62 @@
+import { Button } from '@constructive-io/ui/button';
+import { Field } from '@constructive-io/ui/field';
+import { Input } from '@constructive-io/ui/input';
+import { InputGroup, InputGroupAddon, InputGroupInput, InputGroupText } from '@constructive-io/ui/input-group';
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@constructive-io/ui/select';
+
+import { SinkCard } from './sink-card';
+
+const METRIC_ITEMS = {
+ 'p95-latency': 'p95 latency',
+ 'error-rate': 'Error rate',
+ 'cpu-usage': 'CPU usage',
+ 'disk-free': 'Disk free',
+};
+
+export function CreateAlertRuleCard() {
+ return (
+
+
+ Cancel
+
+
+ Create rule
+
+ >
+ }
+ >
+
+
+
+
+
+
+
+
+
+ {Object.entries(METRIC_ITEMS).map(([id, label]) => (
+
+ {label}
+
+ ))}
+
+
+
+
+
+
+
+ ms
+
+
+
+
+
+
+
+ );
+}
diff --git a/apps/blocks/src/components/theme-playground/canvas/cards/create-table-card.tsx b/apps/blocks/src/components/theme-playground/canvas/cards/create-table-card.tsx
new file mode 100644
index 00000000..6afde723
--- /dev/null
+++ b/apps/blocks/src/components/theme-playground/canvas/cards/create-table-card.tsx
@@ -0,0 +1,40 @@
+import { Button } from '@constructive-io/ui/button';
+import { Checkbox } from '@constructive-io/ui/checkbox';
+import { CheckboxGroup } from '@constructive-io/ui/checkbox-group';
+import { Field } from '@constructive-io/ui/field';
+import { Input } from '@constructive-io/ui/input';
+import { Label } from '@constructive-io/ui/label';
+
+import { SinkCard } from './sink-card';
+
+const OPTIONS = [
+ { id: 'sink-rls', label: 'Enable row-level security', checked: true },
+ { id: 'sink-api', label: 'Generate API surface', checked: true },
+ { id: 'sink-audit', label: 'Track row history', checked: false },
+];
+
+export function CreateTableCard() {
+ return (
+
+
+
+
+
+ {OPTIONS.map((option) => (
+
+
+ {option.label}
+
+ ))}
+
+
+
+ Create
+
+
+ Cancel
+
+
+
+ );
+}
diff --git a/apps/blocks/src/components/theme-playground/canvas/cards/credits-balance-card.tsx b/apps/blocks/src/components/theme-playground/canvas/cards/credits-balance-card.tsx
new file mode 100644
index 00000000..85517945
--- /dev/null
+++ b/apps/blocks/src/components/theme-playground/canvas/cards/credits-balance-card.tsx
@@ -0,0 +1,41 @@
+import { Badge } from '@constructive-io/ui/badge';
+import { Item, ItemContent, ItemTitle } from '@constructive-io/ui/item';
+import { Separator } from '@constructive-io/ui/separator';
+
+import { SinkCard } from './sink-card';
+
+function CreditRow({ label, value, strong }: { label: string; value: string; strong?: boolean }) {
+ return (
+
+ {label}
+ {value}
+
+ );
+}
+
+export function CreditsBalanceCard() {
+ return (
+ Credits apply automatically before card charges on the 1st.
+ }
+ >
+
+ Available credits
+ -
+
+ Credit breakdown
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/apps/blocks/src/components/theme-playground/canvas/cards/database-status-card.tsx b/apps/blocks/src/components/theme-playground/canvas/cards/database-status-card.tsx
new file mode 100644
index 00000000..9edc8874
--- /dev/null
+++ b/apps/blocks/src/components/theme-playground/canvas/cards/database-status-card.tsx
@@ -0,0 +1,42 @@
+import { Label } from '@constructive-io/ui/label';
+import { Progress } from '@constructive-io/ui/progress';
+import { Switch } from '@constructive-io/ui/switch';
+
+import { SinkCard } from './sink-card';
+
+const TOGGLES = [
+ { id: 'sink-auto-backups', label: 'Auto backups', checked: true },
+ { id: 'sink-pitr', label: 'Point-in-time recovery', checked: false },
+];
+
+export function DatabaseStatusCard() {
+ return (
+
+
+
+ Disk usage
+ 6.8 / 10 GB
+
+
+
+ {TOGGLES.map((toggle) => (
+
+
+ {toggle.label}
+
+
+
+ ))}
+
+
+
Connections
+ 42
+
+
+
Replication
+ Healthy
+
+
+
+ );
+}
diff --git a/apps/blocks/src/components/theme-playground/canvas/cards/deploy-load-card.tsx b/apps/blocks/src/components/theme-playground/canvas/cards/deploy-load-card.tsx
new file mode 100644
index 00000000..536ba7dc
--- /dev/null
+++ b/apps/blocks/src/components/theme-playground/canvas/cards/deploy-load-card.tsx
@@ -0,0 +1,44 @@
+import { Button } from '@constructive-io/ui/button';
+
+import { StackedBars } from '../charts/stacked-bars';
+import { SinkCard } from './sink-card';
+
+const DEPLOYS = [4, 6, 5, 8, 7, 3, 5];
+const BUILD_MINUTES = [18, 26, 22, 34, 30, 12, 20];
+const LABELS = ['M', 'T', 'W', 'T', 'F', 'S', 'S'];
+
+export function DeployLoadCard() {
+ return (
+
+ Open pipeline
+
+ }
+ >
+ m / 5) },
+ ]}
+ labels={LABELS}
+ className="h-28"
+ label="Deploys and scaled build minutes per day"
+ />
+
+
+
+ Deploys
+
+
+
+ Build min
+
+ 38 deploys · 214 min
+
+
+ );
+}
diff --git a/apps/blocks/src/components/theme-playground/canvas/cards/deployments-card.tsx b/apps/blocks/src/components/theme-playground/canvas/cards/deployments-card.tsx
new file mode 100644
index 00000000..08a3dc70
--- /dev/null
+++ b/apps/blocks/src/components/theme-playground/canvas/cards/deployments-card.tsx
@@ -0,0 +1,73 @@
+import { GitBranch } from 'lucide-react';
+
+import { Badge } from '@constructive-io/ui/badge';
+import {
+ Pagination,
+ PaginationContent,
+ PaginationItem,
+ PaginationLink,
+ PaginationNext,
+ PaginationPrevious,
+} from '@constructive-io/ui/pagination';
+import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@constructive-io/ui/table';
+
+import { SinkCard } from './sink-card';
+
+const DEPLOYS = [
+ { id: 'dep_9f2c1', branch: 'main', status: 'Ready', variant: 'success', ago: '4m' },
+ { id: 'dep_8a17d', branch: 'feat/billing', status: 'Building', variant: 'warning', ago: '9m' },
+ { id: 'dep_77b3e', branch: 'main', status: 'Ready', variant: 'success', ago: '1h' },
+ { id: 'dep_6d50a', branch: 'fix/auth-redirect', status: 'Failed', variant: 'error', ago: '3h' },
+ { id: 'dep_5c02f', branch: 'main', status: 'Ready', variant: 'success', ago: '1d' },
+] as const;
+
+export function DeploymentsCard() {
+ return (
+ prod} contentClassName="px-0 pb-2 pt-0">
+
+
+
+ Deploy
+ Branch
+ Status
+ Age
+
+
+
+ {DEPLOYS.map((deploy) => (
+
+ {deploy.id}
+
+
+
+ {deploy.branch}
+
+
+
+ {deploy.status}
+
+ {deploy.ago}
+
+ ))}
+
+
+
+
+
+
+
+ {[1, 2, 3].map((page) => (
+
+
+ {page}
+
+
+ ))}
+
+
+
+
+
+
+ );
+}
diff --git a/apps/blocks/src/components/theme-playground/canvas/cards/digest-settings-card.tsx b/apps/blocks/src/components/theme-playground/canvas/cards/digest-settings-card.tsx
new file mode 100644
index 00000000..72aa0e27
--- /dev/null
+++ b/apps/blocks/src/components/theme-playground/canvas/cards/digest-settings-card.tsx
@@ -0,0 +1,46 @@
+import { Badge } from '@constructive-io/ui/badge';
+import { Button } from '@constructive-io/ui/button';
+import { Checkbox } from '@constructive-io/ui/checkbox';
+import { Field, FieldContent, FieldDescription, FieldLabel } from '@constructive-io/ui/field';
+
+import { SinkCard } from './sink-card';
+
+const DIGESTS = [
+ { id: 'deploy-summaries', title: 'Deploy summaries', description: 'A digest after each deploy.', on: true },
+ { id: 'weekly-usage', title: 'Weekly usage report', description: 'Credits and compute, Mondays.', on: true },
+ {
+ id: 'security-advisories',
+ title: 'Security advisories',
+ description: 'Cannot be disabled.',
+ on: true,
+ disabled: true,
+ },
+ { id: 'product-updates', title: 'Product updates', description: 'New blocks and features.', on: false },
+];
+
+export function DigestSettingsCard() {
+ return (
+
+ Save
+
+ }
+ >
+ {DIGESTS.map((digest) => (
+
+
+
+
+ {digest.title}
+ {digest.disabled ? Required : null}
+
+ {digest.description}
+
+
+ ))}
+
+ );
+}
diff --git a/apps/blocks/src/components/theme-playground/canvas/cards/dock-card.tsx b/apps/blocks/src/components/theme-playground/canvas/cards/dock-card.tsx
new file mode 100644
index 00000000..0b014dbf
--- /dev/null
+++ b/apps/blocks/src/components/theme-playground/canvas/cards/dock-card.tsx
@@ -0,0 +1,28 @@
+import { Folder, Mail, Search, Settings, type LucideIcon } from 'lucide-react';
+
+import { Dock, DockIcon } from '@constructive-io/ui/dock';
+
+import { SinkCard } from './sink-card';
+
+const ITEMS: { label: string; icon: LucideIcon }[] = [
+ { label: 'Search', icon: Search },
+ { label: 'Files', icon: Folder },
+ { label: 'Mail', icon: Mail },
+ { label: 'Settings', icon: Settings },
+];
+
+export function DockCard() {
+ return (
+
+
+
+ {ITEMS.map((item) => (
+
+
+
+ ))}
+
+
+
+ );
+}
diff --git a/apps/blocks/src/components/theme-playground/canvas/cards/egress-quarter-card.tsx b/apps/blocks/src/components/theme-playground/canvas/cards/egress-quarter-card.tsx
new file mode 100644
index 00000000..f105e328
--- /dev/null
+++ b/apps/blocks/src/components/theme-playground/canvas/cards/egress-quarter-card.tsx
@@ -0,0 +1,34 @@
+import { Badge } from '@constructive-io/ui/badge';
+import { Button } from '@constructive-io/ui/button';
+
+import { Area } from '../charts/area';
+import { SinkCard } from './sink-card';
+
+const POINTS = [42, 55, 48, 61, 58, 74, 66, 79, 71, 84, 78, 90, 86];
+
+export function EgressQuarterCard() {
+ return (
+
+ Breakdown by region
+
+ }
+ >
+
+
+
+ Included
+ 2 TB
+
+
+ Overage rate
+ $0.09/GB
+
+
+ );
+}
diff --git a/apps/blocks/src/components/theme-playground/canvas/cards/environments-card.tsx b/apps/blocks/src/components/theme-playground/canvas/cards/environments-card.tsx
new file mode 100644
index 00000000..3c6e71ad
--- /dev/null
+++ b/apps/blocks/src/components/theme-playground/canvas/cards/environments-card.tsx
@@ -0,0 +1,114 @@
+import { Container, MoreHorizontal, Search } from 'lucide-react';
+
+import { Badge } from '@constructive-io/ui/badge';
+import { Button } from '@constructive-io/ui/button';
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+} from '@constructive-io/ui/dropdown-menu';
+import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from '@constructive-io/ui/empty';
+import { InputGroup, InputGroupAddon, InputGroupInput } from '@constructive-io/ui/input-group';
+import {
+ Item,
+ ItemActions,
+ ItemContent,
+ ItemDescription,
+ ItemGroup,
+ ItemMedia,
+ ItemTitle,
+} from '@constructive-io/ui/item';
+import { Separator } from '@constructive-io/ui/separator';
+import { Spinner } from '@constructive-io/ui/spinner';
+import { Tabs, TabsContent, TabsList, TabsTrigger } from '@constructive-io/ui/tabs';
+import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@constructive-io/ui/tooltip';
+
+import { SinkCard } from './sink-card';
+
+const ENVIRONMENTS = [
+ { name: 'acme-prod', meta: 'main · eu-central-1', status: 'running' },
+ { name: 'acme-staging', meta: 'feat/billing · us-east-1', status: 'running' },
+ { name: 'acme-preview', meta: 'pr-412 · eu-central-1', status: 'provisioning' },
+];
+
+export function EnvironmentsCard() {
+ return (
+
+
+
+
+ Active
+ Archived
+
+
+ New environment
+
+
+
+
+
+
+
+
+
+
+
+
+ {ENVIRONMENTS.map((env) => (
+ -
+
+
+
+
+
+ {env.name}
+
+ {env.meta}
+
+
+ {env.status === 'provisioning' ? (
+
+
+ Provisioning
+
+ ) : (
+ Running
+ )}
+
+
+ }
+ />
+ }
+ >
+
+
+ Environment actions
+
+
+ View logs
+ Restart
+ Archive
+
+
+
+
+ ))}
+
+
+
+
+
+
+ No archived environments
+ Archived environments keep their snapshots for 30 days.
+
+
+
+
+
+ );
+}
diff --git a/apps/blocks/src/components/theme-playground/canvas/cards/explore-catalog-empty-card.tsx b/apps/blocks/src/components/theme-playground/canvas/cards/explore-catalog-empty-card.tsx
new file mode 100644
index 00000000..98163878
--- /dev/null
+++ b/apps/blocks/src/components/theme-playground/canvas/cards/explore-catalog-empty-card.tsx
@@ -0,0 +1,29 @@
+import { Compass } from 'lucide-react';
+
+import { Button } from '@constructive-io/ui/button';
+import { Empty, EmptyContent, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from '@constructive-io/ui/empty';
+
+import { SinkCard } from './sink-card';
+
+export function ExploreCatalogEmptyCard() {
+ return (
+
+
+
+
+
+
+ Explore the block catalog
+
+ Install ready-made blocks — org charts, billing, storage — from the registry.
+
+
+
+
+ Browse blocks
+
+
+
+
+ );
+}
diff --git a/apps/blocks/src/components/theme-playground/canvas/cards/faq-card.tsx b/apps/blocks/src/components/theme-playground/canvas/cards/faq-card.tsx
new file mode 100644
index 00000000..5e0e9ab5
--- /dev/null
+++ b/apps/blocks/src/components/theme-playground/canvas/cards/faq-card.tsx
@@ -0,0 +1,62 @@
+import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@constructive-io/ui/accordion';
+import { Button } from '@constructive-io/ui/button';
+import { Tabs, TabsContent, TabsList, TabsTrigger } from '@constructive-io/ui/tabs';
+
+import { SinkCard } from './sink-card';
+
+const FAQS: Record = {
+ general: [
+ ['What counts as a compute hour?', 'One vCPU running for one hour, billed per second.'],
+ ['Can I pause a project?', 'Yes — pausing stops compute billing while storage stays reserved.'],
+ ['Where are my backups?', 'Nightly snapshots land in your configured destination.'],
+ ],
+ billing: [
+ ['How do read replicas bill?', 'Each replica is billed at ~$48/mo, prorated by the minute.'],
+ ['When do credits expire?', 'Purchased credits expire 12 months after the invoice date.'],
+ ['Can I get an invoice copy?', 'Invoices are emailed monthly and archived under Billing.'],
+ ],
+ security: [
+ ['Do you support SSO?', 'SAML and OIDC are available on the Scale plan.'],
+ ['How are secrets stored?', 'Secrets are encrypted at rest and rotated on deploy.'],
+ ['Can I restrict API keys?', 'Yes — scope keys per project and per role from Access.'],
+ ],
+};
+
+export function FaqCard() {
+ return (
+
+ Still stuck? Contact support
+
+ }
+ >
+
+
+
+ General
+
+
+ Billing
+
+
+ Security
+
+
+ {Object.entries(FAQS).map(([key, items]) => (
+
+
+ {items.map(([q, a], index) => (
+
+ {q}
+ {a}
+
+ ))}
+
+
+ ))}
+
+
+ );
+}
diff --git a/apps/blocks/src/components/theme-playground/canvas/cards/feature-pack-catalog-card.tsx b/apps/blocks/src/components/theme-playground/canvas/cards/feature-pack-catalog-card.tsx
new file mode 100644
index 00000000..0b16b445
--- /dev/null
+++ b/apps/blocks/src/components/theme-playground/canvas/cards/feature-pack-catalog-card.tsx
@@ -0,0 +1,72 @@
+import {
+ BellRing,
+ Building2,
+ CreditCard,
+ HardDrive,
+ KeyRound,
+ LayoutGrid,
+ List,
+ ScrollText,
+ Search,
+} from 'lucide-react';
+
+import { Badge } from '@constructive-io/ui/badge';
+import { InputGroup, InputGroupAddon, InputGroupInput } from '@constructive-io/ui/input-group';
+import { ToggleGroup, ToggleGroupItem } from '@constructive-io/ui/toggle-group';
+
+import { SinkCard } from './sink-card';
+
+const PACKS = [
+ { name: 'Organizations', version: 'v2.4', icon: Building2, tone: 'var(--chart-1)' },
+ { name: 'Billing', version: 'v1.9', icon: CreditCard, tone: 'var(--chart-2)' },
+ { name: 'Storage', version: 'v3.1', icon: HardDrive, tone: 'var(--chart-3)' },
+ { name: 'Audit log', version: 'v0.8', icon: ScrollText, tone: 'var(--chart-4)' },
+ { name: 'Auth', version: 'v2.0', icon: KeyRound, tone: 'var(--chart-5)' },
+ { name: 'Notifications', version: 'v1.2', icon: BellRing, tone: 'var(--chart-2)' },
+];
+
+function PackTile({ pack }: { pack: (typeof PACKS)[number] }) {
+ return (
+
+
+
{pack.name}
+
+ {pack.version}
+
+
+ );
+}
+
+export function FeaturePackCatalogCard() {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {PACKS.map((pack) => (
+
+ ))}
+
+
+ );
+}
diff --git a/apps/blocks/src/components/theme-playground/canvas/cards/feedback-card.tsx b/apps/blocks/src/components/theme-playground/canvas/cards/feedback-card.tsx
new file mode 100644
index 00000000..d5b2cd9b
--- /dev/null
+++ b/apps/blocks/src/components/theme-playground/canvas/cards/feedback-card.tsx
@@ -0,0 +1,60 @@
+import { Button } from '@constructive-io/ui/button';
+import { Field } from '@constructive-io/ui/field';
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@constructive-io/ui/select';
+import { Textarea } from '@constructive-io/ui/textarea';
+import { ToggleGroup, ToggleGroupItem } from '@constructive-io/ui/toggle-group';
+
+import { SinkCard } from './sink-card';
+
+const TOPICS = {
+ onboarding: 'Onboarding',
+ docs: 'Documentation',
+ performance: 'Performance',
+ other: 'Something else',
+};
+
+const RATINGS = ['1', '2', '3', '4', '5'];
+
+export function FeedbackCard() {
+ return (
+
+ Send feedback
+
+ }
+ >
+
+
Feedback
+
How was your onboarding?
+
+
+ {RATINGS.map((rating) => (
+
+ {rating}
+
+ ))}
+
+
+
+
+
+
+
+ {Object.keys(TOPICS).map((id) => (
+
+ {id}
+
+ ))}
+
+
+
+
+
+ );
+}
diff --git a/apps/blocks/src/components/theme-playground/canvas/cards/files-chat-card.tsx b/apps/blocks/src/components/theme-playground/canvas/cards/files-chat-card.tsx
new file mode 100644
index 00000000..bd2a303d
--- /dev/null
+++ b/apps/blocks/src/components/theme-playground/canvas/cards/files-chat-card.tsx
@@ -0,0 +1,60 @@
+import { ContextCard, ContextCards, FileUpload, InlineDiff, Message, MessageContent } from '@constructive-io/ui/ai';
+
+import { ChatColumn, ChatPrompt } from './chat-column';
+
+export function FilesChatCard() {
+ return (
+ }
+ />
+ }
+ >
+
+
+
+
+
+
+ Reconcile this export with the live schema.
+
+
+
+
+
+ {`The export is missing \`orders.status\` — rows default to \`'pending'\`. Proposed migration:`}
+
+
+
+
+
+ );
+}
diff --git a/apps/blocks/src/components/theme-playground/canvas/cards/icon-grid-card.tsx b/apps/blocks/src/components/theme-playground/canvas/cards/icon-grid-card.tsx
new file mode 100644
index 00000000..d08b4bb5
--- /dev/null
+++ b/apps/blocks/src/components/theme-playground/canvas/cards/icon-grid-card.tsx
@@ -0,0 +1,108 @@
+import {
+ ArrowDownToLine,
+ Bell,
+ BookOpen,
+ Box,
+ Braces,
+ Calendar,
+ Check,
+ ChevronRight,
+ Clipboard,
+ Cloud,
+ Code,
+ Copy,
+ Cpu,
+ CreditCard,
+ Database,
+ Download,
+ Eye,
+ FileText,
+ Filter,
+ Flag,
+ Folder,
+ GitBranch,
+ Globe,
+ HardDrive,
+ KeyRound,
+ Layers,
+ Link,
+ Lock,
+ Mail,
+ MapPin,
+ Package,
+ Play,
+ Search,
+ Settings,
+ Shield,
+ Star,
+ Terminal,
+ Trash2,
+ Upload,
+ Zap,
+ type LucideIcon,
+} from 'lucide-react';
+
+import { SinkCard } from './sink-card';
+
+const ICONS: { name: string; icon: LucideIcon }[] = [
+ { name: 'Search', icon: Search },
+ { name: 'Settings', icon: Settings },
+ { name: 'Bell', icon: Bell },
+ { name: 'Star', icon: Star },
+ { name: 'Download', icon: Download },
+ { name: 'Upload', icon: Upload },
+ { name: 'Trash2', icon: Trash2 },
+ { name: 'Copy', icon: Copy },
+ { name: 'Check', icon: Check },
+ { name: 'ChevronRight', icon: ChevronRight },
+ { name: 'Database', icon: Database },
+ { name: 'Cloud', icon: Cloud },
+ { name: 'Lock', icon: Lock },
+ { name: 'KeyRound', icon: KeyRound },
+ { name: 'Globe', icon: Globe },
+ { name: 'Mail', icon: Mail },
+ { name: 'Calendar', icon: Calendar },
+ { name: 'FileText', icon: FileText },
+ { name: 'Folder', icon: Folder },
+ { name: 'Terminal', icon: Terminal },
+ { name: 'Code', icon: Code },
+ { name: 'Braces', icon: Braces },
+ { name: 'GitBranch', icon: GitBranch },
+ { name: 'Zap', icon: Zap },
+ { name: 'Cpu', icon: Cpu },
+ { name: 'HardDrive', icon: HardDrive },
+ { name: 'Layers', icon: Layers },
+ { name: 'Box', icon: Box },
+ { name: 'Package', icon: Package },
+ { name: 'Link', icon: Link },
+ { name: 'Eye', icon: Eye },
+ { name: 'Flag', icon: Flag },
+ { name: 'MapPin', icon: MapPin },
+ { name: 'CreditCard', icon: CreditCard },
+ { name: 'Shield', icon: Shield },
+ { name: 'Play', icon: Play },
+ { name: 'Filter', icon: Filter },
+ { name: 'Clipboard', icon: Clipboard },
+ { name: 'BookOpen', icon: BookOpen },
+ { name: 'ArrowDownToLine', icon: ArrowDownToLine },
+];
+
+export function IconGridCard() {
+ return (
+
+ Lucide · 40 of 1,500
+
+ {ICONS.map(({ name, icon: Icon }) => (
+
+
+
+ ))}
+
+
+ );
+}
diff --git a/apps/blocks/src/components/theme-playground/canvas/cards/invite-teammate-card.tsx b/apps/blocks/src/components/theme-playground/canvas/cards/invite-teammate-card.tsx
new file mode 100644
index 00000000..594e2f39
--- /dev/null
+++ b/apps/blocks/src/components/theme-playground/canvas/cards/invite-teammate-card.tsx
@@ -0,0 +1,38 @@
+import { Button } from '@constructive-io/ui/button';
+import { Field } from '@constructive-io/ui/field';
+import { Input } from '@constructive-io/ui/input';
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@constructive-io/ui/select';
+
+import { SinkCard } from './sink-card';
+
+const ROLES = {
+ owner: 'Owner',
+ admin: 'Admin',
+ editor: 'Editor',
+ viewer: 'Viewer',
+};
+
+export function InviteTeammateCard() {
+ return (
+
+
+
+
+
+
+
+
+
+
+ {Object.keys(ROLES).map((value) => (
+
+ {value}
+
+ ))}
+
+
+
+ Send invite
+
+ );
+}
diff --git a/apps/blocks/src/components/theme-playground/canvas/cards/invoice-card.tsx b/apps/blocks/src/components/theme-playground/canvas/cards/invoice-card.tsx
new file mode 100644
index 00000000..101980c7
--- /dev/null
+++ b/apps/blocks/src/components/theme-playground/canvas/cards/invoice-card.tsx
@@ -0,0 +1,69 @@
+import { Badge } from '@constructive-io/ui/badge';
+import { Button } from '@constructive-io/ui/button';
+import { Separator } from '@constructive-io/ui/separator';
+import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@constructive-io/ui/table';
+
+import { SinkCard } from './sink-card';
+
+const LINES = [
+ { item: 'Compute (Burst)', qty: '1', amount: '$640.00' },
+ { item: 'Storage', qty: '30 GB', amount: '$6.00' },
+ { item: 'Egress', qty: '1.84 TB', amount: '$166.00' },
+ { item: 'Read replica', qty: '×2', amount: '$96.00' },
+];
+
+const TOTALS = [
+ { label: 'Subtotal', value: '$908.00' },
+ { label: 'Tax (19%)', value: '$171.52' },
+ { label: 'Total', value: '$1,079.52' },
+];
+
+export function InvoiceCard() {
+ return (
+ Open}
+ contentClassName="px-0 pt-0"
+ footer={
+ <>
+
+ Download PDF
+
+
+ Pay now
+
+ >
+ }
+ >
+
+
+
+ Item
+ Qty
+ Amount
+
+
+
+ {LINES.map((line) => (
+
+ {line.item}
+ {line.qty}
+ {line.amount}
+
+ ))}
+
+
+
+ {TOTALS.map((row, index) => (
+
+ {row.label}
+
+ {row.value}
+
+
+ ))}
+
+
+ );
+}
diff --git a/apps/blocks/src/components/theme-playground/canvas/cards/labels-card.tsx b/apps/blocks/src/components/theme-playground/canvas/cards/labels-card.tsx
new file mode 100644
index 00000000..5cfc8a66
--- /dev/null
+++ b/apps/blocks/src/components/theme-playground/canvas/cards/labels-card.tsx
@@ -0,0 +1,38 @@
+import { Badge } from '@constructive-io/ui/badge';
+import { Field } from '@constructive-io/ui/field';
+import { MultiSelect } from '@constructive-io/ui/multi-select';
+
+import { SinkCard } from './sink-card';
+
+const OPTIONS = [
+ { label: 'Frontend', value: 'frontend' },
+ { label: 'Backend', value: 'backend' },
+ { label: 'Design', value: 'design' },
+ { label: 'Docs', value: 'docs' },
+ { label: 'Bug', value: 'bug' },
+];
+
+const TAGS = ['urgent', 'v2.1', 'customer'];
+
+export function LabelsCard() {
+ return (
+
+
+ {}}
+ placeholder="Select labels"
+ maxCount={3}
+ />
+
+
+ {TAGS.map((tag) => (
+
+ {tag}
+
+ ))}
+
+
+ );
+}
diff --git a/apps/blocks/src/components/theme-playground/canvas/cards/latency-history-card.tsx b/apps/blocks/src/components/theme-playground/canvas/cards/latency-history-card.tsx
new file mode 100644
index 00000000..bf98cde1
--- /dev/null
+++ b/apps/blocks/src/components/theme-playground/canvas/cards/latency-history-card.tsx
@@ -0,0 +1,66 @@
+import { Badge } from '@constructive-io/ui/badge';
+import {
+ Combobox,
+ ComboboxEmpty,
+ ComboboxInput,
+ ComboboxItem,
+ ComboboxList,
+ ComboboxPopup,
+} from '@constructive-io/ui/combobox';
+import { Separator } from '@constructive-io/ui/separator';
+
+import { Area } from '../charts/area';
+import { SinkCard } from './sink-card';
+
+const METRICS = [
+ { value: 'p50', label: 'p50' },
+ { value: 'p95', label: 'p95' },
+ { value: 'p99', label: 'p99' },
+];
+
+const LATENCY = [
+ 52, 48, 55, 50, 47, 53, 58, 51, 49, 46, 54, 57, 52, 50, 48, 55, 60, 56, 52, 49, 51, 54, 47, 50, 53, 49, 46, 52, 50,
+ 48,
+];
+
+export function LatencyHistoryCard() {
+ return (
+
+
+
+
+ No metric found.
+
+ {(item: (typeof METRICS)[number]) => (
+
+ {item.label}
+
+ )}
+
+
+
+
+
+
+ Now
+ 48 ms
+
+
+ 30-day avg
+ 52 ms
+
+
+
+ SLO < 120 ms
+ Met
+
+
+
+ );
+}
diff --git a/apps/blocks/src/components/theme-playground/canvas/cards/latency-report-card.tsx b/apps/blocks/src/components/theme-playground/canvas/cards/latency-report-card.tsx
new file mode 100644
index 00000000..93442e8c
--- /dev/null
+++ b/apps/blocks/src/components/theme-playground/canvas/cards/latency-report-card.tsx
@@ -0,0 +1,50 @@
+import { Badge } from '@constructive-io/ui/badge';
+import { Button } from '@constructive-io/ui/button';
+
+import { RadialBars } from '../charts/radial-bars';
+import { SinkCard } from './sink-card';
+
+const RINGS = [
+ { name: 'p50', value: 28, color: 'var(--chart-1)' },
+ { name: 'p95', value: 48, color: 'var(--chart-2)' },
+ { name: 'p99', value: 96, color: 'var(--chart-3)' },
+];
+
+export function LatencyReportCard() {
+ return (
+
+ Open in Logs
+
+ }
+ >
+
+
48 ms
+
p95
+
+ Within SLO
+
+
+
+
({ value: r.value, max: 120, color: r.color }))}
+ size={120}
+ label="Latency percentiles against the 120 ms SLO"
+ />
+
+ {RINGS.map((ring) => (
+
+
+ {ring.name}
+ {ring.value} ms
+
+ ))}
+
+
+
+ );
+}
diff --git a/apps/blocks/src/components/theme-playground/canvas/cards/live-query-stream-card.tsx b/apps/blocks/src/components/theme-playground/canvas/cards/live-query-stream-card.tsx
new file mode 100644
index 00000000..bb315dd6
--- /dev/null
+++ b/apps/blocks/src/components/theme-playground/canvas/cards/live-query-stream-card.tsx
@@ -0,0 +1,90 @@
+'use client';
+
+import { useState } from 'react';
+import { AnimatePresence, motion } from 'motion/react';
+import { Pause, Play } from 'lucide-react';
+
+import { Button } from '@constructive-io/ui/button';
+import { Toggle } from '@constructive-io/ui/toggle';
+import { ToggleGroup, ToggleGroupItem } from '@constructive-io/ui/toggle-group';
+
+import { Waveform } from '../charts/waveform';
+import { SinkCard } from './sink-card';
+
+export function LiveQueryStreamCard() {
+ const [active, setActive] = useState(true);
+ const [processing, setProcessing] = useState(false);
+ const [mode, setMode] = useState<'static' | 'scrolling'>('scrolling');
+
+ return (
+
+ {
+ setActive((v) => !v);
+ if (!active) setProcessing(false);
+ }}
+ >
+
+
+ {active ? : }
+
+
+ {active ? 'Stop' : 'Start'}
+
+ {
+ setProcessing(pressed);
+ if (pressed) setActive(false);
+ }}
+ aria-label="Toggle processing"
+ variant="outline"
+ >
+ Processing
+
+ {
+ if (v[0] === 'static' || v[0] === 'scrolling') setMode(v[0]);
+ }}
+ variant="outline"
+ aria-label="Stream mode"
+ className="ml-auto"
+ >
+
+ Static
+
+
+ Scrolling
+
+
+
+ }
+ >
+
+
+ );
+}
diff --git a/apps/blocks/src/components/theme-playground/canvas/cards/loading-card.tsx b/apps/blocks/src/components/theme-playground/canvas/cards/loading-card.tsx
new file mode 100644
index 00000000..a87a86df
--- /dev/null
+++ b/apps/blocks/src/components/theme-playground/canvas/cards/loading-card.tsx
@@ -0,0 +1,20 @@
+import { Skeleton } from '@constructive-io/ui/skeleton';
+
+import { SinkCard } from './sink-card';
+
+export function LoadingCard() {
+ return (
+
+
+ Loading
+
+ );
+}
diff --git a/apps/blocks/src/components/theme-playground/canvas/cards/migration-milestones-card.tsx b/apps/blocks/src/components/theme-playground/canvas/cards/migration-milestones-card.tsx
new file mode 100644
index 00000000..2ea958c8
--- /dev/null
+++ b/apps/blocks/src/components/theme-playground/canvas/cards/migration-milestones-card.tsx
@@ -0,0 +1,65 @@
+import { Database, GitBranch, Table2 } from 'lucide-react';
+
+import { Button } from '@constructive-io/ui/button';
+import { InputGroup, InputGroupAddon, InputGroupInput } from '@constructive-io/ui/input-group';
+import { Progress } from '@constructive-io/ui/progress';
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@constructive-io/ui/select';
+
+import { SinkCard } from './sink-card';
+
+const CADENCES = {
+ nightly: 'Nightly',
+ hourly: 'Hourly',
+ manual: 'Manual',
+};
+
+const MILESTONES = [
+ { title: 'Backfill orders partitions', pct: 72, icon: Table2, volume: '1,200,000 rows', cadence: 'nightly' },
+ { title: 'Dual-write customers', pct: 45, icon: Database, volume: '860,000 rows', cadence: 'hourly' },
+ { title: 'Cut over search index', pct: 12, icon: GitBranch, volume: '3,400,000 docs', cadence: 'manual' },
+];
+
+export function MigrationMilestonesCard() {
+ return (
+
+ Add milestone
+
+ }
+ contentClassName="flex flex-col gap-5"
+ >
+ {MILESTONES.map((m) => (
+
+
+ {m.title}
+ {m.pct}%
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {Object.entries(CADENCES).map(([id, label]) => (
+
+ {label}
+
+ ))}
+
+
+
+
+ ))}
+
+ );
+}
diff --git a/apps/blocks/src/components/theme-playground/canvas/cards/navigation-card.tsx b/apps/blocks/src/components/theme-playground/canvas/cards/navigation-card.tsx
new file mode 100644
index 00000000..22755374
--- /dev/null
+++ b/apps/blocks/src/components/theme-playground/canvas/cards/navigation-card.tsx
@@ -0,0 +1,33 @@
+import { BarChart3, Folder, Home, Settings, Users, type LucideIcon } from 'lucide-react';
+
+import { Button } from '@constructive-io/ui/button';
+
+import { SinkCard } from './sink-card';
+import { cn } from '@/lib/utils';
+
+const ITEMS: { label: string; icon: LucideIcon; active?: boolean }[] = [
+ { label: 'Overview', icon: Home, active: true },
+ { label: 'Projects', icon: Folder },
+ { label: 'Team', icon: Users },
+ { label: 'Analytics', icon: BarChart3 },
+ { label: 'Settings', icon: Settings },
+];
+
+export function NavigationCard() {
+ return (
+
+ {ITEMS.map((item) => (
+
+
+ {item.label}
+
+ ))}
+
+ );
+}
diff --git a/apps/blocks/src/components/theme-playground/canvas/cards/no-schemas-card.tsx b/apps/blocks/src/components/theme-playground/canvas/cards/no-schemas-card.tsx
new file mode 100644
index 00000000..78473009
--- /dev/null
+++ b/apps/blocks/src/components/theme-playground/canvas/cards/no-schemas-card.tsx
@@ -0,0 +1,27 @@
+import { Database } from 'lucide-react';
+
+import { Button } from '@constructive-io/ui/button';
+import { Empty, EmptyContent, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from '@constructive-io/ui/empty';
+
+import { SinkCard } from './sink-card';
+
+export function NoSchemasCard() {
+ return (
+
+
+
+
+
+
+ No schemas yet
+ Schemas group your tables, policies and generated API surface.
+
+
+
+ Create schema
+
+
+
+
+ );
+}
diff --git a/apps/blocks/src/components/theme-playground/canvas/cards/not-found-card.tsx b/apps/blocks/src/components/theme-playground/canvas/cards/not-found-card.tsx
new file mode 100644
index 00000000..447ca2de
--- /dev/null
+++ b/apps/blocks/src/components/theme-playground/canvas/cards/not-found-card.tsx
@@ -0,0 +1,39 @@
+import { Search } from 'lucide-react';
+
+import { Button } from '@constructive-io/ui/button';
+import { Empty, EmptyContent, EmptyDescription, EmptyHeader, EmptyTitle } from '@constructive-io/ui/empty';
+import { InputGroup, InputGroupAddon, InputGroupInput } from '@constructive-io/ui/input-group';
+import { Kbd } from '@constructive-io/ui/kbd';
+
+import { SinkCard } from './sink-card';
+
+export function NotFoundCard() {
+ return (
+
+
+
+ 404
+ Table not found
+ It may have been renamed or dropped in a migration.
+
+
+
+
+
+
+
+
+ /
+
+
+
+
+ Go back
+
+ Browse schema
+
+
+
+
+ );
+}
diff --git a/apps/blocks/src/components/theme-playground/canvas/cards/notifications-card.tsx b/apps/blocks/src/components/theme-playground/canvas/cards/notifications-card.tsx
new file mode 100644
index 00000000..9f9eaf80
--- /dev/null
+++ b/apps/blocks/src/components/theme-playground/canvas/cards/notifications-card.tsx
@@ -0,0 +1,29 @@
+import { Label } from '@constructive-io/ui/label';
+import { Switch } from '@constructive-io/ui/switch';
+
+import { SinkCard } from './sink-card';
+
+const ROWS = [
+ { id: 'sink-n-push', label: 'Push alerts', hint: 'Critical only', checked: true },
+ { id: 'sink-n-email', label: 'Weekly digest', hint: 'Mondays', checked: true },
+ { id: 'sink-n-comments', label: 'Comment replies', hint: 'Instant', checked: false },
+ { id: 'sink-n-billing', label: 'Billing events', hint: 'Owners', checked: true },
+];
+
+export function NotificationsCard() {
+ return (
+
+ {ROWS.map((row) => (
+
+
+
+ {row.label}
+
+
{row.hint}
+
+
+
+ ))}
+
+ );
+}
diff --git a/apps/blocks/src/components/theme-playground/canvas/cards/observability-card.tsx b/apps/blocks/src/components/theme-playground/canvas/cards/observability-card.tsx
new file mode 100644
index 00000000..ad18035e
--- /dev/null
+++ b/apps/blocks/src/components/theme-playground/canvas/cards/observability-card.tsx
@@ -0,0 +1,47 @@
+import { Check } from 'lucide-react';
+
+import { Badge } from '@constructive-io/ui/badge';
+import { Button } from '@constructive-io/ui/button';
+import { Item, ItemContent, ItemGroup, ItemMedia, ItemTitle } from '@constructive-io/ui/item';
+
+import { SinkCard } from './sink-card';
+
+const FEATURES = [
+ 'Query tracing across every resolver',
+ 'Alerting rules with Slack and PagerDuty',
+ '90-day metrics retention',
+];
+
+export function ObservabilityCard() {
+ return (
+
New}
+ contentClassName="flex flex-col gap-4"
+ footer={
+ <>
+ Enable now
+
+ Compare plans
+
+ >
+ }
+ >
+
+ Deeper visibility into production — tracing, alerting and long-retention metrics — without leaving the project.
+
+
+ {FEATURES.map((feature) => (
+ -
+
+
+
+
+ {feature}
+
+
+ ))}
+
+
+ );
+}
diff --git a/apps/blocks/src/components/theme-playground/canvas/cards/payment-methods-card.tsx b/apps/blocks/src/components/theme-playground/canvas/cards/payment-methods-card.tsx
new file mode 100644
index 00000000..d8c6f5a8
--- /dev/null
+++ b/apps/blocks/src/components/theme-playground/canvas/cards/payment-methods-card.tsx
@@ -0,0 +1,93 @@
+import { Building2, CreditCard, MoreHorizontal } from 'lucide-react';
+
+import { Badge } from '@constructive-io/ui/badge';
+import {
+ Breadcrumb,
+ BreadcrumbItem,
+ BreadcrumbLink,
+ BreadcrumbList,
+ BreadcrumbPage,
+ BreadcrumbSeparator,
+} from '@constructive-io/ui/breadcrumb';
+import { Button } from '@constructive-io/ui/button';
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger,
+} from '@constructive-io/ui/dropdown-menu';
+import {
+ Item,
+ ItemActions,
+ ItemContent,
+ ItemDescription,
+ ItemGroup,
+ ItemMedia,
+ ItemTitle,
+} from '@constructive-io/ui/item';
+
+import { SinkCard } from './sink-card';
+
+const METHODS = [
+ { icon: CreditCard, title: 'Visa •••• 4242', description: 'Expires 08/28', isDefault: true },
+ { icon: CreditCard, title: 'Mastercard •••• 8812', description: 'Expires 01/27', isDefault: false },
+ { icon: Building2, title: 'Bank transfer (SEPA)', description: 'DE89 •••• 3001', isDefault: false },
+];
+
+export function PaymentMethodsCard() {
+ return (
+
+ Add payment method
+
+ }
+ >
+
+
+
+
+ Billing
+
+
+
+
+ Payment methods
+
+
+
+
+ {METHODS.map((method) => (
+ -
+
+
+
+
+
+ {method.title}
+ {method.isDefault ? Default : null}
+
+ {method.description}
+
+
+
+
+
+
+
+
+
+ Set default
+
+ Remove
+
+
+
+
+ ))}
+
+
+ );
+}
diff --git a/apps/blocks/src/components/theme-playground/canvas/cards/plan-billing-card.tsx b/apps/blocks/src/components/theme-playground/canvas/cards/plan-billing-card.tsx
new file mode 100644
index 00000000..4b53d098
--- /dev/null
+++ b/apps/blocks/src/components/theme-playground/canvas/cards/plan-billing-card.tsx
@@ -0,0 +1,17 @@
+import { BillingSubscriptionCard } from '@/blocks/billing/billing-subscription-card/billing-subscription-card';
+import {
+ billingShowcaseFormatOptions,
+ billingShowcaseOrganizationAccount,
+ billingShowcaseOrganizationSubscriptionResources,
+} from '@/lib/billing-showcase-fixtures';
+
+/** Real billing block — same fixture the billing showcase uses. */
+export function PlanBillingCard() {
+ return (
+
+ );
+}
diff --git a/apps/blocks/src/components/theme-playground/canvas/cards/plan-chat-card.tsx b/apps/blocks/src/components/theme-playground/canvas/cards/plan-chat-card.tsx
new file mode 100644
index 00000000..2c532354
--- /dev/null
+++ b/apps/blocks/src/components/theme-playground/canvas/cards/plan-chat-card.tsx
@@ -0,0 +1,78 @@
+import {
+ Message,
+ MessageContent,
+ PlanTracker,
+ RecommendationCard,
+ SystemMessage,
+ TaskList,
+ TaskRow,
+} from '@constructive-io/ui/ai';
+
+import { ChatColumn, ChatPrompt } from './chat-column';
+
+const RLS_PLAN = [
+ { label: 'Snapshot customers', status: 'done' },
+ { label: 'Draft RLS policies', status: 'done' },
+ { label: 'Backfill org_id', status: 'in_progress' },
+ { label: 'Enable RLS and verify', status: 'pending' },
+ { label: 'Cut over reads', status: 'pending' },
+] as const;
+
+export function PlanChatCard() {
+ return (
+
}
+ />
+ }
+ >
+
+ acme-prod · eu-central-1
+
+
+ Migrate customers to RLS without downtime.
+
+
+
+ Here's the rollout plan — tracking each step:
+
+
+
+
+
+
+
+
+ Enable PITR on acme-prod before the
+ cutover.
+ >
+ }
+ confidence={0.9}
+ confidenceLabel="High confidence"
+ alternatives={[{ id: 'skip', label: 'Skip PITR (faster cutover)', badge: 'Risky' }]}
+ />
+
+
+
+ );
+}
diff --git a/apps/blocks/src/components/theme-playground/canvas/cards/plan-tier-card.tsx b/apps/blocks/src/components/theme-playground/canvas/cards/plan-tier-card.tsx
new file mode 100644
index 00000000..dec861af
--- /dev/null
+++ b/apps/blocks/src/components/theme-playground/canvas/cards/plan-tier-card.tsx
@@ -0,0 +1,33 @@
+import { RadioGroup, RadioGroupItem } from '@constructive-io/ui/radio-group';
+import { Label } from '@constructive-io/ui/label';
+
+import { SinkCard } from './sink-card';
+
+const TIERS = [
+ { id: 'free', name: 'Free', price: '$0', note: 'Hobby projects' },
+ { id: 'pro', name: 'Pro', price: '$24', note: 'Per seat / month' },
+ { id: 'team', name: 'Team', price: '$49', note: 'Per seat / month' },
+];
+
+export function PlanTierCard() {
+ return (
+
+
+ {TIERS.map((tier) => (
+
+
+
+ {tier.name}
+ {tier.note}
+
+ {tier.price}
+
+ ))}
+
+
+ );
+}
diff --git a/apps/blocks/src/components/theme-playground/canvas/cards/primary-database-card.tsx b/apps/blocks/src/components/theme-playground/canvas/cards/primary-database-card.tsx
new file mode 100644
index 00000000..a4a05ea4
--- /dev/null
+++ b/apps/blocks/src/components/theme-playground/canvas/cards/primary-database-card.tsx
@@ -0,0 +1,31 @@
+import { Badge } from '@constructive-io/ui/badge';
+import { Separator } from '@constructive-io/ui/separator';
+
+import { SinkCard } from './sink-card';
+
+const STATS = [
+ { label: 'Connections', value: '184 / 400' },
+ { label: 'Replication lag', value: '12 ms' },
+ { label: 'Disk', value: '61%' },
+ { label: 'Uptime', value: '99.99%' },
+];
+
+export function PrimaryDatabaseCard() {
+ return (
+
+
+ Healthy
+ Failover armed
+
+
+
+ {STATS.map((stat) => (
+
+
{stat.label}
+ {stat.value}
+
+ ))}
+
+
+ );
+}
diff --git a/apps/blocks/src/components/theme-playground/canvas/cards/profile-card.tsx b/apps/blocks/src/components/theme-playground/canvas/cards/profile-card.tsx
new file mode 100644
index 00000000..ce32807e
--- /dev/null
+++ b/apps/blocks/src/components/theme-playground/canvas/cards/profile-card.tsx
@@ -0,0 +1,67 @@
+import { Avatar, AvatarFallback } from '@constructive-io/ui/avatar';
+import { Button } from '@constructive-io/ui/button';
+import { Field } from '@constructive-io/ui/field';
+import { Input } from '@constructive-io/ui/input';
+import { InputGroup, InputGroupAddon, InputGroupInput, InputGroupText } from '@constructive-io/ui/input-group';
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@constructive-io/ui/select';
+import { Textarea } from '@constructive-io/ui/textarea';
+
+import { SinkCard } from './sink-card';
+
+const ROLES = {
+ owner: 'Owner',
+ admin: 'Admin',
+ developer: 'Developer',
+};
+
+export function ProfileCard() {
+ return (
+
+ Save changes
+
+ }
+ >
+
+
+
+
+
+
+
+ @
+
+
+
+
+
+
+
+
+
+
+ {Object.keys(ROLES).map((id) => (
+
+ {id}
+
+ ))}
+
+
+
+
+
+
+
+ );
+}
diff --git a/apps/blocks/src/components/theme-playground/canvas/cards/project-icon-card.tsx b/apps/blocks/src/components/theme-playground/canvas/cards/project-icon-card.tsx
new file mode 100644
index 00000000..ad8e3df0
--- /dev/null
+++ b/apps/blocks/src/components/theme-playground/canvas/cards/project-icon-card.tsx
@@ -0,0 +1,47 @@
+import { ImageIcon, X } from 'lucide-react';
+
+import { Button } from '@constructive-io/ui/button';
+import { Label } from '@constructive-io/ui/label';
+import { Item, ItemActions, ItemContent, ItemDescription, ItemMedia, ItemTitle } from '@constructive-io/ui/item';
+
+import { SinkCard } from './sink-card';
+
+export function ProjectIconCard() {
+ return (
+
+
+
+ AC
+
+
+
Upload
+
+ Choose file…
+
+
PNG or SVG · 512×512 · < 1 MB
+
+
+ -
+
+
+
+
+ acme-mark.svg
+ 24 KB · uploaded 2 days ago
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/apps/blocks/src/components/theme-playground/canvas/cards/project-preferences-card.tsx b/apps/blocks/src/components/theme-playground/canvas/cards/project-preferences-card.tsx
new file mode 100644
index 00000000..16e09fc4
--- /dev/null
+++ b/apps/blocks/src/components/theme-playground/canvas/cards/project-preferences-card.tsx
@@ -0,0 +1,78 @@
+'use client';
+
+import { useState } from 'react';
+import { X } from 'lucide-react';
+
+import { Button } from '@constructive-io/ui/button';
+import { Field, FieldContent, FieldDescription, FieldTitle } from '@constructive-io/ui/field';
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@constructive-io/ui/select';
+import { Switch } from '@constructive-io/ui/switch';
+
+import { SinkCard } from './sink-card';
+
+const REGIONS = {
+ 'eu-central-1': 'EU Central (eu-central-1)',
+ 'us-east-1': 'US East (us-east-1)',
+ 'ap-southeast-2': 'AP Southeast (ap-southeast-2)',
+};
+
+export function ProjectPreferencesCard() {
+ const [playground, setPlayground] = useState(false);
+ const [digest, setDigest] = useState(true);
+
+ return (
+
+
+
+ }
+ contentClassName="flex flex-col gap-4"
+ footer={
+ <>
+
+ Reset
+
+
+ Save preferences
+
+ >
+ }
+ >
+
+
+
+
+
+
+ {Object.keys(REGIONS).map((id) => (
+
+ {id}
+
+ ))}
+
+
+
+
+
+ Public GraphQL playground
+ Allow unauthenticated schema introspection.
+
+ setPlayground(v === true)}
+ aria-label="Public GraphQL playground"
+ />
+
+
+
+ Weekly digest
+ Usage, spend and incident summary every Monday.
+
+ setDigest(v === true)} aria-label="Weekly digest" />
+
+
+ );
+}
diff --git a/apps/blocks/src/components/theme-playground/canvas/cards/publish-pack-empty-card.tsx b/apps/blocks/src/components/theme-playground/canvas/cards/publish-pack-empty-card.tsx
new file mode 100644
index 00000000..e2554250
--- /dev/null
+++ b/apps/blocks/src/components/theme-playground/canvas/cards/publish-pack-empty-card.tsx
@@ -0,0 +1,30 @@
+import { Package } from 'lucide-react';
+
+import { Button } from '@constructive-io/ui/button';
+import { Empty, EmptyContent, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from '@constructive-io/ui/empty';
+
+import { SinkCard } from './sink-card';
+
+export function PublishPackEmptyCard() {
+ return (
+
+
+
+
+
+
+ Publish your first feature pack
+ Bundle schemas, policies and UI into a reusable pack.
+
+
+
+ Create pack
+
+ Read the guide
+
+
+
+
+
+ );
+}
diff --git a/apps/blocks/src/components/theme-playground/canvas/cards/query-volume-card.tsx b/apps/blocks/src/components/theme-playground/canvas/cards/query-volume-card.tsx
new file mode 100644
index 00000000..0e6bb6de
--- /dev/null
+++ b/apps/blocks/src/components/theme-playground/canvas/cards/query-volume-card.tsx
@@ -0,0 +1,30 @@
+import { Badge } from '@constructive-io/ui/badge';
+
+import { Bars } from '../charts/bars';
+import { SinkCard } from './sink-card';
+
+const DAYS = [38, 52, 44, 61, 58, 73, 92];
+const LABELS = ['M', 'T', 'W', 'T', 'F', 'S', 'S'];
+
+export function QueryVolumeCard() {
+ return (
+
+12%}>
+
+
Last 7 days
+
48.2k queries
+
+
+
+ Mon–Sun
+ vs 43.1k prior week
+
+
+ );
+}
diff --git a/apps/blocks/src/components/theme-playground/canvas/cards/read-replicas-card.tsx b/apps/blocks/src/components/theme-playground/canvas/cards/read-replicas-card.tsx
new file mode 100644
index 00000000..11dd8c66
--- /dev/null
+++ b/apps/blocks/src/components/theme-playground/canvas/cards/read-replicas-card.tsx
@@ -0,0 +1,47 @@
+'use client';
+
+import { useState } from 'react';
+
+import { Slider } from '@constructive-io/ui/slider';
+import { ToggleGroup, ToggleGroupItem } from '@constructive-io/ui/toggle-group';
+
+import { SinkCard } from './sink-card';
+
+const PRESETS = ['0', '1', '2', '4'];
+
+export function ReadReplicasCard() {
+ const [replicas, setReplicas] = useState(2);
+
+ return (
+
+
+ {replicas} {replicas === 1 ? 'replica' : 'replicas'}
+
+ setReplicas(value as number)}
+ min={0}
+ max={4}
+ step={1}
+ aria-label="Read replica count"
+ />
+ {
+ const next = (values as string[]).at(-1);
+ if (next !== undefined) setReplicas(Number(next));
+ }}
+ spacing={4}
+ aria-label="Replica presets"
+ className="w-full"
+ >
+ {PRESETS.map((preset) => (
+
+ {preset}
+
+ ))}
+
+ Adds ~$48/mo per replica
+
+ );
+}
diff --git a/apps/blocks/src/components/theme-playground/canvas/cards/reasoning-chat-card.tsx b/apps/blocks/src/components/theme-playground/canvas/cards/reasoning-chat-card.tsx
new file mode 100644
index 00000000..c0345af9
--- /dev/null
+++ b/apps/blocks/src/components/theme-playground/canvas/cards/reasoning-chat-card.tsx
@@ -0,0 +1,60 @@
+import {
+ CodeBlock,
+ FeedbackBar,
+ Message,
+ MessageContent,
+ Reasoning,
+ Source,
+ Sources,
+ ThinkingTrace,
+} from '@constructive-io/ui/ai';
+
+import { ChatColumn } from './chat-column';
+
+export function ReasoningChatCard() {
+ return (
+
+
+ Can customers in one org see another org's orders?
+
+
+
+
+
+
+ {`Every row in \`orders\` resolves through \`customer_id\` → \`customers.org_id\`. Add a tenant-isolation policy so members only read their own org's orders:`}
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/apps/blocks/src/components/theme-playground/canvas/cards/recovery-access-card.tsx b/apps/blocks/src/components/theme-playground/canvas/cards/recovery-access-card.tsx
new file mode 100644
index 00000000..976c4970
--- /dev/null
+++ b/apps/blocks/src/components/theme-playground/canvas/cards/recovery-access-card.tsx
@@ -0,0 +1,67 @@
+import { Laptop, Smartphone, Tablet } from 'lucide-react';
+
+import { Badge } from '@constructive-io/ui/badge';
+import { Button } from '@constructive-io/ui/button';
+import { Field } from '@constructive-io/ui/field';
+import { Input } from '@constructive-io/ui/input';
+import {
+ Item,
+ ItemActions,
+ ItemContent,
+ ItemDescription,
+ ItemGroup,
+ ItemMedia,
+ ItemTitle,
+} from '@constructive-io/ui/item';
+
+import { SinkCard } from './sink-card';
+
+const SESSIONS = [
+ { icon: Laptop, title: 'MacBook Pro · Berlin', description: 'This device', current: true },
+ { icon: Tablet, title: 'iPad Air · Lisbon', description: 'Active 2 days ago', current: false },
+ { icon: Smartphone, title: 'Pixel 9 · Berlin', description: 'Active last week', current: false },
+];
+
+export function RecoveryAccessCard() {
+ return (
+
+ Sign out other sessions
+
+ }
+ >
+
+
+
+
+ Verify
+
+
+
+
+ {SESSIONS.map((session) => (
+ -
+
+
+
+
+ {session.title}
+
+ {session.description}
+ {session.current ? Current session : null}
+
+
+
+
+ Revoke
+
+
+
+ ))}
+
+
+ );
+}
diff --git a/apps/blocks/src/components/theme-playground/canvas/cards/report-issue-card.tsx b/apps/blocks/src/components/theme-playground/canvas/cards/report-issue-card.tsx
new file mode 100644
index 00000000..d46771ae
--- /dev/null
+++ b/apps/blocks/src/components/theme-playground/canvas/cards/report-issue-card.tsx
@@ -0,0 +1,81 @@
+import { Button } from '@constructive-io/ui/button';
+import { Field } from '@constructive-io/ui/field';
+import { Input } from '@constructive-io/ui/input';
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@constructive-io/ui/select';
+import { Textarea } from '@constructive-io/ui/textarea';
+
+import { SinkCard } from './sink-card';
+
+const AREAS = {
+ migrations: 'Migrations',
+ api: 'API',
+ console: 'Console',
+ billing: 'Billing',
+};
+
+const SEVERITIES = {
+ low: 'Low',
+ medium: 'Medium',
+ high: 'High',
+};
+
+export function ReportIssueCard() {
+ return (
+
+
+ Cancel
+
+
+ Submit report
+
+ >
+ }
+ >
+
+
+
+
+
+
+
+
+
+
+ {Object.keys(AREAS).map((id) => (
+
+ {id}
+
+ ))}
+
+
+
+
+
+
+
+
+
+ {Object.keys(SEVERITIES).map((id) => (
+
+ {id}
+
+ ))}
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/apps/blocks/src/components/theme-playground/canvas/cards/schedule-maintenance-card.tsx b/apps/blocks/src/components/theme-playground/canvas/cards/schedule-maintenance-card.tsx
new file mode 100644
index 00000000..c8cd49b3
--- /dev/null
+++ b/apps/blocks/src/components/theme-playground/canvas/cards/schedule-maintenance-card.tsx
@@ -0,0 +1,54 @@
+import { Info } from 'lucide-react';
+
+import { Alert, AlertDescription, AlertTitle } from '@constructive-io/ui/alert';
+import { Button } from '@constructive-io/ui/button';
+import { Calendar } from '@constructive-io/ui/calendar-rac';
+import { Field } from '@constructive-io/ui/field';
+import { Popover, PopoverContent, PopoverTrigger } from '@constructive-io/ui/popover';
+import { ToggleGroup, ToggleGroupItem } from '@constructive-io/ui/toggle-group';
+
+import { SinkCard } from './sink-card';
+
+const SLOTS = ['02:00', '03:00', '04:00', '05:00'];
+
+export function ScheduleMaintenanceCard() {
+ return (
+
+ Schedule
+
+ }
+ >
+
+
+
+
+ Jun 14, 2026
+
+
+
+
+
+
+
+
+
+ {SLOTS.map((slot) => (
+
+ {slot}
+
+ ))}
+
+
+
+
+ Heads up
+ Writes pause for ~90 s during failover.
+
+
+ );
+}
diff --git a/apps/blocks/src/components/theme-playground/canvas/cards/schema-tabs-card.tsx b/apps/blocks/src/components/theme-playground/canvas/cards/schema-tabs-card.tsx
new file mode 100644
index 00000000..e2a277b4
--- /dev/null
+++ b/apps/blocks/src/components/theme-playground/canvas/cards/schema-tabs-card.tsx
@@ -0,0 +1,55 @@
+import { ShieldAlert } from 'lucide-react';
+
+import { Alert, AlertDescription, AlertTitle } from '@constructive-io/ui/alert';
+import { Tabs, TabsContent, TabsList, TabsTrigger } from '@constructive-io/ui/tabs';
+
+import { SinkCard } from './sink-card';
+
+export function SchemaTabsCard() {
+ return (
+
+
+
+ Columns
+ Indexes
+ Policies
+
+
+
+
+ id
+ uuid · pk
+
+
+ email
+ text · unique
+
+
+ created_at
+ timestamptz
+
+
+
+
+
+
+ users_email_key
+ btree (email)
+
+
+ users_created_at_idx
+ btree (created_at)
+
+
+
+
+
+
+ No policies
+ RLS is enabled but this table has no policies — all access is denied.
+
+
+
+
+ );
+}
diff --git a/apps/blocks/src/components/theme-playground/canvas/cards/setup-project-card.tsx b/apps/blocks/src/components/theme-playground/canvas/cards/setup-project-card.tsx
new file mode 100644
index 00000000..d0ba7eb6
--- /dev/null
+++ b/apps/blocks/src/components/theme-playground/canvas/cards/setup-project-card.tsx
@@ -0,0 +1,46 @@
+import { Check } from 'lucide-react';
+
+import { Button } from '@constructive-io/ui/button';
+import {
+ Stepper,
+ StepperIndicator,
+ StepperItem,
+ StepperSeparator,
+ StepperTitle,
+ StepperTrigger,
+} from '@constructive-io/ui/stepper';
+
+import { SinkCard } from './sink-card';
+
+const STEPS = ['Project', 'Database', 'Deploy'];
+
+export function SetupProjectCard() {
+ return (
+
+ {/* Labels sit under their indicators so three steps fit a 352px card; the
+ connector is positioned against the indicator row instead of flexing
+ into whatever width the label leaves over. */}
+
+ {STEPS.map((step, index) => (
+
+
+
+ {index === 0 ? : index + 1}
+
+ {step}
+
+ {index < STEPS.length - 1 ? (
+
+ ) : null}
+
+ ))}
+
+
+
+ Back
+
+ Continue
+
+
+ );
+}
diff --git a/apps/blocks/src/components/theme-playground/canvas/cards/shortcuts-card.tsx b/apps/blocks/src/components/theme-playground/canvas/cards/shortcuts-card.tsx
new file mode 100644
index 00000000..48aecf4f
--- /dev/null
+++ b/apps/blocks/src/components/theme-playground/canvas/cards/shortcuts-card.tsx
@@ -0,0 +1,35 @@
+import { Item, ItemActions, ItemContent, ItemGroup, ItemTitle } from '@constructive-io/ui/item';
+import { Kbd, KbdGroup } from '@constructive-io/ui/kbd';
+
+import { SinkCard } from './sink-card';
+
+const SHORTCUTS = [
+ { action: 'Command palette', keys: ['⌘', 'K'] },
+ { action: 'New table', keys: ['⌘', 'T'] },
+ { action: 'Run query', keys: ['⌘', '⏎'] },
+ { action: 'Toggle theme', keys: ['⌘', '⇧', 'L'] },
+ { action: 'Search docs', keys: ['/'] },
+];
+
+export function ShortcutsCard() {
+ return (
+
+
+ {SHORTCUTS.map((shortcut) => (
+ -
+
+ {shortcut.action}
+
+
+
+ {shortcut.keys.map((key) => (
+ {key}
+ ))}
+
+
+
+ ))}
+
+
+ );
+}
diff --git a/apps/blocks/src/components/theme-playground/canvas/cards/sidebar-nav-card.tsx b/apps/blocks/src/components/theme-playground/canvas/cards/sidebar-nav-card.tsx
new file mode 100644
index 00000000..448a70e9
--- /dev/null
+++ b/apps/blocks/src/components/theme-playground/canvas/cards/sidebar-nav-card.tsx
@@ -0,0 +1,87 @@
+import type { CSSProperties } from 'react';
+import {
+ Blocks,
+ Braces,
+ CreditCard,
+ Gauge,
+ GitBranch,
+ HardDrive,
+ LayoutDashboard,
+ ScrollText,
+ ShieldCheck,
+ Table2,
+} from 'lucide-react';
+
+import {
+ Sidebar,
+ SidebarContent,
+ SidebarGroup,
+ SidebarGroupContent,
+ SidebarGroupLabel,
+ SidebarMenu,
+ SidebarMenuBadge,
+ SidebarMenuButton,
+ SidebarMenuItem,
+ SidebarProvider,
+} from '@constructive-io/ui/sidebar';
+
+import { SinkCard } from './sink-card';
+
+const GROUPS = [
+ {
+ label: 'Project',
+ items: [
+ { icon: LayoutDashboard, label: 'Overview' },
+ { icon: Table2, label: 'Tables' },
+ { icon: ShieldCheck, label: 'Policies' },
+ { icon: HardDrive, label: 'Storage' },
+ ],
+ },
+ {
+ label: 'Build',
+ items: [
+ { icon: Braces, label: 'GraphQL' },
+ { icon: Gauge, label: 'Functions' },
+ { icon: Blocks, label: 'Feature packs' },
+ ],
+ },
+ {
+ label: 'Ops',
+ items: [
+ { icon: GitBranch, label: 'Deployments' },
+ { icon: ScrollText, label: 'Logs', badge: '12' },
+ { icon: CreditCard, label: 'Billing' },
+ ],
+ },
+];
+
+export function SidebarNavCard() {
+ return (
+
+
+
+
+ {GROUPS.map((group) => (
+
+ {group.label}
+
+
+ {group.items.map((item) => (
+
+
+
+ {item.label}
+
+ {item.badge ? {item.badge} : null}
+
+ ))}
+
+
+
+ ))}
+
+
+
+
+ );
+}
diff --git a/apps/blocks/src/components/theme-playground/canvas/cards/simple-chat-card.tsx b/apps/blocks/src/components/theme-playground/canvas/cards/simple-chat-card.tsx
new file mode 100644
index 00000000..31ec0125
--- /dev/null
+++ b/apps/blocks/src/components/theme-playground/canvas/cards/simple-chat-card.tsx
@@ -0,0 +1,38 @@
+import { Message, MessageContent, PromptSuggestion, PromptSuggestions, TextShimmer } from '@constructive-io/ui/ai';
+
+import { ChatColumn, ChatPrompt } from './chat-column';
+
+export function SimpleChatCard() {
+ return (
+
+ Index the orders table
+ Explain RLS policies
+
+ }
+ />
+ }
+ >
+
+ Why is my orders report query slow?
+
+
+
+ {`The report runs a sequential scan on \`orders\` — no index covers the filter.\n\n- Add \`idx_orders_customer_id\` on \`orders(customer_id)\`\n- Re-run \`EXPLAIN ANALYZE\` to confirm the index scan\n- Consider partitioning by \`created_at\` once the table passes 10M rows`}
+
+
+
+ Can you write the migration?
+
+
+ Drafting migration…
+
+
+ );
+}
diff --git a/apps/blocks/src/components/theme-playground/canvas/cards/sink-card.tsx b/apps/blocks/src/components/theme-playground/canvas/cards/sink-card.tsx
new file mode 100644
index 00000000..7b8c02c2
--- /dev/null
+++ b/apps/blocks/src/components/theme-playground/canvas/cards/sink-card.tsx
@@ -0,0 +1,67 @@
+import type { ReactNode } from 'react';
+
+import {
+ Card,
+ CardAction,
+ CardContent,
+ CardDescription,
+ CardFooter,
+ CardHeader,
+ CardTitle,
+} from '@constructive-io/ui/card';
+
+import { cn } from '@/lib/utils';
+
+/**
+ * Compact card shell for the kitchen-sink wall. `size="sm"` supplies the
+ * 1rem padding rhythm; the hairline header renders only when a title exists.
+ * `--stagger` is inherited from the column wrapper, so every card shares the
+ * `animate-fade-up` entrance keyed to its column index.
+ */
+export function SinkCard({
+ title,
+ description,
+ action,
+ children,
+ footer,
+ className,
+ contentClassName,
+}: {
+ title?: string;
+ description?: string;
+ action?: ReactNode;
+ children: ReactNode;
+ footer?: ReactNode;
+ className?: string;
+ contentClassName?: string;
+}) {
+ return (
+
+ {title ? (
+
+ {title}
+ {description ? {description} : null}
+ {action ? (
+ {action}
+ ) : null}
+
+ ) : null}
+ {children}
+ {footer ? (
+ {footer}
+ ) : null}
+
+ );
+}
diff --git a/apps/blocks/src/components/theme-playground/canvas/cards/skeleton-rows-card.tsx b/apps/blocks/src/components/theme-playground/canvas/cards/skeleton-rows-card.tsx
new file mode 100644
index 00000000..2a9ad4da
--- /dev/null
+++ b/apps/blocks/src/components/theme-playground/canvas/cards/skeleton-rows-card.tsx
@@ -0,0 +1,20 @@
+import { Skeleton } from '@constructive-io/ui/skeleton';
+
+import { SinkCard } from './sink-card';
+
+export function SkeletonRowsCard() {
+ return (
+
+ {[0, 1, 2].map((row) => (
+
+ ))}
+
+
+ );
+}
diff --git a/apps/blocks/src/components/theme-playground/canvas/cards/spend-alert-card.tsx b/apps/blocks/src/components/theme-playground/canvas/cards/spend-alert-card.tsx
new file mode 100644
index 00000000..06c0a08f
--- /dev/null
+++ b/apps/blocks/src/components/theme-playground/canvas/cards/spend-alert-card.tsx
@@ -0,0 +1,78 @@
+'use client';
+
+import { useState } from 'react';
+import { X } from 'lucide-react';
+
+import { Button } from '@constructive-io/ui/button';
+import { Field, FieldDescription, FieldLabel } from '@constructive-io/ui/field';
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@constructive-io/ui/select';
+import { Slider } from '@constructive-io/ui/slider';
+import { Textarea } from '@constructive-io/ui/textarea';
+
+import { SinkCard } from './sink-card';
+
+const REGIONS = {
+ 'us-east-1': 'US East (us-east-1)',
+ 'eu-central-1': 'EU Central (eu-central-1)',
+ 'ap-southeast-2': 'AP Southeast (ap-southeast-2)',
+};
+
+export function SpendAlertCard() {
+ const [threshold, setThreshold] = useState(2500);
+
+ return (
+
+
+
+ }
+ contentClassName="flex flex-col gap-4"
+ footer={
+ <>
+
+ Cancel
+
+
+ Save alert
+
+ >
+ }
+ >
+
+
+
+
+
+
+ {Object.keys(REGIONS).map((id) => (
+
+ {id}
+
+ ))}
+
+
+
+
+
+ Threshold
+ ${threshold.toLocaleString()}
+
+ setThreshold(value as number)}
+ min={0}
+ max={10000}
+ step={100}
+ aria-label="Spend threshold"
+ />
+ Alerts at 80% and 100%.
+
+
+
+
+
+ );
+}
diff --git a/apps/blocks/src/components/theme-playground/canvas/cards/storage-card.tsx b/apps/blocks/src/components/theme-playground/canvas/cards/storage-card.tsx
new file mode 100644
index 00000000..0e965de9
--- /dev/null
+++ b/apps/blocks/src/components/theme-playground/canvas/cards/storage-card.tsx
@@ -0,0 +1,34 @@
+import { FileCode, FileImage, FileText, type LucideIcon } from 'lucide-react';
+
+import { Progress } from '@constructive-io/ui/progress';
+
+import { SinkCard } from './sink-card';
+
+const FILES: { name: string; size: string; icon: LucideIcon }[] = [
+ { name: 'hero-banner.png', size: '1.2 MB', icon: FileImage },
+ { name: 'invoice-0042.pdf', size: '86 KB', icon: FileText },
+ { name: 'schema.sql', size: '12 KB', icon: FileCode },
+];
+
+export function StorageCard() {
+ return (
+
+
+
+ Used
+ 3.1 / 5 GB
+
+
+
+
+ {FILES.map((file) => (
+
+
+ {file.name}
+ {file.size}
+
+ ))}
+
+
+ );
+}
diff --git a/apps/blocks/src/components/theme-playground/canvas/cards/storage-quota-card.tsx b/apps/blocks/src/components/theme-playground/canvas/cards/storage-quota-card.tsx
new file mode 100644
index 00000000..edcea8b5
--- /dev/null
+++ b/apps/blocks/src/components/theme-playground/canvas/cards/storage-quota-card.tsx
@@ -0,0 +1,40 @@
+import { Donut } from '../charts/donut';
+import { SinkCard } from './sink-card';
+
+const ROWS = [
+ { label: 'Projected full', value: 'Oct 2026' },
+ { label: 'Daily growth', value: '+38 MB' },
+ { label: 'Largest bucket', value: 'attachments' },
+];
+
+export function StorageQuotaCard() {
+ return (
+
+ {ROWS.map((row) => (
+
+ {row.label}
+ {row.value}
+
+ ))}
+
+ }
+ >
+
+
+
24.1 GB
+
80% of 30 GB
+
+
+
+ );
+}
diff --git a/apps/blocks/src/components/theme-playground/canvas/cards/style-overview-card.tsx b/apps/blocks/src/components/theme-playground/canvas/cards/style-overview-card.tsx
new file mode 100644
index 00000000..af6551c7
--- /dev/null
+++ b/apps/blocks/src/components/theme-playground/canvas/cards/style-overview-card.tsx
@@ -0,0 +1,52 @@
+import { getThemePreset } from '@constructive-io/ui/theme-presets';
+
+import { ACCENT_PRESETS, detectAccentPreset, FONT_PRESETS } from '@/lib/theme-playground/draft';
+import { usePreviewThemeDraft } from '../../preview-document';
+import { SinkCard } from './sink-card';
+
+const SWATCH_TOKENS = [
+ 'background',
+ 'foreground',
+ 'primary',
+ 'secondary',
+ 'muted',
+ 'accent',
+ 'border',
+ 'chart-1',
+ 'chart-2',
+ 'chart-3',
+ 'chart-4',
+ 'chart-5',
+];
+
+export function StyleOverviewCard() {
+ const draft = usePreviewThemeDraft();
+ const font = FONT_PRESETS.find((p) => p.id === draft.font)?.label ?? 'Inter';
+ const accentId = detectAccentPreset(draft);
+ const accent = draft.theme
+ ? getThemePreset(draft.theme).label
+ : (ACCENT_PRESETS.find((p) => p.id === accentId)?.label ?? 'Custom');
+
+ return (
+
+
+
+ {font} · {accent}
+
+
A preview of the current type and color tokens.
+
+
+ {SWATCH_TOKENS.map((token) => (
+
+ ))}
+
+
+ );
+}
diff --git a/apps/blocks/src/components/theme-playground/canvas/cards/syncing-schema-card.tsx b/apps/blocks/src/components/theme-playground/canvas/cards/syncing-schema-card.tsx
new file mode 100644
index 00000000..8cdb445b
--- /dev/null
+++ b/apps/blocks/src/components/theme-playground/canvas/cards/syncing-schema-card.tsx
@@ -0,0 +1,28 @@
+import { Button } from '@constructive-io/ui/button';
+import { Empty, EmptyContent, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from '@constructive-io/ui/empty';
+import { Spinner } from '@constructive-io/ui/spinner';
+
+import { SinkCard } from './sink-card';
+
+export function SyncingSchemaCard() {
+ return (
+
+
+
+
+
+
+ Syncing schema…
+
+ Applying 3 migrations to eu-central-1 .
+
+
+
+
+ View log
+
+
+
+
+ );
+}
diff --git a/apps/blocks/src/components/theme-playground/canvas/cards/team-card.tsx b/apps/blocks/src/components/theme-playground/canvas/cards/team-card.tsx
new file mode 100644
index 00000000..d319f266
--- /dev/null
+++ b/apps/blocks/src/components/theme-playground/canvas/cards/team-card.tsx
@@ -0,0 +1,46 @@
+import { Plus } from 'lucide-react';
+
+import { Avatar, AvatarFallback } from '@constructive-io/ui/avatar';
+import { Badge } from '@constructive-io/ui/badge';
+import { Button } from '@constructive-io/ui/button';
+import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@constructive-io/ui/tooltip';
+
+import { SinkCard } from './sink-card';
+
+const MEMBERS = [
+ { initials: 'AK', name: 'Ana Kaya', role: 'Owner', variant: 'default' },
+ { initials: 'JM', name: 'Jon Meyer', role: 'Admin', variant: 'secondary' },
+ { initials: 'RS', name: 'Rui Sato', role: 'Editor', variant: 'secondary' },
+ { initials: 'LP', name: 'Lea Park', role: 'Viewer', variant: 'outline' },
+] as const;
+
+export function TeamCard() {
+ return (
+
+
+
+ }
+ contentClassName="flex flex-col divide-y divide-border/60 py-1"
+ >
+
+ {MEMBERS.map((member) => (
+
+
+
+
+ {member.initials}
+
+
+ {member.name}
+
+
{member.name}
+
{member.role}
+
+ ))}
+
+
+ );
+}
diff --git a/apps/blocks/src/components/theme-playground/canvas/cards/tool-chat-card.tsx b/apps/blocks/src/components/theme-playground/canvas/cards/tool-chat-card.tsx
new file mode 100644
index 00000000..98c83cb6
--- /dev/null
+++ b/apps/blocks/src/components/theme-playground/canvas/cards/tool-chat-card.tsx
@@ -0,0 +1,42 @@
+import { ApprovalCard, Message, MessageContent, Step, Steps, Tool, ToolGroup } from '@constructive-io/ui/ai';
+
+import { ChatColumn } from './chat-column';
+
+export function ToolChatCard() {
+ return (
+
+
+ Speed up the orders report — it's timing out in production.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
undefined}
+ />
+
+
+
+ );
+}
diff --git a/apps/blocks/src/components/theme-playground/canvas/cards/transfer-credits-card.tsx b/apps/blocks/src/components/theme-playground/canvas/cards/transfer-credits-card.tsx
new file mode 100644
index 00000000..da2ec640
--- /dev/null
+++ b/apps/blocks/src/components/theme-playground/canvas/cards/transfer-credits-card.tsx
@@ -0,0 +1,87 @@
+import { Button } from '@constructive-io/ui/button';
+import { Field } from '@constructive-io/ui/field';
+import { InputGroup, InputGroupAddon, InputGroupInput, InputGroupText } from '@constructive-io/ui/input-group';
+import { Item, ItemContent, ItemDescription, ItemGroup, ItemTitle } from '@constructive-io/ui/item';
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@constructive-io/ui/select';
+import { Separator } from '@constructive-io/ui/separator';
+
+import { SinkCard } from './sink-card';
+
+const PROJECTS = {
+ 'acme-prod': 'acme-prod',
+ 'acme-staging': 'acme-staging',
+ 'acme-dev': 'acme-dev',
+};
+
+export function TransferCreditsCard() {
+ return (
+
+ Review transfer
+
+ }
+ >
+
+
+
+
+
+
+ {Object.keys(PROJECTS).map((id) => (
+
+ {id}
+
+ ))}
+
+
+
+
+
+
+
+
+
+ {Object.keys(PROJECTS).map((id) => (
+
+ {id}
+
+ ))}
+
+
+
+
+
+
+ ⌁
+
+
+
+ credits
+
+
+
+
+ -
+
+ Fee
+
+ 0
+
+ -
+
+ Arrives
+
+ Instantly
+
+
+
+
+ Total
+ 250 credits
+
+
+ );
+}
diff --git a/apps/blocks/src/components/theme-playground/canvas/cards/typography-specimen-card.tsx b/apps/blocks/src/components/theme-playground/canvas/cards/typography-specimen-card.tsx
new file mode 100644
index 00000000..91c93074
--- /dev/null
+++ b/apps/blocks/src/components/theme-playground/canvas/cards/typography-specimen-card.tsx
@@ -0,0 +1,87 @@
+import { Button } from '@constructive-io/ui/button';
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogPanel,
+ DialogTitle,
+ DialogTrigger,
+} from '@constructive-io/ui/dialog';
+import { Field } from '@constructive-io/ui/field';
+import { Input } from '@constructive-io/ui/input';
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@constructive-io/ui/select';
+import { Textarea } from '@constructive-io/ui/textarea';
+
+import { SinkCard } from './sink-card';
+
+const AREAS = {
+ typography: 'Typography',
+ color: 'Color',
+ spacing: 'Spacing',
+ components: 'Components',
+};
+
+export function TypographySpecimenCard() {
+ return (
+
+
+
+ Share feedback
+
+
+
+
+ Share feedback
+ Tell us how this style reads in your product.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {Object.keys(AREAS).map((id) => (
+
+ {id}
+
+ ))}
+
+
+
+
+
+
+
+
+
+ Cancel
+
+ Send
+
+
+
+ }
+ >
+ Inter — Body
+ Designing with rhythm and hierarchy.
+
+
+ Good typography disappears into the reading experience. Scale sets the hierarchy; rhythm keeps the eye moving.
+
+
Every weight and size here comes from the same token ladder the components use.
+
+
+ );
+}
diff --git a/apps/blocks/src/components/theme-playground/canvas/cards/ui-elements-card.tsx b/apps/blocks/src/components/theme-playground/canvas/cards/ui-elements-card.tsx
new file mode 100644
index 00000000..e4a7c6b0
--- /dev/null
+++ b/apps/blocks/src/components/theme-playground/canvas/cards/ui-elements-card.tsx
@@ -0,0 +1,184 @@
+import type { ReactNode } from 'react';
+import { ChevronLeft, ChevronRight, MoreHorizontal, Search } from 'lucide-react';
+
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+ AlertDialogTrigger,
+} from '@constructive-io/ui/alert-dialog';
+import { Badge } from '@constructive-io/ui/badge';
+import { Button } from '@constructive-io/ui/button';
+import { ButtonGroup } from '@constructive-io/ui/button-group';
+import { Checkbox } from '@constructive-io/ui/checkbox';
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+} from '@constructive-io/ui/dropdown-menu';
+import { Field, FieldLabel } from '@constructive-io/ui/field';
+import { Input } from '@constructive-io/ui/input';
+import { InputGroup, InputGroupAddon, InputGroupInput } from '@constructive-io/ui/input-group';
+import { Kbd } from '@constructive-io/ui/kbd';
+import { Radio, RadioGroup } from '@constructive-io/ui/radio-group';
+import { Slider } from '@constructive-io/ui/slider';
+import { Switch } from '@constructive-io/ui/switch';
+import { Textarea } from '@constructive-io/ui/textarea';
+import { ToggleGroup, ToggleGroupItem } from '@constructive-io/ui/toggle-group';
+
+import { SinkCard } from './sink-card';
+
+function TrayRow({ label, children }: { label: string; children: ReactNode }) {
+ return (
+
+ );
+}
+
+export function UiElementsCard() {
+ return (
+
+
+ Default
+
+ Secondary
+
+
+ Outline
+
+
+ Ghost
+
+
+ Destructive
+
+
+
+ Prev
+
+
+ Next
+
+
+
+
+ Default
+ Secondary
+ Outline
+ Success
+ Warning
+ Error
+ Info
+ Destructive
+
+
+
+
+
+
+
+
+
+
+ ⌘K
+
+
+
+
+
+
+
+
+
+ Cache
+
+
+
+
+
+ Telemetry
+
+
+
+
+
+ EU
+
+
+
+ US
+
+
+
+ APAC
+
+
+
+
+ Public
+
+
+
+ Beta
+
+
+
+
+
+
+
+ Day
+
+
+ Week
+
+
+ Month
+
+
+
+
+
+
+
+
+ Actions
+
+
+
+ Duplicate
+ Rename
+ Delete
+
+
+
+
+
+ Delete table
+
+
+
+
+ Delete table?
+
+ This drops `orders` and all of its rows. The action cannot be undone.
+
+
+
+ Cancel
+ Delete table
+
+
+
+
+
+ );
+}
diff --git a/apps/blocks/src/components/theme-playground/canvas/cards/upcoming-maintenance-card.tsx b/apps/blocks/src/components/theme-playground/canvas/cards/upcoming-maintenance-card.tsx
new file mode 100644
index 00000000..a25f88b4
--- /dev/null
+++ b/apps/blocks/src/components/theme-playground/canvas/cards/upcoming-maintenance-card.tsx
@@ -0,0 +1,39 @@
+import { CalendarDate } from '@internationalized/date';
+import { RefreshCw, ServerCog } from 'lucide-react';
+
+import { Badge } from '@constructive-io/ui/badge';
+import { Calendar } from '@constructive-io/ui/calendar-rac';
+import { Item, ItemContent, ItemDescription, ItemGroup, ItemMedia, ItemTitle } from '@constructive-io/ui/item';
+
+import { SinkCard } from './sink-card';
+
+const MAINTENANCE = [
+ { icon: ServerCog, title: 'Postgres 17.3 minor', description: 'Rolling restart', badge: 'Jun 14 · 02:00 UTC' },
+ { icon: RefreshCw, title: 'Certificate rotation', description: 'No downtime expected', badge: 'Jun 21' },
+];
+
+export function UpcomingMaintenanceCard() {
+ return (
+
+
+
+ {MAINTENANCE.map((item, index) => (
+ -
+
+
+
+
+ {item.title}
+ {item.description}
+
+ {item.badge}
+
+ ))}
+
+
+ );
+}
diff --git a/apps/blocks/src/components/theme-playground/canvas/cards/upload-assets-card.tsx b/apps/blocks/src/components/theme-playground/canvas/cards/upload-assets-card.tsx
new file mode 100644
index 00000000..fc65988c
--- /dev/null
+++ b/apps/blocks/src/components/theme-playground/canvas/cards/upload-assets-card.tsx
@@ -0,0 +1,33 @@
+import { FileText, Upload } from 'lucide-react';
+
+import { Button } from '@constructive-io/ui/button';
+import { Item, ItemContent, ItemDescription, ItemMedia, ItemTitle } from '@constructive-io/ui/item';
+import { Progress } from '@constructive-io/ui/progress';
+
+import { SinkCard } from './sink-card';
+
+export function UploadAssetsCard() {
+ return (
+
+
+
+
Drop files here
+
SVG, PNG, PDF up to 25 MB
+
+ Browse files
+
+
+ -
+
+
+
+
+ brand-guidelines.pdf
+ 2.1 MB · uploading
+
+ 64%
+
+
+
+ );
+}
diff --git a/apps/blocks/src/components/theme-playground/canvas/cards/usage-card.tsx b/apps/blocks/src/components/theme-playground/canvas/cards/usage-card.tsx
new file mode 100644
index 00000000..ae7ec277
--- /dev/null
+++ b/apps/blocks/src/components/theme-playground/canvas/cards/usage-card.tsx
@@ -0,0 +1,17 @@
+import { BillingUsageOverview } from '@/blocks/billing/billing-usage-overview/billing-usage-overview';
+import {
+ billingShowcaseFormatOptions,
+ billingShowcaseOrganizationAccount,
+ billingShowcaseUsageResources,
+} from '@/lib/billing-showcase-fixtures';
+
+/** Real billing block — the org usage snapshot from the showcase fixtures. */
+export function UsageCard() {
+ return (
+
+ );
+}
diff --git a/apps/blocks/src/components/theme-playground/canvas/cards/webhooks-card.tsx b/apps/blocks/src/components/theme-playground/canvas/cards/webhooks-card.tsx
new file mode 100644
index 00000000..8b14f948
--- /dev/null
+++ b/apps/blocks/src/components/theme-playground/canvas/cards/webhooks-card.tsx
@@ -0,0 +1,46 @@
+import { Globe, MessageSquare, Siren, Trash2 } from 'lucide-react';
+
+import { Button } from '@constructive-io/ui/button';
+import { InputGroup, InputGroupAddon, InputGroupInput } from '@constructive-io/ui/input-group';
+
+import { SinkCard } from './sink-card';
+
+const ENDPOINTS = [
+ { icon: Globe, value: 'https://api.acme.dev/hooks/deploys', label: 'Deploys webhook' },
+ { icon: MessageSquare, value: 'https://hooks.slack.com/T0123/B04', label: 'Slack webhook' },
+ { icon: Siren, value: 'https://acme.pagerduty.com/alerts', label: 'PagerDuty webhook' },
+];
+
+export function WebhooksCard() {
+ return (
+
+
+ Add endpoint
+
+
+ Save
+
+ >
+ }
+ >
+ {ENDPOINTS.map((endpoint) => (
+
+
+
+
+
+
+
+
+
+
+
+ ))}
+
+ );
+}
diff --git a/apps/blocks/src/components/theme-playground/canvas/cards/your-theme-card.tsx b/apps/blocks/src/components/theme-playground/canvas/cards/your-theme-card.tsx
new file mode 100644
index 00000000..ab814260
--- /dev/null
+++ b/apps/blocks/src/components/theme-playground/canvas/cards/your-theme-card.tsx
@@ -0,0 +1,23 @@
+'use client';
+
+import { CodeBlock } from '@/components/docs/code-block';
+import { buildThemeCss } from '@/lib/theme-playground/draft';
+
+import { usePreviewThemeDraft } from '../../preview-document';
+import { SinkCard } from './sink-card';
+
+const PLACEHOLDER = '/* Default theme — no overrides needed */';
+
+/** Live readout of the draft's generated token overrides. */
+export function YourThemeCard() {
+ const draft = usePreviewThemeDraft();
+ const css = buildThemeCss(draft) ?? PLACEHOLDER;
+
+ return (
+
+
+ {css}
+
+
+ );
+}
diff --git a/apps/blocks/src/components/theme-playground/canvas/charts/area.tsx b/apps/blocks/src/components/theme-playground/canvas/charts/area.tsx
new file mode 100644
index 00000000..3e0fdb0d
--- /dev/null
+++ b/apps/blocks/src/components/theme-playground/canvas/charts/area.tsx
@@ -0,0 +1,96 @@
+import { useId } from 'react';
+
+import { cn } from '@/lib/utils';
+
+const VIEW_W = 300;
+const VIEW_H = 80;
+const PAD = 4;
+
+function smoothPath(points: { x: number; y: number }[]): string {
+ if (points.length < 2) return '';
+ let path = `M ${points[0].x} ${points[0].y}`;
+ for (let i = 0; i < points.length - 1; i++) {
+ const p0 = points[Math.max(i - 1, 0)];
+ const p1 = points[i];
+ const p2 = points[i + 1];
+ const p3 = points[Math.min(i + 2, points.length - 1)];
+ const c1x = p1.x + (p2.x - p0.x) / 6;
+ const c1y = p1.y + (p2.y - p0.y) / 6;
+ const c2x = p2.x - (p3.x - p1.x) / 6;
+ const c2y = p2.y - (p3.y - p1.y) / 6;
+ path += ` C ${c1x} ${c1y}, ${c2x} ${c2y}, ${p2.x} ${p2.y}`;
+ }
+ return path;
+}
+
+/**
+ * Smooth area chart: a stroked line over a soft vertical gradient fill.
+ * `baseline` draws three hairline grid lines on the border token.
+ */
+export function Area({
+ data,
+ color = 'var(--chart-1)',
+ strokeWidth = 1.75,
+ dots = false,
+ baseline = false,
+ label,
+ className,
+}: {
+ data: number[];
+ color?: string;
+ strokeWidth?: number;
+ dots?: boolean;
+ baseline?: boolean;
+ label?: string;
+ className?: string;
+}) {
+ const gradientId = useId();
+ const max = Math.max(...data, 1);
+ const min = Math.min(...data, 0);
+ const span = max - min || 1;
+ const points = data.map((value, index) => ({
+ x: (index / Math.max(data.length - 1, 1)) * VIEW_W,
+ y: PAD + (1 - (value - min) / span) * (VIEW_H - PAD * 2),
+ }));
+ const line = smoothPath(points);
+ const fill = `${line} L ${VIEW_W} ${VIEW_H} L 0 ${VIEW_H} Z`;
+ return (
+
+ {label ?
{label} : null}
+
+
+
+
+
+
+
+ {baseline
+ ? [0.25, 0.5, 0.75].map((fraction) => (
+
+ ))
+ : null}
+
+
+ {dots
+ ? points.map((point, index) => )
+ : null}
+
+
+ );
+}
diff --git a/apps/blocks/src/components/theme-playground/canvas/charts/bars.tsx b/apps/blocks/src/components/theme-playground/canvas/charts/bars.tsx
new file mode 100644
index 00000000..eef5c22a
--- /dev/null
+++ b/apps/blocks/src/components/theme-playground/canvas/charts/bars.tsx
@@ -0,0 +1,66 @@
+import { cn } from '@/lib/utils';
+
+const VIEW_W = 300;
+const VIEW_H = 80;
+
+/**
+ * Dependency-free vertical bar chart. Stretches to its container via
+ * `preserveAspectRatio="none"`; labels render in HTML so font and tabular
+ * numbers stay on the design system.
+ */
+export function Bars({
+ data,
+ labels,
+ color = 'var(--chart-1)',
+ highlightLast = false,
+ rx = 3,
+ barGap = 6,
+ label,
+ className,
+}: {
+ data: number[];
+ labels?: string[];
+ color?: string;
+ highlightLast?: boolean;
+ rx?: number;
+ barGap?: number;
+ label?: string;
+ className?: string;
+}) {
+ const max = Math.max(...data, 1);
+ const barWidth = (VIEW_W - barGap * (data.length - 1)) / data.length;
+ return (
+
+ {label ?
{label} : null}
+
+ {data.map((value, index) => {
+ const height = Math.max((value / max) * VIEW_H, 1);
+ return (
+
+ );
+ })}
+
+ {labels ? (
+
+ {labels.map((text, index) => (
+ {text}
+ ))}
+
+ ) : null}
+
+ );
+}
diff --git a/apps/blocks/src/components/theme-playground/canvas/charts/donut.tsx b/apps/blocks/src/components/theme-playground/canvas/charts/donut.tsx
new file mode 100644
index 00000000..f150a31e
--- /dev/null
+++ b/apps/blocks/src/components/theme-playground/canvas/charts/donut.tsx
@@ -0,0 +1,59 @@
+import type { ReactNode } from 'react';
+
+import { cn } from '@/lib/utils';
+
+/**
+ * Segmented donut drawn with `pathLength`-normalized circle strokes. Children
+ * render in an HTML center slot so the big value keeps the design-system font.
+ */
+export function Donut({
+ segments,
+ thickness = 12,
+ startAngle = -90,
+ size = 120,
+ label,
+ className,
+ children,
+}: {
+ segments: { value: number; color: string }[];
+ thickness?: number;
+ startAngle?: number;
+ size?: number;
+ label?: string;
+ className?: string;
+ children?: ReactNode;
+}) {
+ const total = segments.reduce((sum, s) => sum + s.value, 0) || 1;
+ const radius = 50 - thickness / 2;
+ let offset = 0;
+ return (
+
+ {label ?
{label} : null}
+
+
+ {segments.map((segment, index) => {
+ const fraction = segment.value / total;
+ const dash = fraction * 100;
+ const current = offset;
+ offset += dash;
+ return (
+
+ );
+ })}
+
+
+ {children ?
{children}
: null}
+
+ );
+}
diff --git a/apps/blocks/src/components/theme-playground/canvas/charts/heatmap.tsx b/apps/blocks/src/components/theme-playground/canvas/charts/heatmap.tsx
new file mode 100644
index 00000000..a65dbccd
--- /dev/null
+++ b/apps/blocks/src/components/theme-playground/canvas/charts/heatmap.tsx
@@ -0,0 +1,48 @@
+import { cn } from '@/lib/utils';
+
+const LEVELS = [0, 0.2, 0.4, 0.6, 0.8, 1];
+
+/**
+ * Contributions-style grid: `weeks` columns × 7 rows, column-major `values`
+ * in 0–1, quantized to five opacity steps of a single color. Level 0 falls
+ * back to `bg-muted`.
+ */
+export function Heatmap({
+ values,
+ weeks,
+ color = 'var(--chart-1)',
+ label,
+ className,
+}: {
+ values: number[];
+ weeks: number;
+ color?: string;
+ label?: string;
+ className?: string;
+}) {
+ return (
+
+ {label ?
{label} : null}
+
+ {Array.from({ length: weeks * 7 }, (_, index) => {
+ const value = Math.min(Math.max(values[index] ?? 0, 0), 1);
+ if (value <= 0) {
+ return
;
+ }
+ const step = Math.min(Math.ceil(value * (LEVELS.length - 1)), LEVELS.length - 1);
+ return (
+
+ );
+ })}
+
+
+ );
+}
diff --git a/apps/blocks/src/components/theme-playground/canvas/charts/radial-bars.tsx b/apps/blocks/src/components/theme-playground/canvas/charts/radial-bars.tsx
new file mode 100644
index 00000000..08062045
--- /dev/null
+++ b/apps/blocks/src/components/theme-playground/canvas/charts/radial-bars.tsx
@@ -0,0 +1,61 @@
+import { cn } from '@/lib/utils';
+
+/**
+ * Concentric progress arcs (sleep-report archetype). Rings render outermost
+ * first; `value / max` sets each arc's sweep.
+ */
+export function RadialBars({
+ rings,
+ thickness = 9,
+ gap = 5,
+ size = 140,
+ label,
+ className,
+}: {
+ rings: { value: number; max: number; color: string }[];
+ thickness?: number;
+ gap?: number;
+ size?: number;
+ label?: string;
+ className?: string;
+}) {
+ return (
+
+ {label ? {label} : null}
+
+
+ {rings.map((ring, index) => {
+ const radius = 50 - thickness / 2 - index * (thickness + gap);
+ if (radius <= 0) return null;
+ const fraction = Math.min(ring.value / ring.max, 1);
+ return (
+
+
+
+
+ );
+ })}
+
+
+
+ );
+}
diff --git a/apps/blocks/src/components/theme-playground/canvas/charts/stacked-bars.tsx b/apps/blocks/src/components/theme-playground/canvas/charts/stacked-bars.tsx
new file mode 100644
index 00000000..0da467b1
--- /dev/null
+++ b/apps/blocks/src/components/theme-playground/canvas/charts/stacked-bars.tsx
@@ -0,0 +1,87 @@
+import { useId } from 'react';
+
+import { cn } from '@/lib/utils';
+
+const VIEW_W = 300;
+const VIEW_H = 80;
+
+/**
+ * Stacked vertical bars. `series` stacks bottom-up in array order; each column
+ * is scaled against the largest column total.
+ */
+export function StackedBars({
+ series,
+ labels,
+ rx = 2,
+ barGap = 6,
+ label,
+ className,
+}: {
+ series: { color: string; values: number[] }[];
+ labels?: string[];
+ rx?: number;
+ barGap?: number;
+ label?: string;
+ className?: string;
+}) {
+ const clipId = useId();
+ const columns = Math.max(...series.map((s) => s.values.length), 0);
+ const totals = Array.from({ length: columns }, (_, i) => series.reduce((sum, s) => sum + (s.values[i] ?? 0), 0));
+ const max = Math.max(...totals, 1);
+ const barWidth = (VIEW_W - barGap * (columns - 1)) / columns;
+ return (
+
+ {label ?
{label} : null}
+
+ {totals.map((_, column) => {
+ let y = VIEW_H;
+ // Only the topmost segment rounds its top corners — rounding every
+ // segment notches the stack where two colours meet. Drawn bottom-up
+ // with a clipPath so the lower segments stay square.
+ const topIndex = series.reduce((top, s, i) => ((s.values[column] ?? 0) > 0 ? i : top), -1);
+ return series.map((s, seriesIndex) => {
+ const height = ((s.values[column] ?? 0) / max) * VIEW_H;
+ y -= height;
+ const isTop = seriesIndex === topIndex;
+ const x = column * (barWidth + barGap);
+ return (
+
+ {isTop ? (
+ // Rounded rect that extends below the segment; the clip keeps only the segment's own band.
+ <>
+
+
+
+
+ >
+ ) : (
+
+ )}
+
+ );
+ });
+ })}
+
+ {labels ? (
+
+ {labels.map((text, index) => (
+ {text}
+ ))}
+
+ ) : null}
+
+ );
+}
diff --git a/apps/blocks/src/components/theme-playground/canvas/charts/waveform.tsx b/apps/blocks/src/components/theme-playground/canvas/charts/waveform.tsx
new file mode 100644
index 00000000..1f60e0c2
--- /dev/null
+++ b/apps/blocks/src/components/theme-playground/canvas/charts/waveform.tsx
@@ -0,0 +1,209 @@
+'use client';
+
+import { useEffect, useRef } from 'react';
+
+import { cn } from '@/lib/utils';
+
+/** Deterministic per-bar jitter so the stream looks organic but stable. */
+function seed(index: number): number {
+ const x = Math.sin(index * 12.9898) * 43758.5453;
+ return x - Math.floor(x);
+}
+
+const clamp = (v: number) => Math.min(1, Math.max(0.05, v));
+
+/**
+ * Synthetic "statements per second" sample — layered sines plus a slowly
+ * drifting seeded component. Deterministic per absolute tick.
+ */
+function sample(tick: number): number {
+ const t = tick / 30;
+ const w =
+ 0.45 + 0.28 * Math.sin(t * 0.9) + 0.16 * Math.sin(t * 2.3 + 1.7) + 0.12 * (seed(Math.floor(tick / 4)) - 0.5);
+ return clamp(w);
+}
+
+/**
+ * Animated canvas waveform (live-stream archetype, synthetic data only).
+ * - `active` animates bars: `static` mirrors bars around the centre,
+ * `scrolling` pushes history right-to-left.
+ * - `processing` (while inactive) renders a gentle centred sine pulse.
+ * - Neither fades the last data to a dotted idle baseline over ~0.5 s.
+ * The rAF loop pauses while `document.hidden`; `prefers-reduced-motion`
+ * renders a single static frame. Bar color = computed `color`.
+ */
+export function Waveform({
+ active = false,
+ processing = false,
+ mode = 'scrolling',
+ barWidth = 3,
+ barGap = 2,
+ historySize = 120,
+ label,
+ className,
+}: {
+ active?: boolean;
+ processing?: boolean;
+ mode?: 'static' | 'scrolling';
+ barWidth?: number;
+ barGap?: number;
+ historySize?: number;
+ label?: string;
+ className?: string;
+}) {
+ const canvasRef = useRef
(null);
+ const historyRef = useRef([]);
+ const staticBarsRef = useRef([]);
+ const lastActiveRef = useRef([]);
+ const blendRef = useRef(0);
+ const fadeRef = useRef(0);
+
+ useEffect(() => {
+ const canvas = canvasRef.current;
+ const context = canvas?.getContext('2d');
+ if (!canvas || !context) return;
+
+ const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
+ let frame = 0;
+ let tick = 0;
+ let time = 0;
+
+ const draw = () => {
+ const dpr = window.devicePixelRatio || 1;
+ const width = canvas.clientWidth;
+ const height = canvas.clientHeight;
+ if (width === 0 || height === 0) return;
+ if (canvas.width !== width * dpr || canvas.height !== height * dpr) {
+ canvas.width = width * dpr;
+ canvas.height = height * dpr;
+ }
+ context.setTransform(dpr, 0, 0, dpr, 0, 0);
+ context.clearRect(0, 0, width, height);
+ context.fillStyle = getComputedStyle(canvas).color;
+
+ const step = barWidth + barGap;
+ const centerY = height / 2;
+ const data = mode === 'static' ? staticBarsRef.current : historyRef.current;
+ for (let i = 0; i < data.length; i++) {
+ const value = data[i] ?? 0.05;
+ const x = mode === 'static' ? i * step : width - (data.length - i) * step;
+ const barHeight = Math.max(3, value * height * 0.8);
+ context.globalAlpha = 0.4 + value * 0.6;
+ context.beginPath();
+ context.roundRect(x, centerY - barHeight / 2, barWidth, barHeight, barWidth / 2);
+ context.fill();
+ }
+ context.globalAlpha = 1;
+ };
+
+ /** Advance the synthetic data one frame. */
+ const stepData = () => {
+ tick += 1;
+ const width = canvas.clientWidth || 200;
+ const count = Math.floor((width + barGap) / (barWidth + barGap));
+
+ if (active) {
+ fadeRef.current = 0;
+ blendRef.current = Math.min(1, blendRef.current + 0.05);
+ if (mode === 'static') {
+ // Symmetric bars around the centre, animated by tick.
+ const bars: number[] = [];
+ for (let i = 0; i < count; i++) {
+ const m = Math.min(i, count - 1 - i);
+ const w =
+ 0.45 +
+ 0.3 * Math.sin(m * 0.55 + tick * 0.045) +
+ 0.18 * Math.sin(m * 1.7 - tick * 0.07) +
+ 0.12 * (seed(m) - 0.5);
+ bars.push(clamp(w));
+ }
+ staticBarsRef.current = bars;
+ lastActiveRef.current = bars;
+ } else {
+ // History pushed right-to-left.
+ const history = historyRef.current;
+ history.push(sample(tick));
+ if (history.length > historySize) history.shift();
+ lastActiveRef.current = [...history];
+ }
+ return;
+ }
+
+ if (processing) {
+ fadeRef.current = 0;
+ blendRef.current = Math.min(1, blendRef.current + 0.02);
+ time += 0.03;
+ // Gentle centred sine pulse for the processing state.
+ const bars: number[] = [];
+ const half = count / 2;
+ for (let i = 0; i < count; i++) {
+ const pos = mode === 'static' ? (i - half) / half : i;
+ const weight = 1 - Math.abs(mode === 'static' ? pos : (i - half) / half) * 0.4;
+ const wave =
+ Math.sin(time * 1.5 + pos * (mode === 'static' ? 3 : 0.15)) * 0.25 +
+ Math.sin(time * 0.8 - pos * (mode === 'static' ? 2 : 0.1)) * 0.2 +
+ Math.cos(time * 2 + pos * (mode === 'static' ? 1 : 0.05)) * 0.15;
+ let value = (0.2 + wave) * weight;
+ const last = lastActiveRef.current;
+ if (last.length > 0 && blendRef.current < 1) {
+ const li = mode === 'static' ? Math.min(i, last.length - 1) : Math.floor((i / count) * last.length);
+ value = (last[li] || 0) * (1 - blendRef.current) + value * blendRef.current;
+ }
+ bars.push(clamp(value));
+ }
+ if (mode === 'static') staticBarsRef.current = bars;
+ else historyRef.current = bars.slice(-historySize);
+ return;
+ }
+
+ // Idle: fade the last data to the baseline over ~0.5 s.
+ const data = mode === 'static' ? staticBarsRef.current : historyRef.current;
+ if (data.length === 0) return;
+ blendRef.current = 0;
+ fadeRef.current += 0.03;
+ if (fadeRef.current < 1) {
+ const faded = data.map((v) => v * (1 - fadeRef.current));
+ if (mode === 'static') staticBarsRef.current = faded;
+ else historyRef.current = faded;
+ } else {
+ staticBarsRef.current = [];
+ historyRef.current = [];
+ }
+ };
+
+ const loop = () => {
+ stepData();
+ draw();
+ frame = requestAnimationFrame(loop);
+ };
+ const onVisibility = () => {
+ if (document.hidden) {
+ cancelAnimationFrame(frame);
+ } else {
+ loop();
+ }
+ };
+
+ if (reduced) {
+ stepData();
+ draw();
+ return;
+ }
+ loop();
+ document.addEventListener('visibilitychange', onVisibility);
+ return () => {
+ cancelAnimationFrame(frame);
+ document.removeEventListener('visibilitychange', onVisibility);
+ };
+ }, [active, processing, mode, barWidth, barGap, historySize]);
+
+ return (
+
+ {label ?
{label} : null}
+ {!active && !processing ? (
+
+ ) : null}
+
+
+ );
+}
diff --git a/apps/blocks/src/components/theme-playground/canvas/wall-assistant.tsx b/apps/blocks/src/components/theme-playground/canvas/wall-assistant.tsx
new file mode 100644
index 00000000..6a9d5d43
--- /dev/null
+++ b/apps/blocks/src/components/theme-playground/canvas/wall-assistant.tsx
@@ -0,0 +1,29 @@
+import { FilesChatCard } from './cards/files-chat-card';
+import { PlanChatCard } from './cards/plan-chat-card';
+import { ReasoningChatCard } from './cards/reasoning-chat-card';
+import { SimpleChatCard } from './cards/simple-chat-card';
+import { ToolChatCard } from './cards/tool-chat-card';
+import { stagger, WallShell } from './wall-shell';
+
+/** Wall 03 · assistant — five full-height chat columns, one per card. */
+export function WallAssistant() {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/apps/blocks/src/components/theme-playground/canvas/wall-platform.tsx b/apps/blocks/src/components/theme-playground/canvas/wall-platform.tsx
new file mode 100644
index 00000000..353af16e
--- /dev/null
+++ b/apps/blocks/src/components/theme-playground/canvas/wall-platform.tsx
@@ -0,0 +1,93 @@
+import { AuditEventsCard } from './cards/audit-events-card';
+import { AutoscalingCard } from './cards/autoscaling-card';
+import { BackupDestinationCard } from './cards/backup-destination-card';
+import { ComputeBalanceCard } from './cards/compute-balance-card';
+import { ComputeSpendCard } from './cards/compute-spend-card';
+import { ComputeUsageCard } from './cards/compute-usage-card';
+import { ConnectCliCard } from './cards/connect-cli-card';
+import { ConnectPaymentEmptyCard } from './cards/connect-payment-empty-card';
+import { ConnectionPoolCard } from './cards/connection-pool-card';
+import { CreateAlertRuleCard } from './cards/create-alert-rule-card';
+import { CreditsBalanceCard } from './cards/credits-balance-card';
+import { DigestSettingsCard } from './cards/digest-settings-card';
+import { EgressQuarterCard } from './cards/egress-quarter-card';
+import { ExploreCatalogEmptyCard } from './cards/explore-catalog-empty-card';
+import { FaqCard } from './cards/faq-card';
+import { FeaturePackCatalogCard } from './cards/feature-pack-catalog-card';
+import { LatencyHistoryCard } from './cards/latency-history-card';
+import { MigrationMilestonesCard } from './cards/migration-milestones-card';
+import { PaymentMethodsCard } from './cards/payment-methods-card';
+import { PrimaryDatabaseCard } from './cards/primary-database-card';
+import { ProjectIconCard } from './cards/project-icon-card';
+import { ProjectPreferencesCard } from './cards/project-preferences-card';
+import { PublishPackEmptyCard } from './cards/publish-pack-empty-card';
+import { ReadReplicasCard } from './cards/read-replicas-card';
+import { RecoveryAccessCard } from './cards/recovery-access-card';
+import { SidebarNavCard } from './cards/sidebar-nav-card';
+import { SkeletonRowsCard } from './cards/skeleton-rows-card';
+import { SpendAlertCard } from './cards/spend-alert-card';
+import { StorageQuotaCard } from './cards/storage-quota-card';
+import { SyncingSchemaCard } from './cards/syncing-schema-card';
+import { TransferCreditsCard } from './cards/transfer-credits-card';
+import { UpcomingMaintenanceCard } from './cards/upcoming-maintenance-card';
+import { WebhooksCard } from './cards/webhooks-card';
+import { COLUMN_CLASS, stagger, WallShell, WIDE_COLUMN_CLASS } from './wall-shell';
+
+/**
+ * Wall 01 · platform — billing, org, and project state. Seven tracks: C3 is
+ * the wide column (2 tracks) and carries the audit table plus the 2×2
+ * navigation/forms subgrid.
+ */
+export function WallPlatform() {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/apps/blocks/src/components/theme-playground/canvas/wall-shell.tsx b/apps/blocks/src/components/theme-playground/canvas/wall-shell.tsx
new file mode 100644
index 00000000..dc6769aa
--- /dev/null
+++ b/apps/blocks/src/components/theme-playground/canvas/wall-shell.tsx
@@ -0,0 +1,50 @@
+import type { CSSProperties, ReactNode } from 'react';
+import { Bell, Plus, Search } from 'lucide-react';
+
+import { Badge } from '@constructive-io/ui/badge';
+import { Button } from '@constructive-io/ui/button';
+import { Input } from '@constructive-io/ui/input';
+
+import { cn } from '@/lib/utils';
+import type { WallId } from '@/lib/theme-playground/walls';
+
+export function PreviewTopBar() {
+ return (
+
+
Constructive
+
Preview
+
+
+
+
+
+
+
+
+
+ New project
+
+
+ );
+}
+
+/** Skips render work for off-viewport columns on the wall. */
+export const COLUMN_CLASS = 'flex flex-col gap-6 [content-visibility:auto] [contain-intrinsic-size:352px_1400px]';
+export const WIDE_COLUMN_CLASS =
+ 'col-span-2 flex flex-col gap-6 [content-visibility:auto] [contain-intrinsic-size:728px_1400px]';
+
+/** Per-column entrance delay — cards read it through `animate-fade-up`. */
+export const stagger = (index: number) => ({ '--stagger': index }) as CSSProperties;
+
+/** Fixed-width wall — columns stay stable while the viewport scrolls. */
+export function WallShell({ wall, tracks, children }: { wall: WallId; tracks: string; children: ReactNode }) {
+ return (
+
+ );
+}
diff --git a/apps/blocks/src/components/theme-playground/canvas/wall-workspace.tsx b/apps/blocks/src/components/theme-playground/canvas/wall-workspace.tsx
new file mode 100644
index 00000000..ab293bf4
--- /dev/null
+++ b/apps/blocks/src/components/theme-playground/canvas/wall-workspace.tsx
@@ -0,0 +1,124 @@
+import { AccountSecurityCard } from './cards/account-security-card';
+import { ActiveConnectionsCard } from './cards/active-connections-card';
+import { ActivityPrefsCard } from './cards/activity-prefs-card';
+import { AlertsCard } from './cards/alerts-card';
+import { AnalyticsCard } from './cards/analytics-card';
+import { AnomalyAlertCard } from './cards/anomaly-alert-card';
+import { ApiKeysCard } from './cards/api-keys-card';
+import { AssignIssueCard } from './cards/assign-issue-card';
+import { AssistantCard } from './cards/assistant-card';
+import { BillingAddressCard } from './cards/billing-address-card';
+import { ClientShareCard } from './cards/client-share-card';
+import { CommandPaletteCard } from './cards/command-palette-card';
+import { CreateTableCard } from './cards/create-table-card';
+import { DatabaseStatusCard } from './cards/database-status-card';
+import { DeployLoadCard } from './cards/deploy-load-card';
+import { DeploymentsCard } from './cards/deployments-card';
+import { DockCard } from './cards/dock-card';
+import { EnvironmentsCard } from './cards/environments-card';
+import { FeedbackCard } from './cards/feedback-card';
+import { IconGridCard } from './cards/icon-grid-card';
+import { InviteTeammateCard } from './cards/invite-teammate-card';
+import { InvoiceCard } from './cards/invoice-card';
+import { LabelsCard } from './cards/labels-card';
+import { LatencyReportCard } from './cards/latency-report-card';
+import { LiveQueryStreamCard } from './cards/live-query-stream-card';
+import { LoadingCard } from './cards/loading-card';
+import { NavigationCard } from './cards/navigation-card';
+import { NoSchemasCard } from './cards/no-schemas-card';
+import { NotFoundCard } from './cards/not-found-card';
+import { NotificationsCard } from './cards/notifications-card';
+import { ObservabilityCard } from './cards/observability-card';
+import { PlanBillingCard } from './cards/plan-billing-card';
+import { PlanTierCard } from './cards/plan-tier-card';
+import { ProfileCard } from './cards/profile-card';
+import { QueryVolumeCard } from './cards/query-volume-card';
+import { ReportIssueCard } from './cards/report-issue-card';
+import { ScheduleMaintenanceCard } from './cards/schedule-maintenance-card';
+import { SchemaTabsCard } from './cards/schema-tabs-card';
+import { SetupProjectCard } from './cards/setup-project-card';
+import { ShortcutsCard } from './cards/shortcuts-card';
+import { StorageCard } from './cards/storage-card';
+import { StyleOverviewCard } from './cards/style-overview-card';
+import { TeamCard } from './cards/team-card';
+import { TypographySpecimenCard } from './cards/typography-specimen-card';
+import { UiElementsCard } from './cards/ui-elements-card';
+import { UploadAssetsCard } from './cards/upload-assets-card';
+import { UsageCard } from './cards/usage-card';
+import { YourThemeCard } from './cards/your-theme-card';
+import { COLUMN_CLASS, stagger, WallShell, WIDE_COLUMN_CLASS } from './wall-shell';
+
+/**
+ * Wall 02 · workspace — product archetypes. Eight tracks; C3 is the wide
+ * column with the UI-elements tray and the 2×2 forms subgrid.
+ */
+export function WallWorkspace() {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/apps/blocks/src/components/theme-playground/dial-panel.tsx b/apps/blocks/src/components/theme-playground/dial-panel.tsx
new file mode 100644
index 00000000..3a0abbd8
--- /dev/null
+++ b/apps/blocks/src/components/theme-playground/dial-panel.tsx
@@ -0,0 +1,493 @@
+'use client';
+
+import dynamic from 'next/dynamic';
+import { memo, useCallback, useEffect, useRef, useState, type ReactNode } from 'react';
+import { Radio } from '@base-ui/react/radio';
+import { RadioGroup } from '@base-ui/react/radio-group';
+import { Code, Monitor, Moon, RotateCcw, Sun } from 'lucide-react';
+import { useTheme } from 'next-themes';
+import { useDialKitController, DialRoot, type DialKitValueUpdates } from 'dialkit';
+
+import { constructiveTheme } from '@constructive-io/ui/theme';
+import { buildThemeDialConfig, dialValuesToTuning, tuningToDialValues } from '@constructive-io/ui/theme-dials-config';
+import { THEME_PRESETS, type ThemePresetId } from '@constructive-io/ui/theme-presets';
+import { defaultThemeTuning, parseOklch, type ModeTuning } from '@constructive-io/ui/theme-tuning';
+import { Badge } from '@constructive-io/ui/badge';
+import { Button } from '@constructive-io/ui/button';
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@constructive-io/ui/select';
+
+import { contrastRatio } from '@/lib/theme-playground/color';
+import {
+ ACCENT_PRESETS,
+ DEFAULT_DRAFT,
+ applyAccentPreset,
+ applyElevationPreset,
+ applyMotionPreset,
+ applyNeutralPreset,
+ applyRadiusPreset,
+ applyThemePreset,
+ buildThemeCss,
+ clearThemePreset,
+ detectAccentPreset,
+ detectElevationPreset,
+ detectMotionPreset,
+ detectNeutralPreset,
+ detectRadiusPreset,
+ draftAccentValue,
+ encodeDraft,
+ ELEVATION_PRESETS,
+ FONT_PRESETS,
+ fontIdForStack,
+ MOTION_PRESETS,
+ NEUTRAL_PRESETS,
+ RADIUS_PRESETS,
+ randomDraft,
+ type AccentId,
+ type ColorMode,
+ type ElevationId,
+ type MotionId,
+ type NeutralId,
+ type RadiusId,
+ type ThemeDraft,
+} from '@/lib/theme-playground/draft';
+import { cn } from '@/lib/utils';
+
+import { useThemeDraft } from './use-theme-draft';
+
+const DEFAULT_ENCODED = encodeDraft(DEFAULT_DRAFT);
+
+// CodeBlock + sugar-high + InstallToggle only load after the first
+// "Get code" click; once mounted the dialog stays mounted so later closes
+// keep their exit animation.
+const GetCodeDialog = dynamic(() => import('./get-code-dialog').then((m) => m.GetCodeDialog), {
+ ssr: false,
+});
+
+/* ------------------------------------------------------------------ */
+/* DialKit config — shared with the Storybook theme dials */
+/* ------------------------------------------------------------------ */
+
+const DEFAULTS = defaultThemeTuning();
+
+const DIAL_CONFIG = {
+ ...buildThemeDialConfig(
+ DEFAULTS,
+ FONT_PRESETS.map((preset) => ({ value: preset.exportStack, label: preset.label })),
+ ),
+ actions: {
+ shuffle: { type: 'action' as const, label: 'Shuffle' },
+ reset: { type: 'action' as const, label: 'Reset' },
+ copyCss: { type: 'action' as const, label: 'Copy CSS' },
+ share: { type: 'action' as const, label: 'Copy share link' },
+ },
+};
+
+/* ------------------------------------------------------------------ */
+/* Host-owned controls */
+/* ------------------------------------------------------------------ */
+
+const SEGMENT_ITEM =
+ 'inline-flex flex-1 items-center justify-center gap-1.5 rounded-md px-2 py-1.5 text-[12px] font-medium text-muted-foreground outline-none transition-[box-shadow] duration-(--duration-moderate) ease-out hover:text-foreground focus-visible:ring-[3px] focus-visible:ring-ring/50 data-checked:bg-background data-checked:text-foreground data-checked:shadow-sm motion-reduce:transition-none';
+
+const MODE_OPTIONS: { value: ColorMode; label: string; icon: ReactNode }[] = [
+ { value: 'light', label: 'Light', icon: },
+ { value: 'dark', label: 'Dark', icon: },
+ { value: 'system', label: 'System', icon: },
+];
+
+function ModeControl({ value, onChange }: { value: ColorMode; onChange: (mode: ColorMode) => void }) {
+ return (
+ onChange(next as ColorMode)}
+ aria-label="Preview mode"
+ className="flex rounded-lg bg-muted/70 p-0.5"
+ >
+ {MODE_OPTIONS.map((option) => (
+
+ {option.icon}
+ {option.label}
+
+ ))}
+
+ );
+}
+
+const MODE_TUNING_KEYS = Object.keys(DEFAULTS.light) as (keyof ModeTuning)[];
+
+/** Shipped-token swatches for the default ("Constructive") tile. */
+function constructiveSwatches(mode: 'light' | 'dark'): string[] {
+ const tokens = constructiveTheme[mode];
+ return [tokens.background, tokens.muted, tokens.primary, tokens['chart-2'], tokens['chart-3']];
+}
+
+function ThemeTile({
+ label,
+ swatches,
+ selected,
+ onSelect,
+}: {
+ label: string;
+ swatches: string[];
+ selected: boolean;
+ onSelect: () => void;
+}) {
+ return (
+
+
+ {swatches.map((color, index) => (
+
+ ))}
+
+ {label}
+
+ );
+}
+
+function PresetSelect({
+ label,
+ value,
+ options,
+ onChange,
+}: {
+ label: string;
+ value: T | null;
+ options: readonly { id: T; label: string }[];
+ onChange: (id: T) => void;
+}) {
+ return (
+
+ {label}
+ onChange(next as T)}
+ items={Object.fromEntries(options.map((option) => [option.id, option.label]))}
+ >
+
+
+
+
+ {options.map((option) => (
+
+ {option.label}
+
+ ))}
+
+
+
+ );
+}
+
+interface ThemePresetPickerProps {
+ theme: ThemePresetId | null;
+ previewMode: 'light' | 'dark';
+ onSelect: (id: ThemePresetId | null) => void;
+}
+
+const ThemePresetPicker = memo(function ThemePresetPicker({ theme, previewMode, onSelect }: ThemePresetPickerProps) {
+ return (
+
+ onSelect(null)}
+ />
+ {THEME_PRESETS.map((preset) => (
+ onSelect(preset.id)}
+ />
+ ))}
+
+ );
+});
+
+interface PresetRowProps {
+ neutral: NeutralId | null;
+ accent: AccentId | null;
+ radius: RadiusId | null;
+ elevation: ElevationId | null;
+ motion: MotionId | null;
+ onNeutral: (id: NeutralId) => void;
+ onAccent: (id: AccentId) => void;
+ onRadius: (id: RadiusId) => void;
+ onElevation: (id: ElevationId) => void;
+ onMotion: (id: MotionId) => void;
+}
+
+const PresetRow = memo(function PresetRow({
+ neutral,
+ accent,
+ radius,
+ elevation,
+ motion,
+ onNeutral,
+ onAccent,
+ onRadius,
+ onElevation,
+ onMotion,
+}: PresetRowProps) {
+ return (
+
+
label="Neutral" value={neutral} options={NEUTRAL_PRESETS} onChange={onNeutral} />
+ label="Accent" value={accent} options={ACCENT_PRESETS} onChange={onAccent} />
+ label="Radius" value={radius} options={RADIUS_PRESETS} onChange={onRadius} />
+
+ label="Elevation"
+ value={elevation}
+ options={ELEVATION_PRESETS}
+ onChange={onElevation}
+ />
+ label="Motion" value={motion} options={MOTION_PRESETS} onChange={onMotion} />
+
+ );
+});
+
+/** WCAG contrast of the active accent against its foreground (worst of both modes). */
+function AccentContrastBadge({ draft }: { draft: ThemeDraft }) {
+ const ratios = (['light', 'dark'] as const).map((mode) => {
+ const accent = draftAccentValue(draft, mode);
+ const shippedFg = parseOklch(constructiveTheme[mode]['primary-foreground']);
+ return contrastRatio(accent, {
+ l: accent.foregroundL,
+ c: shippedFg.c,
+ h: shippedFg.h,
+ });
+ });
+ const worst = Math.min(...ratios);
+ // WCAG tiers for text on the accent fill: 4.5:1 for body text, 3:1 for
+ // large or bold text (the tier the brand-action blue is specified against).
+ const tier = worst >= 4.5 ? 'AA' : worst >= 3 ? 'AA large' : 'Below AA';
+ return (
+ = 3 ? 'success' : 'warning'}
+ title={`Light ${ratios[0].toFixed(2)}:1 · Dark ${ratios[1].toFixed(2)}:1 — ${
+ worst >= 4.5 ? 'passes AA for body text' : worst >= 3 ? 'passes AA for large or bold text' : 'below AA'
+ }`}
+ className="tabular-nums"
+ >
+ {tier} {worst.toFixed(1)}:1
+
+ );
+}
+
+/* ------------------------------------------------------------------ */
+/* Panel */
+/* ------------------------------------------------------------------ */
+
+/**
+ * The Create panel: host-owned header / preset selects / preview-mode switch
+ * around an embedded DialKit surface (`mode="inline"`).
+ *
+ * Two-way sync is guarded both directions so neither side echoes:
+ * - draft → dials: the draft's encoded form is recorded as "synced" before
+ * `setValues`, so when the store echoes those values back the dial→draft
+ * effect sees equality and skips.
+ * - dials → draft: the derived draft's encoded form is recorded before
+ * `setDraft`, so the draft→dials effect sees equality and skips.
+ * - The dial→draft effect stays unarmed until the store has echoed the
+ * pushed draft once. DialKit emits config-default (or persisted) values
+ * more than once during mount — a single skipped run is not enough, and
+ * any later stale emission would stomp a `?d=`/localStorage draft. While
+ * unarmed, diverging emissions re-assert the draft into the store so the
+ * store converges to the draft rather than the reverse.
+ */
+export function DialPanel() {
+ const { draft, setDraft, reset, shareUrl } = useThemeDraft();
+ const { resolvedTheme } = useTheme();
+ const [codeOpen, setCodeOpen] = useState(false);
+ const [codeMounted, setCodeMounted] = useState(false);
+ const previewMode: 'light' | 'dark' =
+ draft.mode === 'system' ? (resolvedTheme === 'dark' ? 'dark' : 'light') : draft.mode;
+ const isDefault = encodeDraft(draft) === DEFAULT_ENCODED;
+ const draftRef = useRef(draft);
+ draftRef.current = draft;
+ const syncedEncodedRef = useRef(null);
+ const primedRef = useRef(false);
+ // Unarmed re-asserts are bounded: if the store ever normalised a pushed
+ // value, an unbounded push/echo cycle would spin forever — after a few
+ // rounds the store's values win and become the draft.
+ const reassertsRef = useRef(0);
+ const MAX_REASSERTS = 3;
+
+ // Stable handlers — read the live draft through draftRef so the memoized
+ // picker/row props never change identity on a dial tick.
+ const onPresetSelect = useCallback(
+ (id: ThemePresetId | null) =>
+ setDraft(id === null ? clearThemePreset(draftRef.current) : applyThemePreset(draftRef.current, id)),
+ [setDraft],
+ );
+ const onNeutral = useCallback((id: NeutralId) => setDraft(applyNeutralPreset(draftRef.current, id)), [setDraft]);
+ const onAccent = useCallback((id: AccentId) => setDraft(applyAccentPreset(draftRef.current, id)), [setDraft]);
+ const onRadius = useCallback((id: RadiusId) => setDraft(applyRadiusPreset(draftRef.current, id)), [setDraft]);
+ const onElevation = useCallback(
+ (id: ElevationId) => setDraft(applyElevationPreset(draftRef.current, id)),
+ [setDraft],
+ );
+ const onMotion = useCallback((id: MotionId) => setDraft(applyMotionPreset(draftRef.current, id)), [setDraft]);
+
+ const controller = useDialKitController('Constructive theme', DIAL_CONFIG, {
+ onAction: (action) => {
+ if (action === 'shuffle') {
+ setDraft(randomDraft(draftRef.current));
+ } else if (action === 'reset') {
+ reset();
+ } else if (action === 'copyCss') {
+ void navigator.clipboard?.writeText(
+ buildThemeCss(draftRef.current) ?? '/* Draft matches the shipped theme — no overrides needed. */',
+ );
+ } else if (action === 'share') {
+ void navigator.clipboard?.writeText(shareUrl);
+ }
+ },
+ });
+ const { values, setValues } = controller;
+
+ // draft → dials
+ useEffect(() => {
+ const encoded = encodeDraft(draft);
+ if (syncedEncodedRef.current === encoded) return;
+ syncedEncodedRef.current = encoded;
+ setValues(tuningToDialValues(draft) as DialKitValueUpdates);
+ }, [draft, setValues]);
+
+ // dials → draft
+ useEffect(() => {
+ const tuning = dialValuesToTuning(values, DEFAULTS);
+ const font = fontIdForStack(tuning.fontSans) ?? draftRef.current.font;
+ const base = draftRef.current;
+ // Release the pin on every tuning field the dials actually changed — the
+ // rest of the preset stays exact. Only user-driven moves count: nothing is
+ // pinned without a theme, and until the store has echoed the pushed draft
+ // (primed) its emissions are DialKit defaults, not edits. Compare against
+ // the round-tripped base (the tuning the dial layer would express for
+ // `base` untouched): the shared neutral dial collapses per-mode hues and
+ // the accent foreground round-trips through its darkText toggle, so a raw
+ // compare would release fields nobody moved.
+ const released = {
+ light: new Set(base.released.light),
+ dark: new Set(base.released.dark),
+ };
+ if (primedRef.current && base.theme !== null) {
+ const expressed = dialValuesToTuning(tuningToDialValues(base), DEFAULTS);
+ for (const mode of ['light', 'dark'] as const) {
+ for (const key of MODE_TUNING_KEYS) {
+ if (Math.abs(tuning[mode][key] - expressed[mode][key]) > 1e-6) released[mode].add(key);
+ }
+ }
+ }
+ const next: ThemeDraft = {
+ ...tuning,
+ mode: base.mode,
+ font,
+ theme: base.theme,
+ released: { light: [...released.light], dark: [...released.dark] },
+ };
+ const encoded = encodeDraft(next);
+ if (!primedRef.current) {
+ if (encoded === syncedEncodedRef.current || reassertsRef.current >= MAX_REASSERTS) {
+ primedRef.current = true;
+ if (encoded === syncedEncodedRef.current) return;
+ } else {
+ reassertsRef.current += 1;
+ setValues(tuningToDialValues(base) as DialKitValueUpdates);
+ return;
+ }
+ }
+ if (encoded === syncedEncodedRef.current) return;
+ syncedEncodedRef.current = encoded;
+ setDraft(next);
+ }, [values, setDraft, setValues]);
+
+ return (
+
+ );
+}
diff --git a/apps/blocks/src/components/theme-playground/get-code-dialog.tsx b/apps/blocks/src/components/theme-playground/get-code-dialog.tsx
new file mode 100644
index 00000000..979072c5
--- /dev/null
+++ b/apps/blocks/src/components/theme-playground/get-code-dialog.tsx
@@ -0,0 +1,169 @@
+'use client';
+
+import { useState } from 'react';
+import { Bot, Check, Copy } from 'lucide-react';
+
+import { getThemePreset, themePresetRegistryName } from '@constructive-io/ui/theme-presets';
+import { Button } from '@constructive-io/ui/button';
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogPanel,
+ DialogTitle,
+} from '@constructive-io/ui/dialog';
+import { Tabs, TabsContent, TabsList, TabsTrigger } from '@constructive-io/ui/tabs';
+
+import { CodeBlock } from '@/components/docs/code-block';
+import { InstallToggle } from '@/components/docs/install-toggle';
+import { packageCommands, REGISTRY_COMPONENTS_JSON, registryCommands } from '@/lib/install-mode';
+import { buildRegistryItem, buildThemeCss, DEFAULT_DRAFT } from '@/lib/theme-playground/draft';
+
+import { useThemeDraft } from './use-theme-draft';
+
+const EMPTY_NOTE = 'You’re on the default Constructive theme — nothing to override.';
+
+function EmptyNote() {
+ return (
+
+ {EMPTY_NOTE}
+
+ );
+}
+
+export function GetCodeDialog({ open, onOpenChange }: { open: boolean; onOpenChange: (open: boolean) => void }) {
+ const { draft } = useThemeDraft();
+ const [tab, setTab] = useState('css');
+ const [copied, setCopied] = useState(false);
+ const css = buildThemeCss(draft);
+ const item = buildRegistryItem(draft);
+ const fontChanged = draft.font !== DEFAULT_DRAFT.font;
+
+ // A preset is installable by name only when the draft is the preset exactly —
+ // no released pins and every shared knob still at the default.
+ const presetExact =
+ draft.theme !== null &&
+ draft.released.light.length === 0 &&
+ draft.released.dark.length === 0 &&
+ draft.radius === DEFAULT_DRAFT.radius &&
+ draft.hairline === DEFAULT_DRAFT.hairline &&
+ draft.elevation === DEFAULT_DRAFT.elevation &&
+ draft.fontSans === DEFAULT_DRAFT.fontSans &&
+ JSON.stringify(draft.tiers) === JSON.stringify(DEFAULT_DRAFT.tiers);
+
+ const presetAdd = presetExact
+ ? `pnpm dlx shadcn@latest add @constructive/${themePresetRegistryName(draft.theme!)}`
+ : null;
+ const presetSnippet = presetAdd ? `${REGISTRY_COMPONENTS_JSON}\n\n${presetAdd}` : null;
+ const customScript = item
+ ? `cat > constructive-theme.json <<'JSON'\n${JSON.stringify(item, null, 2)}\nJSON\npnpm dlx shadcn@latest add ./constructive-theme.json && rm constructive-theme.json`
+ : null;
+ const installCode = presetSnippet ?? customScript;
+
+ const agentPrompt = presetExact
+ ? `Install the ${getThemePreset(draft.theme!).label} theme into my shadcn/Tailwind v4 project.
+1. Add the @constructive registry to components.json:
+ "registries": { "@constructive": "https://constructive-io.github.io/blocks/r/{name}.json" }
+2. Run: ${presetAdd} -y
+3. Confirm the CSS file referenced by components.json declares --primary, --background and --chart-1 in both :root and .dark.`
+ : item
+ ? `Install this theme into my shadcn/Tailwind v4 project.
+1. Save the JSON below as constructive-theme.json next to components.json.
+2. Run: pnpm dlx shadcn@latest add ./constructive-theme.json -y
+3. Delete constructive-theme.json.
+4. Confirm the CSS file referenced by components.json declares --primary, --background and --chart-1 in both :root and .dark.
+
+${JSON.stringify(item, null, 2)}`
+ : null;
+
+ const copyText = tab === 'css' ? css : installCode;
+
+ async function copy(value: string | null) {
+ if (!value) return;
+ try {
+ await navigator.clipboard.writeText(value);
+ setCopied(true);
+ window.setTimeout(() => setCopied(false), 1600);
+ } catch {
+ setCopied(false);
+ }
+ }
+
+ return (
+
+
+
+ Get code
+ Copy the CSS, or install it straight into a shadcn project.
+
+
+
+
+
+ CSS
+ Install
+
+
+
+ {css ? {css} : }
+
+
+
+ {installCode ? (
+ <>
+ {installCode}
+
+ shadcn merges the theme’s variables into your globals.css (
+ :root and{' '}
+ .dark) and overwrites existing
+ values — confirm the prompt or pass{' '}
+ -y.
+
+ >
+ ) : (
+
+ )}
+ {agentPrompt ? (
+ copy(agentPrompt)}>
+
+ Copy prompt for your agent
+
+ ) : null}
+
+
+ Don’t have the base theme yet?
+
+
+ {fontChanged ? (
+
+ Heads up: in registry mode{' '}
+ --font-sans lives in the{' '}
+ @theme inline block shadcn
+ generated — move your font override there, or load the font and point the variable at it.
+
+ ) : null}
+
+
+
+
+
+
+ copy(copyText)} disabled={!copyText}>
+ {copied ? : }
+ {copied ? 'Copied' : tab === 'css' ? 'Copy CSS' : 'Copy install command'}
+
+
+
+
+ );
+}
diff --git a/apps/blocks/src/components/theme-playground/preview-document.tsx b/apps/blocks/src/components/theme-playground/preview-document.tsx
new file mode 100644
index 00000000..d0535edb
--- /dev/null
+++ b/apps/blocks/src/components/theme-playground/preview-document.tsx
@@ -0,0 +1,179 @@
+'use client';
+
+import dynamic from 'next/dynamic';
+import { createContext, useContext, useEffect, useState } from 'react';
+
+import { DEFAULT_SPRING_TIERS, setSpringTiers, type SpringTiers } from '@constructive-io/ui';
+
+import {
+ isPreviewMessage,
+ postPreviewReady,
+ sanitizePreviewDraft,
+ sanitizePreviewWall,
+} from '@/lib/theme-playground/channel';
+import {
+ DEFAULT_DRAFT,
+ decodeDraft,
+ previewStyleSheet,
+ type ColorMode,
+ type ThemeDraft,
+} from '@/lib/theme-playground/draft';
+import { wallFromSearch, type WallId } from '@/lib/theme-playground/walls';
+
+const STYLE_TAG_ID = 'constructive-theme-preview';
+
+const PreviewDraftContext = createContext(DEFAULT_DRAFT);
+
+/** The draft currently driving the preview — read by cards that show live theme output. */
+export function usePreviewThemeDraft(): ThemeDraft {
+ return useContext(PreviewDraftContext);
+}
+
+function initialDraft(): ThemeDraft {
+ const encoded = new URLSearchParams(window.location.search).get('d');
+ return encoded ? decodeDraft(encoded) : { ...DEFAULT_DRAFT };
+}
+
+/**
+ * Initial draft from `?d=` and wall from `?wall=`, then live updates from the
+ * host via postMessage. Announces PREVIEW_READY once mounted so the host can
+ * start streaming drafts.
+ */
+function usePreviewDraft(): { draft: ThemeDraft | null; wall: WallId } {
+ const [draft, setDraft] = useState(null);
+ const [wall, setWall] = useState(() =>
+ typeof window === 'undefined' ? wallFromSearch('') : wallFromSearch(window.location.search),
+ );
+
+ useEffect(() => {
+ setDraft(initialDraft());
+ setWall(wallFromSearch(window.location.search));
+
+ const onMessage = (event: MessageEvent) => {
+ if (!isPreviewMessage(event, window.parent)) return;
+ setDraft(sanitizePreviewDraft(event.data));
+ setWall(sanitizePreviewWall(event.data));
+ };
+ window.addEventListener('message', onMessage);
+ if (window.parent !== window) {
+ postPreviewReady(window.parent);
+ }
+ return () => window.removeEventListener('message', onMessage);
+ }, []);
+
+ return { draft, wall };
+}
+
+/**
+ * Owns `` + `color-scheme` inside the iframe. The root layout's
+ * next-themes provider also writes that class, so we re-assert through a
+ * MutationObserver — equality guards keep the observer from looping.
+ */
+function useForcedColorMode(mode: ColorMode) {
+ useEffect(() => {
+ const html = document.documentElement;
+ const media = window.matchMedia('(prefers-color-scheme: dark)');
+
+ const apply = () => {
+ const dark = mode === 'dark' || (mode === 'system' && media.matches);
+ if (html.classList.contains('dark') !== dark) html.classList.toggle('dark', dark);
+ const scheme = dark ? 'dark' : 'light';
+ if (html.style.colorScheme !== scheme) html.style.colorScheme = scheme;
+ };
+
+ apply();
+ media.addEventListener('change', apply);
+ const observer = new MutationObserver(apply);
+ observer.observe(html, { attributes: true, attributeFilter: ['class', 'style'] });
+ return () => {
+ media.removeEventListener('change', apply);
+ observer.disconnect();
+ };
+ }, [mode]);
+}
+
+/** `