From 0f72b5e26b0e1dd1fb75e059ff420c160b2ef824 Mon Sep 17 00:00:00 2001 From: Developing-Gamer Date: Thu, 5 Feb 2026 14:22:51 -0800 Subject: [PATCH 01/18] Demo Page setup and Fixed Alerts in design-language --- .../design-language/page-client.tsx | 18 +- .../realistic-demo/page-client.tsx | 703 ++++++++++++++++++ .../design-language/realistic-demo/page.tsx | 9 + apps/dashboard/src/components/ui/alert.tsx | 2 +- 4 files changed, 727 insertions(+), 5 deletions(-) create mode 100644 apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/design-language/realistic-demo/page-client.tsx create mode 100644 apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/design-language/realistic-demo/page.tsx diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/design-language/page-client.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/design-language/page-client.tsx index 5b35912a21..8950a18b4a 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/design-language/page-client.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/design-language/page-client.tsx @@ -27,6 +27,7 @@ import { cn, } from "@/components/ui"; import { EditableGrid, type EditableGridItem } from "@/components/editable-grid"; +import { Link } from "@/components/link"; import { CheckCircle, Cube, @@ -741,6 +742,15 @@ export default function PageClient() { return (
+
+
+ Realistic Theme Preview + Use the new demo page to validate theme changes across a realistic layout. +
+ +
{/* ============================================================ */} {/* CARDS */} @@ -1317,7 +1327,7 @@ export default function PageClient() { title="Success Alert" description="Use for successful operations" > - + Success Your changes have been saved successfully. @@ -1328,7 +1338,7 @@ export default function PageClient() { title="Error Alert" description="Use for errors and failures" > - + Error An error occurred while processing your request. @@ -1339,7 +1349,7 @@ export default function PageClient() { title="Warning Alert" description="Use for warnings that need attention" > - + Warning You are using a shared email server. Configure a custom SMTP server to customize email templates. @@ -1350,7 +1360,7 @@ export default function PageClient() { title="Info Alert" description="Use for informational messages without a title" > - + Info diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/design-language/realistic-demo/page-client.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/design-language/realistic-demo/page-client.tsx new file mode 100644 index 0000000000..7727d28c72 --- /dev/null +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/design-language/realistic-demo/page-client.tsx @@ -0,0 +1,703 @@ +"use client"; + +import { EditableGrid, type EditableGridItem } from "@/components/editable-grid"; +import { Link } from "@/components/link"; +import { + Alert, + AlertDescription, + AlertTitle, + Avatar, + AvatarFallback, + Button, + Checkbox, + DataTable, + DataTableColumnHeader, + DataTableViewOptions, + Input, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, + Switch, + Typography, + cn, +} from "@/components/ui"; +import { ChartContainer, ChartTooltip } from "@/components/ui/chart"; +import { ChartCard, type DataPoint, type LineChartDisplayConfig } from "../../(overview)/line-chart"; +import { + Bell, + ClockCounterClockwise, + FunnelSimple, + Package, + ShieldCheck, + SquaresFourIcon, + UserCircle, + WarningCircle +} from "@phosphor-icons/react"; +import { Bar, BarChart, CartesianGrid, Cell, XAxis, YAxis, type TooltipProps } from "recharts"; +import { isWeekend } from "@stackframe/stack-shared/dist/utils/dates"; +import { ColumnDef, Table as TableType } from "@tanstack/react-table"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import { PageLayout } from "../../page-layout"; + +type IncidentSeverity = "critical" | "high" | "medium"; +type ActiveTab = "operations" | "milestones" | "settings"; +type StatusBadgeColor = "blue" | "cyan" | "purple" | "green" | "orange" | "red"; +type StatusBadgeSize = "sm" | "md"; + +type IncidentRow = { + id: string, + service: string, + severity: IncidentSeverity, + owner: string, + startedAt: string, + status: "investigating" | "mitigated" | "monitoring", +}; + +type ReleaseMilestone = { + id: string, + title: string, + owner: string, + dueDate: string, + status: "done" | "in_progress" | "blocked", +}; + +type ActivityEntry = { + id: string, + title: string, + description: string, + at: string, +}; + +const STATUS_BADGE_STYLES: Record = { + blue: "text-blue-700 dark:text-blue-400 bg-blue-500/20 dark:bg-blue-500/10 ring-1 ring-blue-500/30 dark:ring-blue-500/20", + cyan: "text-cyan-700 dark:text-cyan-400 bg-cyan-500/20 dark:bg-cyan-500/10 ring-1 ring-cyan-500/30 dark:ring-cyan-500/20", + purple: "text-purple-700 dark:text-purple-400 bg-purple-500/20 dark:bg-purple-500/10 ring-1 ring-purple-500/30 dark:ring-purple-500/20", + green: "text-emerald-700 dark:text-emerald-400 bg-emerald-500/20 dark:bg-emerald-500/10 ring-1 ring-emerald-500/30 dark:ring-emerald-500/20", + orange: "text-orange-700 dark:text-orange-400 bg-orange-500/20 dark:bg-orange-500/10 ring-1 ring-orange-500/30 dark:ring-orange-500/20", + red: "text-red-700 dark:text-red-400 bg-red-500/20 dark:bg-red-500/10 ring-1 ring-red-500/30 dark:ring-red-500/20", +}; + +const INCIDENTS: IncidentRow[] = [ + { + id: "inc_1024", + service: "Email Delivery Pipeline", + severity: "high", + owner: "Nia Ramirez", + startedAt: "8m ago", + status: "investigating", + }, + { + id: "inc_1017", + service: "OAuth Token Refresh", + severity: "medium", + owner: "Dylan Park", + startedAt: "22m ago", + status: "monitoring", + }, + { + id: "inc_1009", + service: "Team Invite Webhooks", + severity: "critical", + owner: "Avery Shah", + startedAt: "43m ago", + status: "mitigated", + }, + { + id: "inc_1006", + service: "Dashboard Audit Export", + severity: "medium", + owner: "Mia Johnson", + startedAt: "1h ago", + status: "monitoring", + }, +]; + +const MILESTONES: ReleaseMilestone[] = [ + { id: "m_1", title: "Rotate signing keys", owner: "Security", dueDate: "Today", status: "in_progress" }, + { id: "m_2", title: "Staging load test", owner: "Platform", dueDate: "Feb 7", status: "done" }, + { id: "m_3", title: "Update SSO docs", owner: "Docs", dueDate: "Feb 8", status: "blocked" }, + { id: "m_4", title: "Rollout feature flag", owner: "Auth", dueDate: "Feb 9", status: "in_progress" }, +]; + +const DEPLOYMENT_ACTIVITY_CONFIG = { + name: "Deployment Activity", + description: "Throughput trend over the selected time window", + chart: { + activity: { + label: "Activity", + theme: { + light: "hsl(221, 83%, 53%)", + dark: "hsl(217, 91%, 60%)", + }, + }, + }, +} satisfies LineChartDisplayConfig; + +const DEPLOYMENT_ACTIVITY_DATA: DataPoint[] = [ + { date: "2026-01-20", activity: 12 }, + { date: "2026-01-21", activity: 18 }, + { date: "2026-01-22", activity: 14 }, + { date: "2026-01-23", activity: 22 }, + { date: "2026-01-24", activity: 19 }, + { date: "2026-01-25", activity: 8 }, + { date: "2026-01-26", activity: 6 }, + { date: "2026-01-27", activity: 21 }, + { date: "2026-01-28", activity: 25 }, + { date: "2026-01-29", activity: 17 }, + { date: "2026-01-30", activity: 23 }, +]; + +const RECENT_ACTIVITY: ActivityEntry[] = [ + { + id: "a_1", + title: "Traffic ramped to 25%", + description: "Auth API p95 stayed under 220ms", + at: "4m ago", + }, + { + id: "a_2", + title: "Webhook retries normalized", + description: "Team invite delivery error rate dropped to baseline", + at: "12m ago", + }, + { + id: "a_3", + title: "Signing key checksum verified", + description: "Staging and production fingerprints match", + at: "23m ago", + }, +]; + +const SEVERITY_META = new Map([ + ["critical", { label: "Critical", color: "red" }], + ["high", { label: "High", color: "orange" }], + ["medium", { label: "Medium", color: "cyan" }], +]); + +const INCIDENT_STATUS_META = new Map([ + ["investigating", { label: "Investigating", color: "orange" }], + ["mitigated", { label: "Mitigated", color: "green" }], + ["monitoring", { label: "Monitoring", color: "blue" }], +]); + +const MILESTONE_META = new Map([ + ["done", { label: "Done", color: "green" }], + ["in_progress", { label: "In progress", color: "blue" }], + ["blocked", { label: "Blocked", color: "red" }], +]); + +function DemoChartTooltip({ active, payload }: TooltipProps) { + if (!active || !payload?.length) return null; + const data = payload[0].payload as DataPoint; + const date = new Date(data.date); + const formattedDate = !isNaN(date.getTime()) + ? date.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" }) + : data.date; + return ( +
+
+ {formattedDate} +
+ + Activity + + {typeof data.activity === "number" ? data.activity.toLocaleString() : data.activity} + +
+
+
+ ); +} + +function GlassCard({ + children, + className, + gradientColor = "default", +}: { + children: React.ReactNode, + className?: string, + gradientColor?: "blue" | "purple" | "green" | "orange" | "default" | "cyan", +}) { + const hoverTints: Record = { + blue: "group-hover:bg-blue-500/[0.03]", + purple: "group-hover:bg-purple-500/[0.03]", + green: "group-hover:bg-emerald-500/[0.03]", + orange: "group-hover:bg-orange-500/[0.03]", + default: "group-hover:bg-slate-500/[0.02]", + cyan: "group-hover:bg-cyan-500/[0.03]", + }; + + return ( +
+
+
+
{children}
+
+ ); +} + +function SectionHeader({ icon: Icon, title }: { icon: React.ElementType, title: string }) { + return ( +
+
+ +
+ {title} +
+ ); +} + +function StatusBadge({ + label, + color, + icon, + size = "md", +}: { + label: string, + color: StatusBadgeColor, + icon?: React.ElementType, + size?: StatusBadgeSize, +}) { + const Icon = icon; + const sizeClasses = size === "sm" ? "px-2 py-0.5 text-[10px]" : "px-2.5 py-1 text-[11px]"; + + return ( +
+ {Icon && } + {label} +
+ ); +} + +function CategoryTabs({ + categories, + selectedCategory, + onSelect, +}: { + categories: Array<{ id: string, label: string, count: number }>, + selectedCategory: string, + onSelect: (id: string) => void, +}) { + return ( +
+ {categories.map((category) => { + const isActive = selectedCategory === category.id; + + return ( + + ); + })} +
+ ); +} + +function TableInstanceBridge({ + tableInstance, + onTableInstance, + onVisibilityChange, +}: { + tableInstance: TableType, + onTableInstance: (table: TableType) => void, + onVisibilityChange: (visibility: Record) => void, +}) { + useEffect(() => { + onTableInstance(tableInstance); + }, [tableInstance, onTableInstance]); + + const currentVisibility = tableInstance.getState().columnVisibility; + const visibilityKey = JSON.stringify(currentVisibility); + useEffect(() => { + onVisibilityChange(currentVisibility as Record); + // eslint-disable-next-line react-hooks/exhaustive-deps -- serialized visibility key intentionally controls updates + }, [visibilityKey, onVisibilityChange]); + + return null; +} + +export default function PageClient() { + const [environment, setEnvironment] = useState("production"); + const [timeWindow, setTimeWindow] = useState("7d"); + const [activeTab, setActiveTab] = useState("operations"); + const [compactMode, setCompactMode] = useState(false); + const [includeBackfill, setIncludeBackfill] = useState(true); + const [notificationFrequency, setNotificationFrequency] = useState("every_update"); + const [incidentTable, setIncidentTable] = useState | null>(null); + const [incidentTableVisibility, setIncidentTableVisibility] = useState({}); + + const categories = useMemo(() => [ + { id: "operations", label: "Operations", count: INCIDENTS.length }, + { id: "milestones", label: "Milestones", count: MILESTONES.length }, + { id: "settings", label: "Settings", count: 2 }, + ], []); + + const simulateSave = useCallback(async () => { + await new Promise((resolve) => { + setTimeout(resolve, 150); + }); + }, []); + + const settingsGridItems = useMemo(() => [ + { + type: "text", + icon: , + name: "Release Name", + value: "Auth Platform 2026.02", + onUpdate: simulateSave, + }, + { + type: "dropdown", + icon: , + name: "Rollout Bucket", + value: "25_percent", + options: [ + { value: "10_percent", label: "10%" }, + { value: "25_percent", label: "25%" }, + { value: "50_percent", label: "50%" }, + { value: "100_percent", label: "100%" }, + ], + onUpdate: simulateSave, + }, + { + type: "boolean", + icon: , + name: "Require Approval", + value: true, + trueLabel: "Enabled", + falseLabel: "Disabled", + onUpdate: simulateSave, + }, + { + type: "custom", + icon: , + name: "Cooldown", + children: 30 min between phases, + }, + ], [simulateSave]); + + const incidentColumns = useMemo[]>(() => [ + { + accessorKey: "service", + header: ({ column }) => , + cell: ({ row }) => ( +
+ {row.original.service} + {row.original.id} +
+ ), + }, + { + accessorKey: "severity", + header: ({ column }) => , + cell: ({ row }) => { + const severity = SEVERITY_META.get(row.original.severity); + if (!severity) throw new Error(`Unknown severity: ${row.original.severity}`); + return ; + }, + }, + { + accessorKey: "owner", + header: ({ column }) => , + cell: ({ row }) => {row.original.owner}, + }, + { + accessorKey: "startedAt", + header: ({ column }) => , + cell: ({ row }) => {row.original.startedAt}, + }, + { + accessorKey: "status", + header: ({ column }) => , + cell: ({ row }) => { + const status = INCIDENT_STATUS_META.get(row.original.status); + if (!status) throw new Error(`Unknown status: ${row.original.status}`); + return ; + }, + }, + ], []); + + return ( + + + +
+ } + > +
+ + + Warning + + Validate readability in both modes on this page: badges, tables, muted copy, compact controls, and highlighted states should remain legible. + + + +
+ +
+
+ + {DEPLOYMENT_ACTIVITY_CONFIG.name} + +
+ {DEPLOYMENT_ACTIVITY_CONFIG.description} +
+
+
+
+ + + + } + cursor={{ fill: "var(--color-activity)", opacity: 0.35, radius: 4 }} + offset={20} + allowEscapeViewBox={{ x: true, y: true }} + wrapperStyle={{ zIndex: 9999, pointerEvents: "none" }} + /> + + {DEPLOYMENT_ACTIVITY_DATA.map((entry, index) => ( + + ))} + + + { + const date = new Date(value); + if (!isNaN(date.getTime())) { + return `${date.toLocaleDateString("en-US", { month: "short" })} ${date.getDate()}`; + } + return value; + }} + /> + + +
+
+ + +
+ + Latest rollout events from the command log +
+ {RECENT_ACTIVITY.map((entry) => ( +
+
+ {entry.title} + {entry.at} +
+ {entry.description} +
+ ))} +
+
+
+
+ +
+ setActiveTab(id as ActiveTab)} + /> + + {activeTab === "operations" && ( +
+ +
+
+
+ + Operational incident queue with owners and current mitigation state +
+ {incidentTable && ( +
+ +
+ )} +
+
+ +
+ ( + + )} + /> +
+
+ +
+ + + Launch configuration used by the release coordinator +
+
+
+
+ +
+
+
+
+
+
+ Compact mode + Use denser spacing on operator displays +
+ +
+
+ setIncludeBackfill(checked === true)} + /> + +
+
+ + +
+
+ )} + + {activeTab === "milestones" && ( + +
+ {MILESTONES.map((milestone) => { + const style = MILESTONE_META.get(milestone.status); + if (!style) throw new Error(`Unknown milestone status: ${milestone.status}`); + + return ( +
+
+ {milestone.title} + Owner: {milestone.owner} · Due: {milestone.dueDate} +
+ +
+ ); + })} +
+
+ )} + + {activeTab === "settings" && ( +
+ + + Where rollout updates are broadcast +
+ + +
+
+ + + + On-call team members and recent SLA status +
+ {["NR", "DP", "AS", "MJ"].map((initials) => ( +
+ + {initials} + +
+ {initials} · On call + Responded within SLA +
+ +
+ ))} +
+
+
+ )} +
+ +
+ + ); +} diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/design-language/realistic-demo/page.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/design-language/realistic-demo/page.tsx new file mode 100644 index 0000000000..e6d409e4d5 --- /dev/null +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/design-language/realistic-demo/page.tsx @@ -0,0 +1,9 @@ +import PageClient from "./page-client"; + +export const metadata = { + title: "Design Language Demo", +}; + +export default function Page() { + return ; +} diff --git a/apps/dashboard/src/components/ui/alert.tsx b/apps/dashboard/src/components/ui/alert.tsx index e96fc1295d..38a2fa1595 100644 --- a/apps/dashboard/src/components/ui/alert.tsx +++ b/apps/dashboard/src/components/ui/alert.tsx @@ -5,7 +5,7 @@ import React from "react"; import { cn } from "@/lib/utils"; const alertVariants = cva( - "stack-scope relative w-full rounded-lg border px-4 py-3 text-sm [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground [&>svg~*]:pl-7", + "stack-scope relative w-full rounded-2xl border p-4 text-sm [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground [&>svg~*]:pl-7", { variants: { variant: { From b1bdcd2fb72dfb0c9da5fa48030a48cd19eb72c7 Mon Sep 17 00:00:00 2001 From: Developing-Gamer Date: Thu, 5 Feb 2026 14:57:30 -0800 Subject: [PATCH 02/18] Theme Toggle transition --- apps/dashboard/src/app/globals.css | 6 ++ .../dashboard/src/components/theme-toggle.tsx | 71 ++++++++++++++++++- 2 files changed, 75 insertions(+), 2 deletions(-) diff --git a/apps/dashboard/src/app/globals.css b/apps/dashboard/src/app/globals.css index 598eec1c64..225a49d2b6 100644 --- a/apps/dashboard/src/app/globals.css +++ b/apps/dashboard/src/app/globals.css @@ -134,6 +134,12 @@ } } +::view-transition-old(root), +::view-transition-new(root) { + animation: none; + mix-blend-mode: normal; +} + .bg-card { box-shadow: 0 0 3px 3px rgba(0, 0, 0, 0.02); backdrop-filter: blur(12px); diff --git a/apps/dashboard/src/components/theme-toggle.tsx b/apps/dashboard/src/components/theme-toggle.tsx index 0fee90f783..7809938cb1 100644 --- a/apps/dashboard/src/components/theme-toggle.tsx +++ b/apps/dashboard/src/components/theme-toggle.tsx @@ -1,15 +1,82 @@ import { Button } from "@/components/ui"; import { MoonIcon, SunIcon } from "@phosphor-icons/react"; +import { runAsynchronously } from "@stackframe/stack-shared/dist/utils/promises"; import { useTheme } from "next-themes"; +import { useRef } from "react"; + +type ViewTransitionWithReady = { + ready: Promise, +}; + +type DocumentWithViewTransition = globalThis.Document & { + startViewTransition?: (callback: () => void) => ViewTransitionWithReady, +}; export default function ThemeToggle() { - const { theme, setTheme } = useTheme(); + const { resolvedTheme, setTheme } = useTheme(); + const buttonRef = useRef(null); + const isReady = resolvedTheme === "dark" || resolvedTheme === "light"; + + const handleToggle = () => { + if (!isReady) { + return; + } + + const nextTheme = resolvedTheme === "dark" ? "light" : "dark"; + + if (typeof document === "undefined") { + setTheme(nextTheme); + return; + } + + const prefersReducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches; + const documentWithTransition: DocumentWithViewTransition = document; + const button = buttonRef.current; + + if (!documentWithTransition.startViewTransition || prefersReducedMotion || !button) { + setTheme(nextTheme); + return; + } + + const rect = button.getBoundingClientRect(); + const x = rect.left + rect.width / 2; + const y = rect.top + rect.height / 2; + const maxRadius = Math.hypot( + Math.max(x, window.innerWidth - x), + Math.max(y, window.innerHeight - y) + ); + + const transition = documentWithTransition.startViewTransition(() => { + setTheme(nextTheme); + }); + + runAsynchronously(async () => { + await transition.ready; + document.documentElement.animate( + { + clipPath: [ + `circle(0px at ${x}px ${y}px)`, + `circle(${maxRadius}px at ${x}px ${y}px)` + ], + }, + { + duration: 450, + easing: "ease-in-out", + pseudoElement: "::view-transition-new(root)", + } + ); + }); + }; + return ( + + - - - - - setSidebarOpen(false)} /> - - - - {/* Desktop: Logo + Breadcrumb + Project Switcher */} -
- - - {getPublicEnvVar("NEXT_PUBLIC_STACK_EMULATOR_ENABLED") === "true" ? ( - - ) : ( - - )} -
+ setSidebarOpen(false)} /> +
+ + + {/* Desktop: Logo + Breadcrumb + Project Switcher */} +
+ + + {getPublicEnvVar("NEXT_PUBLIC_STACK_EMULATOR_ENABLED") === "true" ? ( + + ) : ( + + )} +
- {/* Mobile: Logo */} -
- + {/* Mobile: Logo */} +
+ +
-
- {/* Middle section: Control Center (development only) */} - {process.env.NODE_ENV === "development" && ( -
- -
- )} + {/* Middle section: Control Center (development only) */} + {process.env.NODE_ENV === "development" && ( +
+ +
+ )} - {/* Right section: Search, Theme toggle and User button */} -
- {getPublicEnvVar("NEXT_PUBLIC_STACK_EMULATOR_ENABLED") === "true" ? ( - - ) : ( - <> + {/* Right section: Search, Theme toggle and User button */} +
+ {getPublicEnvVar("NEXT_PUBLIC_STACK_EMULATOR_ENABLED") === "true" ? ( - - - )} -
+ ) : ( + <> + + + + )} +
From dd7671af6cc88b66c5935077fcfc236a2a8b3617 Mon Sep 17 00:00:00 2001 From: Developing-Gamer Date: Mon, 9 Feb 2026 15:32:15 -0800 Subject: [PATCH 08/18] Removed glassmorphism from stack companion --- apps/dashboard/src/components/stack-companion.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/dashboard/src/components/stack-companion.tsx b/apps/dashboard/src/components/stack-companion.tsx index e3d52d352a..cf850f7f2f 100644 --- a/apps/dashboard/src/components/stack-companion.tsx +++ b/apps/dashboard/src/components/stack-companion.tsx @@ -397,7 +397,7 @@ export function StackCompanion({ className }: { className?: string }) { style={{ opacity: contentOpacity }} > {/* Header */} -
+
{currentItem && ( <> @@ -443,7 +443,7 @@ export function StackCompanion({ className }: { className?: string }) { > {/* The Handle Pill */}
Date: Mon, 9 Feb 2026 15:57:10 -0800 Subject: [PATCH 09/18] Update color variables in globals.css for improved accessibility and adjust button styles for better visual consistency. Changes include modifications to secondary, muted, and accent colors, as well as refinements to button hover effects. --- apps/dashboard/src/app/globals.css | 6 +++--- apps/dashboard/src/components/ui/button.tsx | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/apps/dashboard/src/app/globals.css b/apps/dashboard/src/app/globals.css index 415c4e3fb1..81ba46f14a 100644 --- a/apps/dashboard/src/app/globals.css +++ b/apps/dashboard/src/app/globals.css @@ -16,15 +16,15 @@ --primary: 240 5.9% 10%; --primary-foreground: 0 0% 98%; - --secondary: 240 4.8% 95.9%; + --secondary: 245 30% 90%; --secondary-in-card: 240 4.8% 95.9%; --secondary-foreground: 240 5.9% 10%; - --muted: 232 30% 90%; + --muted: 250 35% 92%; --muted-in-card: 240 4.8% 95.9%; --muted-foreground: 232 12% 38%; - --accent: 240 4.8% 95.9%; + --accent: 248 32% 91%; --accent-in-card: 240 4.8% 95.9%; --accent-foreground: 240 5.9% 10%; diff --git a/apps/dashboard/src/components/ui/button.tsx b/apps/dashboard/src/components/ui/button.tsx index b313f20ddd..ada6116a31 100644 --- a/apps/dashboard/src/components/ui/button.tsx +++ b/apps/dashboard/src/components/ui/button.tsx @@ -3,9 +3,9 @@ import { forwardRefIfNeeded } from "@stackframe/stack-shared/dist/utils/react"; import { cva, type VariantProps } from "class-variance-authority"; import React from "react"; +import { cn } from "@/lib/utils"; import { useAsyncCallback } from "@stackframe/stack-shared/dist/hooks/use-async-callback"; import { runAsynchronouslyWithAlert } from "@stackframe/stack-shared/dist/utils/promises"; -import { cn } from "@/lib/utils"; import { Spinner } from "./spinner"; const buttonVariants = cva( @@ -18,7 +18,7 @@ const buttonVariants = cva( destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/90", outline: - "border border-input bg-background hover:bg-accent hover:text-accent-foreground", + "border border-input bg-white/85 dark:bg-background hover:bg-white dark:hover:bg-accent hover:text-accent-foreground", secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80", ghost: "hover:bg-accent hover:text-accent-foreground", From 38d585f556eb31032370fd9a74649152fd4de5c7 Mon Sep 17 00:00:00 2001 From: Developing-Gamer Date: Mon, 9 Feb 2026 16:08:40 -0800 Subject: [PATCH 10/18] Update sidebar layout to improve responsiveness by adjusting padding for larger screens. This change enhances the overall visual consistency and user experience in the dashboard. --- .../(main)/(protected)/projects/[projectId]/sidebar-layout.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/sidebar-layout.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/sidebar-layout.tsx index de69a7fe8d..559ad26ecf 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/sidebar-layout.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/sidebar-layout.tsx @@ -667,7 +667,7 @@ export default function SidebarLayout(props: { children?: React.ReactNode }) { {/* Main Content Area */}
-
+
{props.children}
From 610cb26dce312ec185406b88c5a84c41129caf31 Mon Sep 17 00:00:00 2001 From: Armaan Jain <84474476+Developing-Gamer@users.noreply.github.com> Date: Tue, 10 Feb 2026 20:24:04 -0800 Subject: [PATCH 11/18] Card component created (#1169) --- .../playground/page-client.tsx | 1380 +++++++++++++++++ .../(outside-dashboard)/playground/page.tsx | 9 + .../design-language/page-client.tsx | 1272 ++++++--------- apps/dashboard/src/app/globals.css | 14 + .../src/components/design-language/alert.tsx | 120 ++ .../src/components/design-language/badge.tsx | 89 ++ .../src/components/design-language/button.tsx | 92 ++ .../src/components/design-language/card.tsx | 199 +++ .../design-language/cursor-blast-effect.tsx | 331 ++++ .../design-language/editable-grid.tsx | 615 ++++++++ .../src/components/design-language/index.ts | 13 + .../src/components/design-language/input.tsx | 85 + .../src/components/design-language/list.tsx | 123 ++ .../src/components/design-language/menu.tsx | 155 ++ .../design-language/pill-toggle.tsx | 145 ++ .../src/components/design-language/select.tsx | 75 + .../src/components/design-language/table.tsx | 104 ++ .../src/components/design-language/tabs.tsx | 197 +++ .../dashboard/src/components/theme-toggle.tsx | 53 +- 19 files changed, 4238 insertions(+), 833 deletions(-) create mode 100644 apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/playground/page-client.tsx create mode 100644 apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/playground/page.tsx create mode 100644 apps/dashboard/src/components/design-language/alert.tsx create mode 100644 apps/dashboard/src/components/design-language/badge.tsx create mode 100644 apps/dashboard/src/components/design-language/button.tsx create mode 100644 apps/dashboard/src/components/design-language/card.tsx create mode 100644 apps/dashboard/src/components/design-language/cursor-blast-effect.tsx create mode 100644 apps/dashboard/src/components/design-language/editable-grid.tsx create mode 100644 apps/dashboard/src/components/design-language/index.ts create mode 100644 apps/dashboard/src/components/design-language/input.tsx create mode 100644 apps/dashboard/src/components/design-language/list.tsx create mode 100644 apps/dashboard/src/components/design-language/menu.tsx create mode 100644 apps/dashboard/src/components/design-language/pill-toggle.tsx create mode 100644 apps/dashboard/src/components/design-language/select.tsx create mode 100644 apps/dashboard/src/components/design-language/table.tsx create mode 100644 apps/dashboard/src/components/design-language/tabs.tsx diff --git a/apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/playground/page-client.tsx b/apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/playground/page-client.tsx new file mode 100644 index 0000000000..ec40c4b5bd --- /dev/null +++ b/apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/playground/page-client.tsx @@ -0,0 +1,1380 @@ +"use client"; + +import { + CursorBlastEffect, + DesignAlert, + DesignBadge, + type DesignBadgeColor, + type DesignBadgeContentMode, + DesignButton, + DesignCard, + DesignCategoryTabs, + DesignDataTable, + DesignEditableGrid, + type DesignEditableGridItem, + DesignInput, + DesignListItemRow, + DesignMenu, + DesignPillToggle, + DesignSelectorDropdown, + DesignUserList, +} from "@/components/design-language"; +import { DataTableColumnHeader, Typography } from "@/components/ui"; +import { + CheckCircle, + Cube, + Envelope, + FileText, + HardDrive, + MagnifyingGlassIcon, + Package, + PencilSimple, + Sliders, + Sparkle, + StackSimple, + Tag, + Trash, +} from "@phosphor-icons/react"; +import { ColumnDef } from "@tanstack/react-table"; +import { useMemo, useRef, useState } from "react"; + +// ─── Types ─────────────────────────────────────────────────────────────────── + +type ComponentId = + | "alert" + | "badge" + | "button" + | "card" + | "category-tabs" + | "cursor-blast" + | "data-table" + | "editable-grid" + | "input" + | "list-item-row" + | "menu" + | "pill-toggle" + | "selector-dropdown" + | "user-list"; + +const COMPONENT_LIST: Array<{ value: ComponentId, label: string }> = [ + { value: "alert", label: "Alert" }, + { value: "badge", label: "Badge" }, + { value: "button", label: "Button" }, + { value: "card", label: "Card" }, + { value: "category-tabs", label: "Category Tabs" }, + { value: "cursor-blast", label: "Cursor Blast Effect" }, + { value: "data-table", label: "Data Table" }, + { value: "editable-grid", label: "Editable Grid" }, + { value: "input", label: "Input" }, + { value: "list-item-row", label: "List Item Row" }, + { value: "menu", label: "Menu" }, + { value: "pill-toggle", label: "Pill Toggle" }, + { value: "selector-dropdown", label: "Selector Dropdown" }, + { value: "user-list", label: "User List" }, +]; + +function isComponentId(value: string): value is ComponentId { + return COMPONENT_LIST.some((c) => c.value === value); +} + +// ─── Shared enums ──────────────────────────────────────────────────────────── + +type Gradient = "blue" | "cyan" | "purple" | "green" | "orange" | "default"; +type Size3 = "sm" | "md" | "lg"; + +const GRADIENT_OPTIONS: Array<{ value: Gradient, label: string }> = [ + { value: "default", label: "Default" }, + { value: "blue", label: "Blue" }, + { value: "cyan", label: "Cyan" }, + { value: "purple", label: "Purple" }, + { value: "green", label: "Green" }, + { value: "orange", label: "Orange" }, +]; + +const SIZE3_OPTIONS: Array<{ value: Size3, label: string }> = [ + { value: "sm", label: "Small" }, + { value: "md", label: "Medium" }, + { value: "lg", label: "Large" }, +]; + +function isGradient(v: string): v is Gradient { + return GRADIENT_OPTIONS.some((o) => o.value === v); +} +function isSize3(v: string): v is Size3 { + return SIZE3_OPTIONS.some((o) => o.value === v); +} + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +function PropField({ label, children }: { label: string, children: React.ReactNode }) { + return ( +
+ + {label} + + {children} +
+ ); +} + +function BoolToggle({ + value, + onChange, + on = "On", + off = "Off", +}: { + value: boolean, + onChange: (v: boolean) => void, + on?: string, + off?: string, +}) { + return ( +
+ onChange(nextValue === "true")} + options={[ + { value: "true", label: on }, + { value: "false", label: off }, + ]} + size="sm" + /> +
+ ); +} + +// ─── Demo data ─────────────────────────────────────────────────────────────── + +type DemoProduct = { + id: string, + name: string, + category: string, + price: number, + status: "active" | "draft" | "archived", +}; + +const DEMO_PRODUCTS: DemoProduct[] = [ + { id: "1", name: "Widget Pro", category: "Hardware", price: 29.99, status: "active" }, + { id: "2", name: "Gadget Lite", category: "Accessories", price: 14.99, status: "draft" }, + { id: "3", name: "Tool Max", category: "Software", price: 49.99, status: "archived" }, + { id: "4", name: "Sensor Hub", category: "Hardware", price: 79.99, status: "active" }, +]; + +const STATUS_BADGE: Record = { + active: { label: "Active", color: "green" }, + draft: { label: "Draft", color: "orange" }, + archived: { label: "Archived", color: "red" }, +}; + +const DEMO_USERS = [ + { name: "Ada Lovelace", email: "ada@example.com", time: "Active 1h ago", color: "cyan" as const }, + { name: "Grace Hopper", email: "grace@example.com", time: "Active 3h ago", color: "blue" as const }, + { name: "Alan Turing", email: "alan@example.com", time: "Active 5h ago", color: "cyan" as const }, +]; + +// ─── Main ──────────────────────────────────────────────────────────────────── + +export default function PageClient() { + const [selected, setSelected] = useState("button"); + + // Alert + const [alertVariant, setAlertVariant] = useState<"default" | "success" | "error" | "warning" | "info">("success"); + const [alertTitle, setAlertTitle] = useState("Order placed"); + const [alertDesc, setAlertDesc] = useState("Your order has been confirmed."); + + // Badge + const [badgeLabel, setBadgeLabel] = useState("In stock"); + const [badgeColor, setBadgeColor] = useState("green"); + const [badgeSize, setBadgeSize] = useState<"sm" | "md">("md"); + const [badgeIcon, setBadgeIcon] = useState(true); + const [badgeContentMode, setBadgeContentMode] = useState("both"); + + // Button + const [btnLabel, setBtnLabel] = useState("Buy now"); + const [btnVariant, setBtnVariant] = useState<"default" | "secondary" | "outline" | "destructive" | "ghost" | "link" | "plain">("default"); + const [btnSize, setBtnSize] = useState<"default" | "sm" | "lg" | "icon">("default"); + const [btnLoading, setBtnLoading] = useState(false); + + // Card + const [cardVariant, setCardVariant] = useState<"header" | "compact" | "bodyOnly">("compact"); + const [cardTitle, setCardTitle] = useState("Featured Bundle"); + const [cardSubtitle, setCardSubtitle] = useState("Save 20% this week."); + const [cardGradient, setCardGradient] = useState("default"); + const [cardGlass, setCardGlass] = useState(true); + const [cardShowIcon, setCardShowIcon] = useState(true); + + // Category Tabs + const [tabSize, setTabSize] = useState<"sm" | "md">("sm"); + const [tabGlass, setTabGlass] = useState(false); + const [tabGradient, setTabGradient] = useState("blue"); + const [tabSelected, setTabSelected] = useState("all"); + const [tabShowBadge, setTabShowBadge] = useState(true); + + // Cursor Blast + const blastPreviewRef = useRef(null); + const [blastEnabled, setBlastEnabled] = useState(true); + const [blastLifetime, setBlastLifetime] = useState(720); + const [blastMaxActive, setBlastMaxActive] = useState(18); + const [blastRageThreshold, setBlastRageThreshold] = useState(3); + const [blastRageWindow, setBlastRageWindow] = useState(600); + const [blastRageRadius, setBlastRageRadius] = useState(60); + + // Data Table + const [tableTitle, setTableTitle] = useState("Products"); + const [tableSubtitle, setTableSubtitle] = useState("All items in catalog"); + const [tableShowHeader, setTableShowHeader] = useState(true); + const [tableShowIcon, setTableShowIcon] = useState(true); + const [tableClickableRows, setTableClickableRows] = useState(false); + const [tableLastRowClick, setTableLastRowClick] = useState(""); + + // Editable Grid + const [gridCols, setGridCols] = useState<1 | 2>(2); + const [gridMode, setGridMode] = useState<"basic" | "full">("basic"); + const [gridDeferredSave, setGridDeferredSave] = useState(false); + const [gridHasChanges, setGridHasChanges] = useState(false); + const [gridShowModified, setGridShowModified] = useState(false); + const [gridActionLog, setGridActionLog] = useState(""); + + // Input + const [inputPlaceholder, setInputPlaceholder] = useState("Search products..."); + const [inputSize, setInputSize] = useState("md"); + const [inputIcon, setInputIcon] = useState(false); + const [inputPrefix, setInputPrefix] = useState(false); + const [inputType, setInputType] = useState<"text" | "password">("text"); + const [inputDisabled, setInputDisabled] = useState(false); + + // List Item Row + const [listTitle, setListTitle] = useState("Premium Support Plan"); + const [listShowIcon, setListShowIcon] = useState(true); + const [listEdit, setListEdit] = useState(true); + const [listDelete, setListDelete] = useState(true); + const [listLastAction, setListLastAction] = useState(""); + + // Menu + const [menuVariant, setMenuVariant] = useState<"actions" | "selector" | "toggles">("actions"); + const [menuSelectorValue, setMenuSelectorValue] = useState("all"); + const [menuToggles, setMenuToggles] = useState>({ opt1: true, opt2: false, opt3: true }); + const [menuTrigger, setMenuTrigger] = useState<"button" | "icon">("button"); + const [menuTriggerLabel, setMenuTriggerLabel] = useState("Open Menu"); + const [menuLabel, setMenuLabel] = useState("Actions"); + const [menuAlign, setMenuAlign] = useState<"start" | "center" | "end">("start"); + const [menuWithIcons, setMenuWithIcons] = useState(true); + const [menuActionStyle, setMenuActionStyle] = useState<"default" | "destructive">("destructive"); + const [menuLastAction, setMenuLastAction] = useState(""); + + // Pill Toggle + const [pillSize, setPillSize] = useState("md"); + const [pillGlass, setPillGlass] = useState(false); + const [pillShowIcons, setPillShowIcons] = useState(true); + const [pillShowLabels, setPillShowLabels] = useState(true); + const [pillSelected, setPillSelected] = useState("a"); + + // Selector Dropdown + const [selSize, setSelSize] = useState("sm"); + const [selDisabled, setSelDisabled] = useState(false); + const [selValue, setSelValue] = useState("option-a"); + const [selPlaceholder, setSelPlaceholder] = useState("Select"); + const [selDisableOptionB, setSelDisableOptionB] = useState(false); + + // User List + const [userClickable, setUserClickable] = useState(true); + const [userShowAvatar, setUserShowAvatar] = useState(true); + const [userGradient, setUserGradient] = useState<"blue-purple" | "cyan-blue" | "none">("blue-purple"); + const [userLastClick, setUserLastClick] = useState(""); + + // ─── Demo table columns ────────────────────────────────────────────────── + + const tableColumns = useMemo[]>( + () => [ + { + accessorKey: "name", + header: ({ column }) => , + cell: ({ row }) => {row.getValue("name")}, + }, + { + accessorKey: "category", + header: ({ column }) => , + cell: ({ row }) => {row.getValue("category")}, + }, + { + accessorKey: "price", + header: ({ column }) => , + cell: ({ row }) => ( + + ${(row.getValue("price") as number).toFixed(2)} + + ), + }, + { + accessorKey: "status", + header: ({ column }) => , + cell: ({ row }) => { + const s = row.getValue("status") as DemoProduct["status"]; + return ; + }, + }, + ], + [] + ); + + // ─── Demo editable grid items ──────────────────────────────────────────── + + const editableItems = useMemo(() => { + const baseItems: DesignEditableGridItem[] = [ + { + itemKey: "display-name", + type: "text", + icon: , + name: "Display Name", + value: "Widget Pro", + readOnly: false, + onUpdate: async () => { + await new Promise((r) => setTimeout(r, 400)); + setGridActionLog("Updated text value"); + }, + }, + { + itemKey: "active", + type: "boolean", + icon: , + name: "Active", + value: true, + readOnly: false, + trueLabel: "Yes", + falseLabel: "No", + onUpdate: async () => { + await new Promise((r) => setTimeout(r, 400)); + setGridActionLog("Updated boolean value"); + }, + }, + { + itemKey: "category", + type: "dropdown", + icon: , + name: "Category", + value: "hardware", + options: [ + { value: "hardware", label: "Hardware" }, + { value: "software", label: "Software" }, + { value: "accessories", label: "Accessories" }, + ], + readOnly: false, + onUpdate: async () => { + await new Promise((r) => setTimeout(r, 400)); + setGridActionLog("Updated dropdown value"); + }, + }, + { + itemKey: "price", + type: "custom", + icon: , + name: "Price", + children: $29.99, + }, + ]; + + if (gridMode === "full") { + return [ + ...baseItems, + { + itemKey: "custom-dropdown", + type: "custom-dropdown", + icon: , + name: "Custom Dropdown", + triggerContent: Open custom panel, + popoverContent:
Custom content
, + disabled: false, + }, + { + itemKey: "custom-button", + type: "custom-button", + icon: , + name: "Custom Button", + onClick: () => setGridActionLog("Clicked custom button"), + children: Run action, + disabled: false, + }, + ]; + } + + return baseItems; + }, [gridMode]); + + // ─── Preview renderer ──────────────────────────────────────────────────── + + function renderPreview() { + if (selected === "alert") { + return ( +
+ +
+ ); + } + if (selected === "badge") { + const badgeIconProp = badgeContentMode === "icon" + ? CheckCircle + : (badgeIcon ? CheckCircle : undefined); + return ( + + ); + } + if (selected === "button") { + return ( + + {btnSize === "icon" + ? + : btnLabel || "Button"} + + ); + } + if (selected === "card") { + return ( +
+ + + Highlight pricing, benefits, or key product details here. + + +
+ ); + } + if (selected === "category-tabs") { + return ( + + ); + } + if (selected === "cursor-blast") { + return ( +
+ {blastEnabled && ( + + )} + + {blastEnabled + ? "Rage-click inside the preview area to trigger the blast effect." + : "Enable the effect to see cursor blasts."} + +
+ ); + } + if (selected === "data-table") { + return ( +
+ setTableLastRowClick(row.name) : undefined} + /> + {tableLastRowClick && ( + + Last row click: {tableLastRowClick} + + )} +
+ ); + } + if (selected === "editable-grid") { + return ( +
+
+
+ { + await new Promise((r) => setTimeout(r, 400)); + setGridActionLog("Saved deferred changes"); + setGridHasChanges(false); + } : undefined} + onDiscard={gridDeferredSave ? () => { + setGridActionLog("Discarded deferred changes"); + setGridHasChanges(false); + } : undefined} + externalModifiedKeys={gridShowModified ? new Set(["display-name", "category"]) : undefined} + /> + {gridActionLog && ( + + Last action: {gridActionLog} + + )} +
+
+
+ ); + } + if (selected === "input") { + return ( +
+ : undefined)} + prefixItem={inputPrefix ? "$" : undefined} + /> +
+ ); + } + if (selected === "list-item-row") { + return ( +
+ setListLastAction("edit") : undefined} + onDelete={listDelete ? () => setListLastAction("delete") : undefined} + /> + {listLastAction && ( + + Last action: {listLastAction} + + )} +
+ ); + } + if (selected === "menu") { + if (menuVariant === "selector") { + return ( + o.id === menuSelectorValue)?.label ?? "Select" + ) + } + label={menuLabel} + options={[ + { id: "all", label: "All" }, + { id: "active", label: "Active" }, + { id: "drafts", label: "Drafts" }, + ]} + value={menuSelectorValue} + onValueChange={setMenuSelectorValue} + /> + ); + } + if (menuVariant === "toggles") { + return ( + setMenuToggles((prev) => ({ ...prev, [id]: checked }))} + /> + ); + } + return ( + , onClick: () => setMenuLastAction("edit") }, + { id: "email", label: "Send email", icon: , onClick: () => setMenuLastAction("send-email") }, + { id: "delete", label: "Delete", icon: , itemVariant: menuActionStyle, onClick: () => setMenuLastAction("delete") }, + ]} + /> + ); + } + if (selected === "pill-toggle") { + return ( + + ); + } + if (selected === "selector-dropdown") { + return ( +
+ +
+ ); + } + // user-list + return ( +
+ setUserLastClick(user.name) : undefined} + /> + {userLastClick && ( + + Last clicked user: {userLastClick} + + )} +
+ ); + } + + // ─── Controls renderer ─────────────────────────────────────────────────── + + function renderControls() { + if (selected === "alert") { + return ( +
+ + { + if (v === "default" || v === "success" || v === "error" || v === "warning" || v === "info") { + setAlertVariant(v); + return; + } + throw new Error(`Unknown alert variant "${v}"`); + }} + options={[ + { value: "default", label: "Default" }, + { value: "success", label: "Success" }, + { value: "error", label: "Error" }, + { value: "warning", label: "Warning" }, + { value: "info", label: "Info" }, + ]} + size="sm" + /> + + + setAlertTitle(e.target.value)} /> + + + setAlertDesc(e.target.value)} /> + +
+ ); + } + if (selected === "badge") { + return ( +
+ + setBadgeLabel(e.target.value)} /> + + + { + if (v === "blue" || v === "cyan" || v === "purple" || v === "green" || v === "orange" || v === "red") { + setBadgeColor(v); + return; + } + throw new Error(`Unknown badge color "${v}"`); + }} + options={[ + { value: "blue", label: "Blue" }, + { value: "cyan", label: "Cyan" }, + { value: "purple", label: "Purple" }, + { value: "green", label: "Green" }, + { value: "orange", label: "Orange" }, + { value: "red", label: "Red" }, + ]} + size="sm" + /> + + + { + if (v === "sm" || v === "md") { + setBadgeSize(v); + return; + } + throw new Error(`Unknown badge size "${v}"`); + }} + options={[ + { value: "sm", label: "Small" }, + { value: "md", label: "Medium" }, + ]} + size="sm" + /> + + + { + if (v === "both" || v === "text" || v === "icon") { + setBadgeContentMode(v); + return; + } + throw new Error(`Unknown badge content mode "${v}"`); + }} + options={[ + { value: "both", label: "Both" }, + { value: "text", label: "Text only" }, + { value: "icon", label: "Icon only" }, + ]} + size="sm" + /> + + {badgeContentMode === "both" && ( + + + + )} +
+ ); + } + if (selected === "button") { + return ( +
+ + setBtnLabel(e.target.value)} /> + + + { + if (v === "default" || v === "secondary" || v === "outline" || v === "destructive" || v === "ghost" || v === "link" || v === "plain") { + setBtnVariant(v); + return; + } + throw new Error(`Unknown button variant "${v}"`); + }} + options={[ + { value: "default", label: "Default" }, + { value: "secondary", label: "Secondary" }, + { value: "outline", label: "Outline" }, + { value: "destructive", label: "Destructive" }, + { value: "ghost", label: "Ghost" }, + { value: "link", label: "Link" }, + { value: "plain", label: "Plain" }, + ]} + size="sm" + /> + + + { + if (v === "default" || v === "sm" || v === "lg" || v === "icon") { + setBtnSize(v); + return; + } + throw new Error(`Unknown button size "${v}"`); + }} + options={[ + { value: "default", label: "Default" }, + { value: "sm", label: "Small" }, + { value: "lg", label: "Large" }, + { value: "icon", label: "Icon" }, + ]} + size="sm" + /> + + + + +
+ ); + } + if (selected === "card") { + return ( +
+ + { + if (v === "header" || v === "compact" || v === "bodyOnly") { + setCardVariant(v); + return; + } + throw new Error(`Unknown card variant "${v}"`); + }} + options={[ + { value: "header", label: "Header" }, + { value: "compact", label: "Compact" }, + { value: "bodyOnly", label: "Body Only" }, + ]} + size="sm" + /> + + + setCardTitle(e.target.value)} /> + + + setCardSubtitle(e.target.value)} /> + + + { + if (!isGradient(v)) throw new Error(`Unknown gradient "${v}"`); + setCardGradient(v); + }} + options={GRADIENT_OPTIONS} + size="sm" + /> + + + + + + + +
+ ); + } + if (selected === "category-tabs") { + return ( +
+ + { + if (v !== "sm" && v !== "md") throw new Error(`Unknown tab size "${v}"`); + setTabSize(v); + }} + options={[ + { value: "sm", label: "Small" }, + { value: "md", label: "Medium" }, + ]} + size="sm" + /> + + + { + if (!isGradient(v)) throw new Error(`Unknown gradient "${v}"`); + setTabGradient(v); + }} + options={GRADIENT_OPTIONS} + size="sm" + /> + + + + + + + +
+ ); + } + if (selected === "cursor-blast") { + return ( +
+ + + + + { + const n = Number(e.target.value); + if (!Number.isNaN(n) && n > 0) setBlastLifetime(n); + }} + /> + + + { + const n = Number(e.target.value); + if (!Number.isNaN(n) && n > 0) setBlastMaxActive(n); + }} + /> + + + { + const n = Number(e.target.value); + if (!Number.isNaN(n) && n > 0) setBlastRageThreshold(n); + }} + /> + + + { + const n = Number(e.target.value); + if (!Number.isNaN(n) && n > 0) setBlastRageWindow(n); + }} + /> + + + { + const n = Number(e.target.value); + if (!Number.isNaN(n) && n > 0) setBlastRageRadius(n); + }} + /> + +
+ ); + } + if (selected === "data-table") { + return ( +
+ + setTableTitle(e.target.value)} /> + + + setTableSubtitle(e.target.value)} /> + + + + + + + + + + +
+ ); + } + if (selected === "editable-grid") { + return ( +
+ + { + if (v === "1" || v === "2") { + setGridCols(Number(v) as 1 | 2); + return; + } + throw new Error(`Unknown column count "${v}"`); + }} + options={[ + { value: "1", label: "1 Column" }, + { value: "2", label: "2 Columns" }, + ]} + size="sm" + /> + + + { + if (v === "basic" || v === "full") { + setGridMode(v); + return; + } + throw new Error(`Unknown grid mode "${v}"`); + }} + options={[ + { value: "basic", label: "Basic items" }, + { value: "full", label: "All item types" }, + ]} + size="sm" + /> + + + + + + + + + + +
+ ); + } + if (selected === "input") { + return ( +
+ + setInputPlaceholder(e.target.value)} /> + + + { + if (!isSize3(v)) throw new Error(`Unknown size "${v}"`); + setInputSize(v); + }} + options={SIZE3_OPTIONS} + size="sm" + /> + + + + + + { + setInputPrefix(v); + if (v) { + setInputIcon(false); + } + }} + /> + + + { + if (v === "text" || v === "password") { + setInputType(v); + return; + } + throw new Error(`Unknown input type "${v}"`); + }} + options={[ + { value: "text", label: "Text" }, + { value: "password", label: "Password" }, + ]} + size="sm" + /> + + + + +
+ ); + } + if (selected === "list-item-row") { + return ( +
+ + setListTitle(e.target.value)} /> + + + + + + + + + + +
+ ); + } + if (selected === "menu") { + return ( +
+ + { + if (v === "actions" || v === "selector" || v === "toggles") { + setMenuVariant(v); + return; + } + throw new Error(`Unknown menu variant "${v}"`); + }} + options={[ + { value: "actions", label: "Actions" }, + { value: "selector", label: "Selector" }, + { value: "toggles", label: "Toggles" }, + ]} + size="sm" + /> + + + { + if (v === "button" || v === "icon") { + setMenuTrigger(v); + return; + } + throw new Error(`Unknown menu trigger "${v}"`); + }} + options={[ + { value: "button", label: "Button" }, + { value: "icon", label: "Icon" }, + ]} + size="sm" + /> + + + { + if (v === "start" || v === "center" || v === "end") { + setMenuAlign(v); + return; + } + throw new Error(`Unknown menu align "${v}"`); + }} + options={[ + { value: "start", label: "Start" }, + { value: "center", label: "Center" }, + { value: "end", label: "End" }, + ]} + size="sm" + /> + + + setMenuTriggerLabel(e.target.value)} /> + + + setMenuLabel(e.target.value)} /> + + + + + + { + if (v === "default" || v === "destructive") { + setMenuActionStyle(v); + return; + } + throw new Error(`Unknown menu item style "${v}"`); + }} + options={[ + { value: "default", label: "Default" }, + { value: "destructive", label: "Destructive" }, + ]} + size="sm" + /> + + {menuLastAction && ( + + + {menuLastAction} + + + )} +
+ ); + } + if (selected === "pill-toggle") { + return ( +
+ + { + if (!isSize3(v)) throw new Error(`Unknown size "${v}"`); + setPillSize(v); + }} + options={SIZE3_OPTIONS} + size="sm" + /> + + + + + + + + + + +
+ ); + } + if (selected === "selector-dropdown") { + return ( +
+ + { + if (!isSize3(v)) throw new Error(`Unknown size "${v}"`); + setSelSize(v); + }} + options={SIZE3_OPTIONS} + size="sm" + /> + + + + + + setSelPlaceholder(e.target.value)} /> + + + + +
+ ); + } + // user-list + return ( +
+ + + + + + + + { + if (v === "blue-purple" || v === "cyan-blue" || v === "none") { + setUserGradient(v); + return; + } + throw new Error(`Unknown gradient "${v}"`); + }} + options={[ + { value: "blue-purple", label: "Blue → Purple" }, + { value: "cyan-blue", label: "Cyan → Blue" }, + { value: "none", label: "None" }, + ]} + size="sm" + /> + +
+ ); + } + + // ─── Layout ────────────────────────────────────────────────────────────── + + return ( +
+
+ + {/* Header */} +
+
+ + Playground + + + Explore and configure every design-language component. + +
+
+ { + if (!isComponentId(v)) throw new Error(`Unknown component "${v}"`); + setSelected(v); + }} + options={COMPONENT_LIST} + size="md" + /> +
+
+ + {/* Preview */} +
+
+ {renderPreview()} +
+
+ + {/* Controls */} +
+ + Props + +
+ {renderControls()} +
+
+
+
+ ); +} diff --git a/apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/playground/page.tsx b/apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/playground/page.tsx new file mode 100644 index 0000000000..5264f1feb2 --- /dev/null +++ b/apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/playground/page.tsx @@ -0,0 +1,9 @@ +import PageClient from "./page-client"; + +export const metadata = { + title: "Component Playground", +}; + +export default function Page() { + return ; +} diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/design-language/page-client.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/design-language/page-client.tsx index dc79fff5dc..810db0bb26 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/design-language/page-client.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/design-language/page-client.tsx @@ -1,33 +1,31 @@ "use client"; import { - Alert, - AlertDescription, - AlertTitle, + CursorBlastEffect, + DesignAlert, + DesignBadge, + type DesignBadgeColor, + DesignButton, + DesignCard, + DesignCardTint, + DesignCategoryTabs, + DesignDataTable, + DesignEditableGrid, + type DesignEditableGridItem, + DesignInput, + DesignListItemRow, + DesignMenu, + DesignPillToggle, + DesignSelectorDropdown, + DesignUserList +} from "@/components/design-language"; +import { Link } from "@/components/link"; +import { Button, - DataTable, DataTableColumnHeader, - DataTableViewOptions, - DropdownMenu, - DropdownMenuCheckboxItem, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuLabel, - DropdownMenuRadioGroup, - DropdownMenuRadioItem, - DropdownMenuSeparator, - DropdownMenuTrigger, - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, - Input, Typography, cn, } from "@/components/ui"; -import { EditableGrid, type EditableGridItem } from "@/components/editable-grid"; -import { Link } from "@/components/link"; import { CheckCircle, Cube, @@ -39,42 +37,16 @@ import { MagnifyingGlassIcon, Palette, PencilSimple, - StackSimple, Sliders, SquaresFourIcon, + StackSimple, Tag, Trash, WarningCircle, XCircle } from "@phosphor-icons/react"; -import { ColumnDef, Table as TableType } from "@tanstack/react-table"; -import { useEffect, useMemo, useState } from "react"; - -// Bridge component to capture table instance without violating React rules -// (setState during render is not allowed, so we use useEffect instead) -function TableInstanceBridge({ - tableInstance, - onTableInstance, - onVisibilityChange, -}: { - tableInstance: TableType, - onTableInstance: (table: TableType) => void, - onVisibilityChange: (visibility: Record) => void, -}) { - useEffect(() => { - onTableInstance(tableInstance); - }, [tableInstance, onTableInstance]); - - const currentVisibility = tableInstance.getState().columnVisibility; - // Serialize visibility to avoid unnecessary re-renders from object reference changes - const visibilityKey = JSON.stringify(currentVisibility); - useEffect(() => { - onVisibilityChange(currentVisibility as Record); - // eslint-disable-next-line react-hooks/exhaustive-deps -- intentionally using serialized key for comparison - }, [visibilityKey, onVisibilityChange]); - - return null; -} +import { ColumnDef } from "@tanstack/react-table"; +import { useMemo, useState } from "react"; import { TimeRange, TimeRangeToggle @@ -147,88 +119,6 @@ function PropsTable({ ); } -// ============================================================================= -// GLASSMORPHIC CARD COMPONENT -// From: DESIGN-GUIDE.md - "Glassmorphism & Surfaces" section -// Used in: emails/page-client.tsx, email-themes/page-client.tsx, email-drafts/page-client.tsx -// Key CSS: bg-background/60 backdrop-blur-xl ring-1 ring-foreground/[0.06] -// CRITICAL: Always use "transition-all duration-150 hover:transition-none" -// ============================================================================= -function GlassCard({ - children, - className, - gradientColor = "blue", -}: { - children: React.ReactNode, - className?: string, - gradientColor?: "blue" | "purple" | "green" | "orange" | "default" | "cyan", -}) { - const hoverTints: Record = { - blue: "group-hover:bg-blue-500/[0.03]", - purple: "group-hover:bg-purple-500/[0.03]", - green: "group-hover:bg-emerald-500/[0.03]", - orange: "group-hover:bg-orange-500/[0.03]", - default: "group-hover:bg-slate-500/[0.02]", - cyan: "group-hover:bg-cyan-500/[0.03]", - }; - - return ( -
-
-
-
- {children} -
-
- ); -} - -// Demo card WITH hover tints - used only for demonstrating the hover effect -function GlassCardWithTint({ - children, - className, - gradientColor = "default", -}: { - children: React.ReactNode, - className?: string, - gradientColor: "blue" | "purple" | "green" | "orange" | "default" | "cyan", -}) { - const hoverTints: Record = { - blue: "group-hover/tint:bg-blue-500/[0.02]", - purple: "group-hover/tint:bg-purple-500/[0.02]", - green: "group-hover/tint:bg-emerald-500/[0.02]", - orange: "group-hover/tint:bg-orange-500/[0.02]", - default: "group-hover/tint:bg-slate-500/[0.015]", - cyan: "group-hover/tint:bg-cyan-500/[0.02]", - }; - - return ( -
-
-
-
- {children} -
-
- ); -} - // ============================================================================= // SECTION HEADER WITH ICON // From: DESIGN-GUIDE.md - "Component Patterns" > "Section Header with Icon" @@ -285,8 +175,6 @@ function DesignSection({ // STATUS BADGE COMPONENT // Gradient-based status pills with optional icons and size variants // ============================================================================= -type StatusBadgeColor = "blue" | "cyan" | "purple" | "green" | "orange" | "red"; -type StatusBadgeSize = "sm" | "md"; type ColumnKey = "recipient" | "subject" | "sentAt" | "status"; type DemoEmailRow = { id: string, @@ -296,98 +184,12 @@ type DemoEmailRow = { status: "sent" | "failed" | "scheduled", }; -const STATUS_BADGE_STYLES: Record = { - blue: "text-blue-700 dark:text-blue-400 bg-blue-500/20 dark:bg-blue-500/10 ring-1 ring-blue-500/30 dark:ring-blue-500/20", - cyan: "text-cyan-700 dark:text-cyan-400 bg-cyan-500/20 dark:bg-cyan-500/10 ring-1 ring-cyan-500/30 dark:ring-cyan-500/20", - purple: "text-purple-700 dark:text-purple-400 bg-purple-500/20 dark:bg-purple-500/10 ring-1 ring-purple-500/30 dark:ring-purple-500/20", - green: "text-emerald-700 dark:text-emerald-400 bg-emerald-500/20 dark:bg-emerald-500/10 ring-1 ring-emerald-500/30 dark:ring-emerald-500/20", - orange: "text-amber-700 dark:text-amber-300 bg-amber-500/20 dark:bg-amber-500/10 ring-1 ring-amber-500/30 dark:ring-amber-500/20", - red: "text-red-700 dark:text-red-400 bg-red-500/20 dark:bg-red-500/10 ring-1 ring-red-500/30 dark:ring-red-500/20", -}; - -const DEMO_STATUS_MAP: Record = { +const DEMO_STATUS_MAP: Record = { sent: { label: "Sent", color: "green" }, failed: { label: "Failed", color: "red" }, scheduled: { label: "Scheduled", color: "orange" }, }; -function StatusBadge({ - label, - color, - icon, - size = "md", -}: { - label: string, - color: StatusBadgeColor, - icon?: React.ElementType, - size?: StatusBadgeSize, -}) { - const Icon = icon; - const sizeClasses = size === "sm" - ? "px-2 py-0.5 text-[10px]" - : "px-2.5 py-1 text-[11px]"; - - return ( -
- {Icon && } - {label} -
- ); -} - -// ============================================================================= -// CATEGORY TABS (UNDERLINE STYLE) -// From: apps/page-client.tsx - Category tabs with counts and underline indicator -// Used for: Filtering lists, category navigation -// ============================================================================= -function CategoryTabs({ - categories, - selectedCategory, - onSelect, -}: { - categories: Array<{ id: string, label: string, count: number }>, - selectedCategory: string, - onSelect: (id: string) => void, -}) { - return ( -
- {categories.map((category) => { - const isActive = selectedCategory === category.id; - return ( - - ); - })} -
- ); -} - // ============================================================================= // UNDERLINE TABS // Used for: Small view switchers (charts, lists) @@ -418,134 +220,6 @@ function UnderlineTabsDemo() { ); } -// ============================================================================= -// PILL TOGGLE / VIEWPORT SELECTOR -// From: DESIGN-GUIDE.md - "Time Range Toggle (Pill Buttons)" -// Used in: metrics-page.tsx, email-themes/page-client.tsx (ViewportSelector) -// Container: rounded-xl bg-foreground/[0.04] p-1 backdrop-blur-sm -// Active: bg-background shadow-sm ring-1 ring-foreground/[0.06] -// ============================================================================= -function ViewportSelector({ - options, - selected, - onSelect, -}: { - options: Array<{ id: string, label: string, icon: React.ElementType }>, - selected: string, - onSelect: (id: string) => void, -}) { - return ( -
- {options.map((option) => { - const isActive = selected === option.id; - const Icon = option.icon; - return ( - - ); - })} -
- ); -} - -// ============================================================================= -// LIST ITEM ROW (EMAIL TEMPLATES STYLE) -// From: email-templates/page-client.tsx - Template list item pattern -// Used for: Lists of templates, themes, configurations -// Features: Icon container with hover, edit button, dropdown menu -// ============================================================================= -function ListItemRow({ - icon: Icon, - title, - onEdit, - onDelete, -}: { - icon: React.ElementType, - title: string, - onEdit?: () => void, - onDelete?: () => void, -}) { - return ( -
-
-
-
- -
- {title} -
-
- {onEdit && ( - - )} - {onDelete && ( - - - - - - - Delete - - - - )} -
-
- ); -} - -// ============================================================================= -// DEMO COMPONENTS (for showcasing overview page patterns) -// ============================================================================= - -function UserListItemDemo() { - const users = [ - { name: "John Doe", email: "john@example.com", time: "Active 2h ago", color: "cyan" }, - { name: "Jane Smith", email: "jane@example.com", time: "Active 5h ago", color: "blue" }, - ]; - return ( -
- {users.map((user) => ( - - ))} -
- ); -} - export default function PageClient() { const [selectedCategory, setSelectedCategory] = useState("all"); const [selectedViewport, setSelectedViewport] = useState("phone"); @@ -553,8 +227,6 @@ export default function PageClient() { const [listAction, setListAction] = useState<"edit" | "delete" | null>(null); const [selectedMenuFilter, setSelectedMenuFilter] = useState("all"); const [selectedSelectorValue, setSelectedSelectorValue] = useState("no"); - const [tableDemo, setTableDemo] = useState | null>(null); - const [tableDemoVisibility, setTableDemoVisibility] = useState({}); const [visibleColumns, setVisibleColumns] = useState>({ recipient: true, subject: true, @@ -643,12 +315,12 @@ export default function PageClient() { cell: ({ row }) => { const status = row.getValue("status") as DemoEmailRow["status"]; const config = DEMO_STATUS_MAP[status]; - return ; + return ; }, }, ], [demoDateFormatter]); - const editableGridItems = useMemo(() => [ + const editableGridItems = useMemo(() => [ { type: "text", icon: , @@ -741,6 +413,7 @@ export default function PageClient() { return ( +
@@ -753,352 +426,330 @@ export default function PageClient() {
{/* ============================================================ */} - {/* CARDS */} + {/* ALERT COMPONENT */} {/* ============================================================ */} - -
-
-
- - - Create, edit, and send email drafts - -
-
-
-
- - Placeholder content for the card body. - -
-
+
- -
-
- -
- - Preview - -
-
- - Placeholder content for the card body. - -
-
+
- -
- - Placeholder content for the card body. - -
-
+
-
- {(["blue", "cyan", "purple", "green", "orange", "default"] as const).map((color) => ( - -
- - - Hover to see {color} tint - -
-
- ))} -
+
Props
{/* ============================================================ */} - {/* TABS COMPONENT */} + {/* BADGE COMPONENT */} {/* ============================================================ */} - +
+ + + + + + +
+
+ + +
+ + + +
Props ", description: "Tab items with counts for category tabs." }, - { name: "selectedCategory", type: "string", description: "Currently selected category id." }, - { name: "onSelect", type: "(id: string) => void", description: "Selection handler for category tabs." }, - { name: "size", type: "'sm' | 'md' | 'lg' | ...", default: "'md'", description: "Controls padding and density." }, - { name: "glassmorphic", type: "boolean", default: "true", description: "Enable when tabs are outside a card." }, - { name: "gradient", type: "'blue' | 'cyan' | 'purple' | 'green' | 'orange' | 'default'", description: "Optional accent when glassmorphic is true." }, + { name: "label", type: "string", description: "Text for the badge (used as aria-label when contentMode is 'icon')" }, + { name: "color", type: "'blue' | 'cyan' | 'purple' | 'green' | 'orange' | 'red'", description: "Gradient color theme" }, + { name: "icon", type: "ReactElement", description: "Optional icon. Required when contentMode is 'icon'." }, + { name: "contentMode", type: "'both' | 'text' | 'icon'", default: "'both'", description: "What to display. At least one of text or icon must be shown." }, + { name: "size", type: "'sm' | 'md'", default: "'md'", description: "Badge size" }, + { name: "glassmorphic", type: "boolean", default: "false", description: "Enable only when badges sit on glass." }, ]} />
{/* ============================================================ */} - {/* DROPDOWNS */} + {/* BUTTONS */} {/* ============================================================ */} - - - - - - }> - Edit - - }> - Send email - - - } - onClick={() => new Promise((resolve) => { - setTimeout(() => resolve(), 5000); - })} - > - Delete - - - +
+ + Primary + + + Ghost + + + Secondary + + + Outline + + + Delete + + + Learn more + + + Active + +
- - - - - - - Filter - - - - {menuFilterOptions.map((option) => ( - - {option.label} - - ))} - - - +
+ + Small + + + Default + + + Large + + + + +
- - - - - - - Toggle columns - - - {columnOptions.map((column) => ( - { - setVisibleColumns((prev) => ({ - ...prev, - [column.id]: !!checked, - })); - }} - > - {column.label} - - ))} - - +
+ + Saving + + new Promise((resolve) => { + setTimeout(() => resolve(), 1500); + })} + > + Async Action + +
Props void | Promise", description: "Return a Promise to keep the menu open with a spinner until complete." }, - ]} /> -
-
+ { name: "variant", type: "'default' | 'secondary' | 'outline' | 'ghost' | 'destructive' | 'link' | 'plain'", default: "'default'", description: "Visual style for the button." }, + { name: "size", type: "'default' | 'sm' | 'lg' | 'icon'", default: "'default'", description: "Controls padding and button height." }, + { name: "loading", type: "boolean", default: "false", description: "Shows a spinner and disables the button." }, + { name: "loadingStyle", type: "'spinner' | 'disabled'", default: "'spinner'", description: "Spinner overlay or disabled-only state." }, + { name: "asChild", type: "boolean", default: "false", description: "Renders a child component instead of a native button." }, + { name: "onClick", type: "(event) => void | Promise", description: "Async handlers show loading automatically." }, + ]} /> +
+ {/* ============================================================ */} - {/* SELECTS */} + {/* CARDS */} {/* ============================================================ */} -
- + + + Placeholder content for the card body. + + + + + + + + Placeholder content for the card body. + + + + + + + + Placeholder content for the card body. + + + + + +
+ {(["blue", "cyan", "purple", "green", "orange", "default"] as const).map((color) => ( + +
+ + + Hover to see {color} tint + +
+
+ ))}
Props void", description: "Selection handler for the dropdown." }, - { name: "trigger", type: "ReactElement", description: "Select trigger element (e.g., SelectTrigger)." }, - { name: "options", type: "Array<{ value: string, label: string }>", description: "Selectable options rendered inside SelectContent." }, - { name: "disabled", type: "boolean", default: "false", description: "Disables the select and its trigger." }, + { name: "variant", type: "'header' | 'compact' | 'bodyOnly' | 'glassmorphic'", default: "'header'", description: "Layout style for the card header." }, + { name: "title", type: "string", description: "Primary title when a header is present." }, + { name: "subtitle", type: "string", description: "Optional supporting text under the title." }, + { name: "icon", type: "ReactElement", description: "Optional leading icon in the header." }, + { name: "glassmorphic", type: "boolean", default: "true", description: "Use glass styling when outside another card." }, + { name: "size", type: "'sm' | 'md' | 'lg' | ...", default: "'md'", description: "Controls padding and density." }, + { name: "gradient", type: "'blue' | 'cyan' | 'purple' | 'green' | 'orange' | 'default'", description: "Tint for glassmorphic cards." }, ]} />
{/* ============================================================ */} - {/* TABLES */} + {/* EDITABLE GRID */} {/* ============================================================ */} - -
-
-
- - - Recent delivery activity with quick filters - -
- {tableDemo && ( -
- -
- )} -
-
-
- ( - - )} - /> +
+
+
+
- +
Props >", description: "Row data to render in the table." }, - { name: "defaultSorting", type: "SortingState", description: "Initial sort order for the table." }, - { name: "showDefaultToolbar", type: "boolean", default: "true", description: "Toggle the built-in toolbar." }, - { name: "viewOptions", type: "boolean", default: "false", description: "Use DataTableViewOptions for column toggles." }, - { name: "onRowClick", type: "(row) => void", description: "Optional row click handler for navigation." }, + { name: "items", type: "DesignEditableGridItem[]", description: "Defines editable rows and their input types." }, + { name: "columns", type: "1 | 2", default: "2", description: "Number of columns in the grid." }, + { name: "type", type: "'text' | 'boolean' | 'dropdown' | 'custom-dropdown' | 'custom-button' | 'custom'", description: "Row type that controls the editor." }, + { name: "readOnly", type: "boolean", default: "false", description: "Disables editing for the row." }, + { name: "onUpdate", type: "(value) => Promise", description: "Async handler for updates." }, ]} />
@@ -1117,7 +768,7 @@ export default function PageClient() { description="Default input for forms and settings." >
- +
@@ -1126,7 +777,7 @@ export default function PageClient() { description="Use size sm with a leading icon for compact search." >
- } placeholder="Search products..." @@ -1140,140 +791,160 @@ export default function PageClient() { { name: "size", type: "'sm' | 'md' | 'lg'", default: "'md'", description: "Controls input height and text size." }, { name: "leadingIcon", type: "ReactElement", description: "Optional icon rendered inside the input." }, { name: "prefixItem", type: "ReactElement", description: "Optional leading segment for grouped inputs." }, + { name: "value", type: "string", description: "Controlled input value." }, { name: "placeholder", type: "string", description: "Placeholder text for empty states." }, + { name: "disabled", type: "boolean", default: "false", description: "Disables input interactions." }, { name: "onChange", type: "(event) => void", description: "Change handler for input updates." }, ]} />
{/* ============================================================ */} - {/* EDITABLE GRID */} + {/* LIST COMPONENTS */} {/* ============================================================ */} -
-
-
- -
+
+ setListAction("edit")} + onDelete={() => setListAction("delete")} + /> + + {listAction ? `Last action: ${listAction}` : "Click edit or delete to preview actions."} +
-
+ + + + +
Props Promise", description: "Async handler for updates." }, + { name: "icon", type: "ReactElement", description: "Optional leading icon for list rows." }, + { name: "title", type: "string", description: "Primary row label." }, + { name: "subtitle", type: "string", description: "Optional supporting text." }, + { name: "onClick", type: "() => void", description: "Row click handler." }, + { name: "onEdit", type: "() => void", description: "Optional edit action for row variants." }, + { name: "onDelete", type: "() => void", description: "Optional delete action for row variants." }, + { name: "size", type: "'sm' | 'md' | 'lg' | ...", default: "'md'", description: "Controls row padding and density." }, + { name: "glassmorphic", type: "boolean", default: "true", description: "Use when list is outside a parent card." }, + { name: "gradient", type: "'blue' | 'cyan' | 'purple' | 'green' | 'orange' | 'default'", description: "Optional accent on hover." }, ]} />
{/* ============================================================ */} - {/* BUTTONS */} + {/* DROPDOWNS */} {/* ============================================================ */} -
- - - - - - - -
+ , + }, + { + id: "send-email", + label: "Send email", + icon: , + }, + { + id: "delete", + label: "Delete", + icon: , + itemVariant: "destructive", + onClick: () => new Promise((resolve) => { + setTimeout(() => resolve(), 5000); + }), + }, + ]} + />
-
- - - - -
+ option.id === selectedMenuFilter)?.label ?? "Select"} + label="Filter" + options={menuFilterOptions} + value={selectedMenuFilter} + onValueChange={setSelectedMenuFilter} + />
-
- - -
+ ({ + id: column.id, + label: column.label, + checked: visibleColumns[column.id], + }))} + onToggleChange={(id, checked) => { + if (id !== "recipient" && id !== "subject" && id !== "sentAt" && id !== "status") { + throw new Error(`Unknown column id "${id}" in column toggle menu`); + } + setVisibleColumns((prev) => ({ + ...prev, + [id]: checked, + })); + }} + />
Props void | Promise", description: "Async handlers show loading automatically." }, + { name: "variant", type: "'actions' | 'selector' | 'toggles'", default: "'actions'", description: "Selects action list, radio selector menu, or checkbox settings menu." }, + { name: "trigger", type: "'button' | 'icon'", default: "'button'", description: "Trigger presentation for the menu." }, + { name: "triggerLabel", type: "string", default: "'Open Menu'", description: "Label for button trigger or aria-label for icon trigger." }, + { name: "label", type: "string", description: "Optional section label for grouped items." }, + { name: "items", type: "Array<{ id: string, label: string, icon?: ReactNode, itemVariant?: 'default' | 'destructive', onClick?: () => void | Promise }>", description: "Action menu items for variant='actions'." }, + { name: "options", type: "Array<{ id: string, label: string }> | Array<{ id: string, label: string, checked: boolean }>", description: "Selector options or toggle options depending on variant." }, + { name: "value", type: "string", description: "Selected option id when variant='selector'." }, + { name: "onValueChange", type: "(value: string) => void", description: "Selection handler when variant='selector'." }, + { name: "onToggleChange", type: "(id: string, checked: boolean) => void", description: "Checkbox toggle handler when variant='toggles'." }, + { name: "withIcons", type: "boolean", default: "false", description: "Adds leading icons for action menus." }, + { name: "item.onClick", type: "() => void | Promise", description: "Return a Promise to keep the menu open with a spinner until complete." }, ]} />
@@ -1291,7 +962,14 @@ export default function PageClient() { title="Standard Pill Toggle" description="Default segmented control" > - + {/* ============================================================ */} - {/* ALERT COMPONENT */} + {/* SELECTS */} {/* ============================================================ */} - - - Success - Your changes have been saved successfully. - - - - - - - Error - An error occurred while processing your request. - - - - - - - Warning - You are using a shared email server. Configure a custom SMTP server to customize email templates. - - - - - - - Info - - Configure a custom SMTP server to send manual emails. You can still create and edit drafts. - - +
+ +
Props void", description: "Selection handler for the dropdown." }, + { name: "options", type: "Array<{ value: string, label: string, disabled?: boolean }>", description: "Selectable options rendered in the dropdown." }, + { name: "placeholder", type: "string", default: "'Select'", description: "Placeholder label when no option is selected." }, + { name: "size", type: "'sm' | 'md' | 'lg'", default: "'sm'", description: "Controls trigger height and text size." }, + { name: "disabled", type: "boolean", default: "false", description: "Disables the select and its trigger." }, ]} />
{/* ============================================================ */} - {/* BADGE COMPONENT */} + {/* TABLES */} {/* ============================================================ */} -
- - - - - - -
+
Props >", description: "Row data to render in the table." }, + { name: "defaultColumnFilters", type: "ColumnFiltersState", default: "[]", description: "Initial filter state for table columns." }, + { name: "defaultSorting", type: "SortingState", description: "Initial sort order for the table." }, + { name: "showDefaultToolbar", type: "boolean", default: "false", description: "Toggle the built-in toolbar controls." }, + { name: "viewOptions", type: "boolean", default: "false", description: "Use DataTableViewOptions for column toggles." }, + { name: "onRowClick", type: "(row) => void", description: "Optional row click handler for navigation." }, ]} />
{/* ============================================================ */} - {/* LIST COMPONENTS */} + {/* TABS COMPONENT */} {/* ============================================================ */} -
- setListAction("edit")} - onDelete={() => setListAction("delete")} - /> - - {listAction ? `Last action: ${listAction}` : "Click edit or delete to preview actions."} - -
-
- - - + -
+
Props void", description: "Row click handler." }, - { name: "onEdit", type: "() => void", description: "Optional edit action for row variants." }, - { name: "onDelete", type: "() => void", description: "Optional delete action for row variants." }, - { name: "size", type: "'sm' | 'md' | 'lg' | ...", default: "'md'", description: "Controls row padding and density." }, - { name: "glassmorphic", type: "boolean", default: "true", description: "Use when list is outside a parent card." }, - { name: "gradient", type: "'blue' | 'cyan' | 'purple' | 'green' | 'orange' | 'default'", description: "Optional accent on hover." }, + { name: "categories", type: "Array<{ id: string, label: string, count?: number, badgeCount?: number }>", description: "Tab items. Set count/badgeCount to provide badge values." }, + { name: "selectedCategory", type: "string", description: "Currently selected category id." }, + { name: "onSelect", type: "(id: string) => void", description: "Selection handler for category tabs." }, + { name: "showBadge", type: "boolean", default: "true", description: "Enable/disable the number badge next to each tab label." }, + { name: "size", type: "'sm' | 'md'", default: "'sm'", description: "Controls padding and density." }, + { name: "glassmorphic", type: "boolean", default: "false", description: "Enable when tabs are on glass surfaces." }, + { name: "gradient", type: "'blue' | 'cyan' | 'purple' | 'green' | 'orange' | 'default'", description: "Optional accent when glassmorphic is true." }, ]} />
diff --git a/apps/dashboard/src/app/globals.css b/apps/dashboard/src/app/globals.css index 81ba46f14a..6cfc005b3a 100644 --- a/apps/dashboard/src/app/globals.css +++ b/apps/dashboard/src/app/globals.css @@ -167,6 +167,20 @@ mix-blend-mode: normal; } +::view-transition-old(root), +::view-transition-new(root) { + transform-origin: center center; +} + +/* Applied during theme view-transitions so component-level CSS transitions + (transition-colors, transition-all, etc.) don't lag behind the swipe. */ +.vt-disable-transitions, +.vt-disable-transitions *, +.vt-disable-transitions *::before, +.vt-disable-transitions *::after { + transition-duration: 0s !important; +} + .bg-card { box-shadow: 0 0 3px 3px rgba(0, 0, 0, 0.02); backdrop-filter: blur(12px); diff --git a/apps/dashboard/src/components/design-language/alert.tsx b/apps/dashboard/src/components/design-language/alert.tsx new file mode 100644 index 0000000000..77162c778c --- /dev/null +++ b/apps/dashboard/src/components/design-language/alert.tsx @@ -0,0 +1,120 @@ +"use client"; + +import { CheckCircle, Info, WarningCircle, XCircle } from "@phosphor-icons/react"; +import { cn } from "@/lib/utils"; + +type DesignAlertVariant = "default" | "success" | "error" | "warning" | "info"; + +const variantIconMap = new Map([ + ["default", Info], + ["success", CheckCircle], + ["error", XCircle], + ["warning", WarningCircle], + ["info", Info], +]); + +type VariantStyles = { + container: string, + icon: string, + title: string, +}; + +const variantStyles = new Map([ + [ + "default", + { + container: "bg-background border-border", + icon: "text-foreground", + title: "text-foreground", + }, + ], + [ + "success", + { + container: "bg-green-500/[0.06] border-green-500/30", + icon: "text-green-500", + title: "text-green-600 dark:text-green-400", + }, + ], + [ + "error", + { + container: "bg-red-500/[0.06] border-red-500/30", + icon: "text-red-500", + title: "text-red-600 dark:text-red-400", + }, + ], + [ + "warning", + { + container: "bg-amber-500/[0.08] border-amber-500/40", + icon: "text-amber-600 dark:text-amber-400", + title: "text-amber-700 dark:text-amber-300", + }, + ], + [ + "info", + { + container: "bg-blue-500/[0.06] border-blue-500/30", + icon: "text-blue-500", + title: "text-blue-600 dark:text-blue-400", + }, + ], +]); + +function getMapValueOrThrow(map: Map, key: TKey, mapName: string) { + const value = map.get(key); + if (!value) { + throw new Error(`Missing ${mapName} entry for key "${String(key)}"`); + } + return value; +} + +export type DesignAlertProps = React.HTMLAttributes & { + variant?: DesignAlertVariant, + title?: React.ReactNode, + description?: React.ReactNode, + glassmorphic?: boolean, +}; + +export function DesignAlert({ + variant = "default", + title, + description, + glassmorphic = false, + className, + children, + ...props +}: DesignAlertProps) { + const styles = getMapValueOrThrow(variantStyles, variant, "variantStyles"); + const Icon = getMapValueOrThrow(variantIconMap, variant, "variantIconMap"); + + return ( +
+ +
+ {title && ( +
+ {title} +
+ )} + {description && ( +
+ {description} +
+ )} + {children} +
+
+ ); +} diff --git a/apps/dashboard/src/components/design-language/badge.tsx b/apps/dashboard/src/components/design-language/badge.tsx new file mode 100644 index 0000000000..81a617ff6b --- /dev/null +++ b/apps/dashboard/src/components/design-language/badge.tsx @@ -0,0 +1,89 @@ +"use client"; + +import { cn } from "@/lib/utils"; + +export type DesignBadgeColor = "blue" | "cyan" | "purple" | "green" | "orange" | "red"; +export type DesignBadgeSize = "sm" | "md"; + +const badgeStyles = new Map([ + ["blue", "text-blue-700 dark:text-blue-400 bg-blue-500/20 dark:bg-blue-500/10 ring-1 ring-blue-500/30 dark:ring-blue-500/20"], + ["cyan", "text-cyan-700 dark:text-cyan-400 bg-cyan-500/20 dark:bg-cyan-500/10 ring-1 ring-cyan-500/30 dark:ring-cyan-500/20"], + ["purple", "text-purple-700 dark:text-purple-400 bg-purple-500/20 dark:bg-purple-500/10 ring-1 ring-purple-500/30 dark:ring-purple-500/20"], + ["green", "text-emerald-700 dark:text-emerald-400 bg-emerald-500/20 dark:bg-emerald-500/10 ring-1 ring-emerald-500/30 dark:ring-emerald-500/20"], + ["orange", "text-amber-700 dark:text-amber-300 bg-amber-500/20 dark:bg-amber-500/10 ring-1 ring-amber-500/30 dark:ring-amber-500/20"], + ["red", "text-red-700 dark:text-red-400 bg-red-500/20 dark:bg-red-500/10 ring-1 ring-red-500/30 dark:ring-red-500/20"], +]); + +function getMapValueOrThrow(map: Map, key: TKey, mapName: string) { + const value = map.get(key); + if (!value) { + throw new Error(`Missing ${mapName} entry for key "${String(key)}"`); + } + return value; +} + +/** At least one of showLabel or showIcon must be true. */ +export type DesignBadgeContentMode = "both" | "text" | "icon"; + +export type DesignBadgeProps = { + label: string, + color: DesignBadgeColor, + icon?: React.ElementType, + size?: DesignBadgeSize, + /** What to display: "both" (default), "text" (label only), or "icon" (icon only; requires icon prop). */ + contentMode?: DesignBadgeContentMode, +}; + +function getShowLabelShowIcon( + contentMode: DesignBadgeContentMode, + hasIcon: boolean, +): { showLabel: boolean, showIcon: boolean } { + switch (contentMode) { + case "both": + return { showLabel: true, showIcon: hasIcon }; + case "text": + return { showLabel: true, showIcon: false }; + case "icon": + if (!hasIcon) { + throw new Error("DesignBadge contentMode 'icon' requires the icon prop to be provided."); + } + return { showLabel: false, showIcon: true }; + default: { + const _exhaustive: never = contentMode; + throw new Error(`Unknown contentMode: ${String(_exhaustive)}`); + } + } +} + +export function DesignBadge({ + label, + color, + icon, + size = "md", + contentMode = "both", +}: DesignBadgeProps) { + const Icon = icon; + const { showLabel, showIcon } = getShowLabelShowIcon(contentMode, !!Icon); + if (!showLabel && !showIcon) { + throw new Error("DesignBadge must show at least label or icon."); + } + const sizeClasses = size === "sm" + ? "px-2 py-0.5 text-[10px]" + : "px-2.5 py-1 text-[11px]"; + const colorClasses = getMapValueOrThrow(badgeStyles, color, "badgeStyles"); + + return ( +
+ {showIcon && Icon && } + {showLabel ? label : null} +
+ ); +} diff --git a/apps/dashboard/src/components/design-language/button.tsx b/apps/dashboard/src/components/design-language/button.tsx new file mode 100644 index 0000000000..a427823f06 --- /dev/null +++ b/apps/dashboard/src/components/design-language/button.tsx @@ -0,0 +1,92 @@ +import { Slot, Slottable } from "@radix-ui/react-slot"; +import { forwardRefIfNeeded } from "@stackframe/stack-shared/dist/utils/react"; +import { cva, type VariantProps } from "class-variance-authority"; +import React from "react"; + +import { cn } from "@/lib/utils"; +import { useAsyncCallback } from "@stackframe/stack-shared/dist/hooks/use-async-callback"; +import { runAsynchronouslyWithAlert } from "@stackframe/stack-shared/dist/utils/promises"; +import { Spinner } from "@/components/ui/spinner"; + +const designButtonVariants = cva( + "stack-scope inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50", + { + variants: { + variant: { + default: + "bg-primary text-primary-foreground hover:bg-primary/90", + destructive: + "bg-destructive text-destructive-foreground hover:bg-destructive/90", + outline: + "border border-input bg-white/85 dark:bg-background hover:bg-white dark:hover:bg-accent hover:text-accent-foreground", + secondary: + "bg-secondary text-secondary-foreground hover:bg-secondary/80", + ghost: "hover:bg-accent hover:text-accent-foreground", + link: "text-primary underline-offset-4 hover:underline", + plain: "", + }, + size: { + default: "h-9 px-4 py-2", + sm: "h-8 rounded-md px-3 text-xs", + lg: "h-10 rounded-md px-8", + icon: "h-9 w-9", + }, + }, + defaultVariants: { + variant: "default", + size: "default", + }, + } +); + +export type DesignOriginalButtonProps = { + asChild?: boolean, +} & React.ButtonHTMLAttributes & VariantProps; + +const DesignOriginalButton = forwardRefIfNeeded( + ({ className, variant, size, asChild = false, ...props }, ref) => { + const Comp = asChild ? Slot : "button"; + return ( + + ); + } +); +DesignOriginalButton.displayName = "DesignButton"; + +export type DesignButtonProps = { + onClick?: (e: React.MouseEvent) => void | Promise, + loading?: boolean, + loadingStyle?: "spinner" | "disabled", +} & DesignOriginalButtonProps; + +export const DesignButton = forwardRefIfNeeded( + ({ onClick, loading: loadingProp, loadingStyle = "spinner", children, size, ...props }, ref) => { + const [handleClick, isLoading] = useAsyncCallback(async (e: React.MouseEvent) => { + await onClick?.(e); + }, [onClick]); + + const loading = loadingProp || isLoading; + + return ( + runAsynchronouslyWithAlert(handleClick(e))} + size={size} + className={cn("relative", loading && "[&>:not(.stack-button-do-not-hide-when-siblings-are)]:invisible", props.className)} + > + {loadingStyle === "spinner" && } + + {typeof children === "string" ? {children} : children} + + + ); + } +); +DesignButton.displayName = "DesignButton"; + diff --git a/apps/dashboard/src/components/design-language/card.tsx b/apps/dashboard/src/components/design-language/card.tsx new file mode 100644 index 0000000000..6fbcf222c6 --- /dev/null +++ b/apps/dashboard/src/components/design-language/card.tsx @@ -0,0 +1,199 @@ +"use client"; + +import { Card } from "@/components/ui/card"; +import { cn } from "@/lib/utils"; +import React from "react"; + +// ─── Card nesting context ──────────────────────────────────────────────────── +// Components with a `glassmorphic` prop use this to auto-detect whether they +// sit inside a DesignCard. When they do, glassmorphic defaults to `true`; +// when they don't, it defaults to `false`. + +const DesignCardNestingContext = React.createContext(false); + +/** + * Returns `true` when the calling component is rendered inside a DesignCard. + * Useful for deriving a glassmorphic default. + */ +export function useInsideDesignCard(): boolean { + return React.useContext(DesignCardNestingContext); +} + +/** + * Resolve the effective glassmorphic value. + * - If the caller passed an explicit boolean → honour it. + * - Otherwise → fall back to whether we're inside a DesignCard. + */ +export function useGlassmorphicDefault(explicit: boolean | undefined): boolean { + const insideCard = useInsideDesignCard(); + return explicit ?? insideCard; +} + +type DesignCardVariant = "header" | "compact" | "bodyOnly"; +type DesignCardGradient = "blue" | "cyan" | "purple" | "green" | "orange" | "default"; + +const hoverTintClasses = new Map([ + ["blue", "group-hover:bg-blue-500/[0.03]"], + ["purple", "group-hover:bg-purple-500/[0.03]"], + ["green", "group-hover:bg-emerald-500/[0.03]"], + ["orange", "group-hover:bg-orange-500/[0.03]"], + ["default", "group-hover:bg-slate-500/[0.02]"], + ["cyan", "group-hover:bg-cyan-500/[0.03]"], +]); + +const demoTintClasses = new Map([ + ["blue", "group-hover/tint:bg-blue-500/[0.02]"], + ["purple", "group-hover/tint:bg-purple-500/[0.02]"], + ["green", "group-hover/tint:bg-emerald-500/[0.02]"], + ["orange", "group-hover/tint:bg-orange-500/[0.02]"], + ["default", "group-hover/tint:bg-slate-500/[0.015]"], + ["cyan", "group-hover/tint:bg-cyan-500/[0.02]"], +]); + +const bodyPaddingClass = "p-5"; + +export type DesignCardProps = { + variant?: DesignCardVariant, + title?: React.ReactNode, + subtitle?: React.ReactNode, + icon?: React.ElementType, + glassmorphic?: boolean, + gradient?: DesignCardGradient, + contentClassName?: string, +} & Omit, "title"> + +export function DesignCard({ + variant = "compact", + title, + subtitle, + icon: Icon, + glassmorphic: glassmorphicProp, + gradient = "default", + children, + className, + contentClassName, + ...props +}: DesignCardProps) { + const glassmorphic = useGlassmorphicDefault(glassmorphicProp); + const hoverTintClass = hoverTintClasses.get(gradient) ?? "group-hover:bg-slate-500/[0.02]"; + + return ( + + + {glassmorphic && ( + <> +
+
+ + )} +
+ {variant === "header" && ( +
+ {(title || subtitle || Icon) && ( +
+
+ {(title || Icon) && ( +
+ {Icon && ( +
+ +
+ )} + {title && ( + + {title} + + )} +
+ )} + {subtitle && ( +

+ {subtitle} +

+ )} +
+
+ )} +
+ )} + {variant === "compact" && ( +
+ {Icon && ( +
+ +
+ )} + {title && ( + + {title} + + )} +
+ )} +
+ {children} +
+
+ + + ); +} + +export type DesignCardTintProps = { + gradient: DesignCardGradient, +} & React.ComponentProps<"div"> + +export function DesignCardTint({ + gradient, + className, + children, + ...props +}: DesignCardTintProps) { + const tintClass = demoTintClasses.get(gradient) ?? "group-hover/tint:bg-slate-500/[0.015]"; + + return ( +
+
+
+
+ {children} +
+
+ ); +} diff --git a/apps/dashboard/src/components/design-language/cursor-blast-effect.tsx b/apps/dashboard/src/components/design-language/cursor-blast-effect.tsx new file mode 100644 index 0000000000..1ab0558fd7 --- /dev/null +++ b/apps/dashboard/src/components/design-language/cursor-blast-effect.tsx @@ -0,0 +1,331 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import { createPortal } from "react-dom"; + +type Blast = { + id: number, + x: number, + y: number, + size: number, + hue: number, +}; + +const DEFAULT_BLAST_LIFETIME_MS = 720; +const DEFAULT_MAX_ACTIVE_BLASTS = 18; + +/** Minimum rapid clicks in the time window to count as a rage click */ +const DEFAULT_RAGE_CLICK_THRESHOLD = 3; +/** Time window (ms) in which clicks must occur to be considered rage clicking */ +const DEFAULT_RAGE_CLICK_WINDOW_MS = 600; +/** Max distance (px) between clicks to still count as same-spot rage clicking */ +const DEFAULT_RAGE_CLICK_RADIUS_PX = 60; + +type RecentClick = { + time: number, + x: number, + y: number, +}; + +export type CursorBlastEffectProps = { + /** Lifetime of each blast animation in ms. Default: 720 */ + blastLifetimeMs?: number, + /** Maximum number of concurrent active blasts. Default: 18 */ + maxActiveBlasts?: number, + /** Minimum rapid clicks in the time window to trigger a blast. Default: 3 */ + rageClickThreshold?: number, + /** Time window (ms) for counting rage clicks. Default: 600 */ + rageClickWindowMs?: number, + /** Max distance (px) between clicks to count as same-spot rage clicking. Default: 60 */ + rageClickRadiusPx?: number, + /** + * When provided, the blast effect is scoped to this container element. + * Clicks are only detected within the container and blasts are positioned + * relative to the container rather than the viewport. + */ + containerRef?: React.RefObject, +}; + +export function CursorBlastEffect({ + blastLifetimeMs = DEFAULT_BLAST_LIFETIME_MS, + maxActiveBlasts = DEFAULT_MAX_ACTIVE_BLASTS, + rageClickThreshold = DEFAULT_RAGE_CLICK_THRESHOLD, + rageClickWindowMs = DEFAULT_RAGE_CLICK_WINDOW_MS, + rageClickRadiusPx = DEFAULT_RAGE_CLICK_RADIUS_PX, + containerRef, +}: CursorBlastEffectProps = {}) { + const [blasts, setBlasts] = useState([]); + const [mounted, setMounted] = useState(false); + const idCounterRef = useRef(0); + const timeoutIdsRef = useRef>(new Map()); + const recentClicksRef = useRef([]); + + // Store latest config in refs so the effect callback always reads current values + const configRef = useRef({ + blastLifetimeMs, + maxActiveBlasts, + rageClickThreshold, + rageClickWindowMs, + rageClickRadiusPx, + }); + configRef.current = { + blastLifetimeMs, + maxActiveBlasts, + rageClickThreshold, + rageClickWindowMs, + rageClickRadiusPx, + }; + + useEffect(() => { + setMounted(true); + }, []); + + useEffect(() => { + const removeBlast = (id: number) => { + setBlasts((prev) => prev.filter((blast) => blast.id !== id)); + const timeoutId = timeoutIdsRef.current.get(id); + if (timeoutId !== undefined) { + window.clearTimeout(timeoutId); + timeoutIdsRef.current.delete(id); + } + }; + + const spawnBlast = (x: number, y: number) => { + const cfg = configRef.current; + const nextId = idCounterRef.current; + idCounterRef.current += 1; + + const nextBlast: Blast = { + id: nextId, + x, + y, + size: 44 + Math.random() * 20, + hue: 185 + Math.random() * 35, + }; + + setBlasts((prev) => { + const next = [...prev, nextBlast]; + if (next.length <= cfg.maxActiveBlasts) { + return next; + } + return next.slice(next.length - cfg.maxActiveBlasts); + }); + + const timeoutId = window.setTimeout(() => removeBlast(nextId), cfg.blastLifetimeMs); + timeoutIdsRef.current.set(nextId, timeoutId); + }; + + const onClick = (event: MouseEvent) => { + const cfg = configRef.current; + const now = performance.now(); + + let x: number; + let y: number; + + if (containerRef?.current) { + const rect = containerRef.current.getBoundingClientRect(); + x = event.clientX - rect.left; + y = event.clientY - rect.top; + } else { + x = event.clientX; + y = event.clientY; + } + + // Prune clicks outside the time window + recentClicksRef.current = recentClicksRef.current.filter( + (click) => now - click.time < cfg.rageClickWindowMs, + ); + + recentClicksRef.current.push({ time: now, x, y }); + + // Count how many recent clicks are within the radius of the current click + const nearbyCount = recentClicksRef.current.filter((click) => { + const dx = click.x - x; + const dy = click.y - y; + return Math.sqrt(dx * dx + dy * dy) <= cfg.rageClickRadiusPx; + }).length; + + if (nearbyCount >= cfg.rageClickThreshold) { + spawnBlast(x, y); + } + }; + + const target = containerRef?.current ?? window; + const timeoutIds = timeoutIdsRef.current; + // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion -- EventTarget union requires cast + (target as EventTarget).addEventListener("click", onClick as EventListener); + return () => { + (target as EventTarget).removeEventListener("click", onClick as EventListener); + for (const timeoutId of timeoutIds.values()) { + window.clearTimeout(timeoutId); + } + timeoutIds.clear(); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps -- containerRef identity is stable; config is read from ref + }, [containerRef]); + + if (!mounted) { + return null; + } + + const blastElements = ( + <> + {blasts.map((blast) => ( +
+ + + {Array.from({ length: 10 }).map((_, index) => { + const angle = (360 / 10) * index; + return ( + + + + ); + })} +
+ ))} + + + ); + + // When scoped to a container, render inline (the container must have position: relative) + if (containerRef) { + return ( +
+ {blastElements} +
+ ); + } + + // Default: portal to body with fixed positioning (original behaviour) + return createPortal( +
+ {blastElements} +
, + document.body, + ); +} diff --git a/apps/dashboard/src/components/design-language/editable-grid.tsx b/apps/dashboard/src/components/design-language/editable-grid.tsx new file mode 100644 index 0000000000..608a429d71 --- /dev/null +++ b/apps/dashboard/src/components/design-language/editable-grid.tsx @@ -0,0 +1,615 @@ +"use client"; + +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, + SimpleTooltip, + Spinner, +} from "@/components/ui"; +import { cn } from "@/lib/utils"; +import { useAsyncCallback } from "@stackframe/stack-shared/dist/hooks/use-async-callback"; +import { runAsynchronouslyWithAlert } from "@stackframe/stack-shared/dist/utils/promises"; +import { ArrowCounterClockwise, FloppyDisk } from "@phosphor-icons/react"; +import { useEffect, useRef, useState } from "react"; +import { DesignButton } from "./button"; +import { DesignInput } from "./input"; + +type BaseItemProps = { + itemKey?: string, + icon: React.ReactNode, + name: string, + tooltip?: string, +}; + +type TextItem = BaseItemProps & { + type: "text", + value: string, + onUpdate?: (value: string) => Promise, + readOnly?: boolean, + placeholder?: string, +}; + +type BooleanItem = BaseItemProps & { + type: "boolean", + value: boolean, + onUpdate?: (value: boolean) => Promise, + readOnly?: boolean, + trueLabel?: string, + falseLabel?: string, +}; + +type DropdownOption = { + value: string, + label: string, + disabled?: boolean, + disabledReason?: string, +}; + +type DropdownItem = BaseItemProps & { + type: "dropdown", + value: string, + options: DropdownOption[], + onUpdate?: (value: string) => Promise, + readOnly?: boolean, + extraAction?: { + label: string, + onClick: () => void, + }, +}; + +type CustomDropdownItem = BaseItemProps & { + type: "custom-dropdown", + triggerContent: React.ReactNode, + popoverContent: React.ReactNode, + open?: boolean, + onOpenChange?: (open: boolean) => void, + disabled?: boolean, +}; + +type CustomButtonItem = BaseItemProps & { + type: "custom-button", + children: React.ReactNode, + onClick: () => void, + disabled?: boolean, +}; + +type CustomContentItem = BaseItemProps & { + type: "custom", + children: React.ReactNode, +}; + +export type DesignEditableGridItem = + | TextItem + | BooleanItem + | DropdownItem + | CustomDropdownItem + | CustomButtonItem + | CustomContentItem; + +type DesignEditableGridProps = { + items: DesignEditableGridItem[], + columns?: 1 | 2, + className?: string, + deferredSave?: boolean, + hasChanges?: boolean, + onSave?: () => Promise, + onDiscard?: () => void, + externalModifiedKeys?: Set, +}; + +type DesignEditableInputProps = { + value: string, + initialEditValue?: string | undefined, + onUpdate?: (value: string) => Promise, + readOnly?: boolean, + placeholder?: string, + inputClassName?: string, + shiftTextToLeft?: boolean, + mode?: "text" | "password", +}; + +function DesignEditableInput({ + value, + initialEditValue, + onUpdate, + readOnly, + placeholder, + inputClassName, + shiftTextToLeft, + mode = "text", +}: DesignEditableInputProps) { + const [editValue, setEditValue] = useState(initialEditValue ?? value); + const saveDebounceTimeoutRef = useRef | null>(null); + const lastPersistedValueRef = useRef(value); + const isPersistingRef = useRef(false); + const queuedPersistValueRef = useRef(null); + + useEffect(() => { + setEditValue(value); + lastPersistedValueRef.current = value; + }, [value]); + + useEffect(() => { + return () => { + if (saveDebounceTimeoutRef.current) { + clearTimeout(saveDebounceTimeoutRef.current); + } + }; + }, []); + + const persistValue = (nextValue: string) => { + if (!onUpdate || readOnly) return; + if (nextValue === lastPersistedValueRef.current) return; + + if (isPersistingRef.current) { + queuedPersistValueRef.current = nextValue; + return; + } + + isPersistingRef.current = true; + runAsynchronouslyWithAlert( + Promise.resolve(onUpdate(nextValue)).finally(() => { + lastPersistedValueRef.current = nextValue; + isPersistingRef.current = false; + const queuedValue = queuedPersistValueRef.current; + queuedPersistValueRef.current = null; + if (queuedValue !== null && queuedValue !== lastPersistedValueRef.current) { + persistValue(queuedValue); + } + }) + ); + }; + + const schedulePersist = (nextValue: string) => { + if (saveDebounceTimeoutRef.current) { + clearTimeout(saveDebounceTimeoutRef.current); + } + saveDebounceTimeoutRef.current = setTimeout(() => { + persistValue(nextValue); + }, 350); + }; + + return
+ { + const nextValue = e.target.value; + setEditValue(nextValue); + schedulePersist(nextValue); + }} + onBlur={() => { + if (saveDebounceTimeoutRef.current) { + clearTimeout(saveDebounceTimeoutRef.current); + } + persistValue(editValue); + }} + /> +
; +} + +function GridLabel({ + icon, + name, + tooltip, + isModified, +}: { + icon: React.ReactNode, + name: string, + tooltip?: string, + isModified?: boolean, +}) { + const label = ( + + + {icon} + + {name} + {isModified && ( + + )} + + ); + + if (tooltip) { + return ( + + {label} + + ); + } + + return label; +} + +function EditableBooleanField({ + value, + onUpdate, + readOnly, + trueLabel = "Yes", + falseLabel = "No", +}: { + value: boolean, + onUpdate?: (value: boolean) => Promise, + readOnly?: boolean, + trueLabel?: string, + falseLabel?: string, +}) { + const [isUpdating, setIsUpdating] = useState(false); + + const handleChange = async (newValue: string) => { + if (!onUpdate) return; + setIsUpdating(true); + try { + await onUpdate(newValue === "true"); + } finally { + setIsUpdating(false); + } + }; + + if (readOnly) { + return ( + + {value ? trueLabel : falseLabel} + + ); + } + + return ( +
+ + {isUpdating && ( + + )} +
+ ); +} + +function EditableDropdownField({ + value, + options, + onUpdate, + readOnly, + extraAction, +}: { + value: string, + options: DropdownOption[], + onUpdate?: (value: string) => Promise, + readOnly?: boolean, + extraAction?: { label: string, onClick: () => void }, +}) { + const [isUpdating, setIsUpdating] = useState(false); + + const handleChange = async (newValue: string) => { + if (!onUpdate) return; + setIsUpdating(true); + try { + await onUpdate(newValue); + } finally { + setIsUpdating(false); + } + }; + + const selectedOption = options.find(option => option.value === value); + + if (readOnly) { + return ( + + {selectedOption?.label ?? value} + + ); + } + + return ( +
+ + {isUpdating && ( + + )} +
+ ); +} + +function CustomButtonField({ + children, + onClick, + disabled, +}: { + children: React.ReactNode, + onClick: () => void | Promise, + disabled?: boolean, +}) { + return ( + + {children} + + ); +} + +function CustomDropdownField({ + triggerContent, + disabled, +}: { + triggerContent: React.ReactNode, + disabled?: boolean, +}) { + return ( + + ); +} + +function GridItemValue({ item }: { item: DesignEditableGridItem }) { + switch (item.type) { + case "text": { + return ( + + ); + } + case "boolean": { + return ( + + ); + } + case "dropdown": { + return ( + + ); + } + case "custom-dropdown": { + return ( + + ); + } + case "custom-button": { + return ( + + {item.children} + + ); + } + case "custom": { + return <>{item.children}; + } + } +} + +function GridItemContent({ item, isModified }: { item: DesignEditableGridItem, isModified?: boolean }) { + return ( + <> + +
+ +
+ + ); +} + +function DesignInlineSaveDiscard({ + hasChanges, + onSave, + onDiscard, +}: { + hasChanges: boolean, + onSave: () => Promise, + onDiscard: () => void, +}) { + const [handleSave, isSaving] = useAsyncCallback(onSave, [onSave]); + + return ( +
+ + + Discard + + + + Save + +
+ ); +} + +export function DesignEditableGrid({ + items, + columns = 2, + className, + deferredSave, + hasChanges, + onSave, + onDiscard, + externalModifiedKeys, +}: DesignEditableGridProps) { + const gridCols = columns === 1 + ? "grid-cols-[min-content_1fr]" + : "grid-cols-[min-content_1fr] lg:grid-cols-[min-content_1fr_min-content_1fr]"; + + return ( +
+
+ {items.map((item, index) => ( + + ))} +
+ {deferredSave && onSave && onDiscard && ( + + )} +
+ ); +} diff --git a/apps/dashboard/src/components/design-language/index.ts b/apps/dashboard/src/components/design-language/index.ts new file mode 100644 index 0000000000..1e2fd8ca30 --- /dev/null +++ b/apps/dashboard/src/components/design-language/index.ts @@ -0,0 +1,13 @@ +export * from "./alert"; +export * from "./badge"; +export * from "./button"; +export * from "./card"; +export * from "./cursor-blast-effect"; +export * from "./editable-grid"; +export * from "./input"; +export * from "./list"; +export * from "./menu"; +export * from "./pill-toggle"; +export * from "./select"; +export * from "./table"; +export * from "./tabs"; diff --git a/apps/dashboard/src/components/design-language/input.tsx b/apps/dashboard/src/components/design-language/input.tsx new file mode 100644 index 0000000000..4adea6d8ed --- /dev/null +++ b/apps/dashboard/src/components/design-language/input.tsx @@ -0,0 +1,85 @@ +"use client"; + +import { forwardRefIfNeeded } from "@stackframe/stack-shared/dist/utils/react"; +import React from "react"; + +import { cn } from "@/lib/utils"; + +export type DesignInputProps = { + prefixItem?: React.ReactNode, + leadingIcon?: React.ReactNode, + size?: "sm" | "md" | "lg", +} & Omit, "size">; + +export const DesignInput = forwardRefIfNeeded( + ({ className, type, prefixItem, leadingIcon, size = "md", ...props }, ref) => { + const sizeClasses = size === "sm" + ? "h-8 px-3 text-xs" + : size === "lg" + ? "h-10 px-4 text-sm" + : "h-9 px-3 text-sm"; + const baseClasses = cn( + "stack-scope flex w-full rounded-xl border border-black/[0.08] dark:border-white/[0.06] bg-white/80 dark:bg-foreground/[0.03] shadow-sm ring-1 ring-black/[0.08] dark:ring-white/[0.06]", + "file:border-0 file:bg-transparent file:text-sm file:font-medium", + "placeholder:text-muted-foreground/50 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-foreground/[0.1]", + "disabled:cursor-not-allowed disabled:opacity-50", + "transition-all duration-150 hover:transition-none hover:bg-white dark:hover:bg-foreground/[0.06]", + sizeClasses + ); + + if (prefixItem) { + return ( +
+
+ {prefixItem} +
+ +
+ ); + } + + if (leadingIcon) { + return ( +
+
+ {leadingIcon} +
+ +
+ ); + } + + return ( +
+ +
+ ); + } +); +DesignInput.displayName = "DesignInput"; diff --git a/apps/dashboard/src/components/design-language/list.tsx b/apps/dashboard/src/components/design-language/list.tsx new file mode 100644 index 0000000000..feadec64a3 --- /dev/null +++ b/apps/dashboard/src/components/design-language/list.tsx @@ -0,0 +1,123 @@ +"use client"; + +import { cn } from "@/lib/utils"; +import { DesignButton } from "./button"; +import { DesignMenu } from "./menu"; + +export type DesignListItemRowProps = { + icon: React.ElementType, + title: string, + showIcon?: boolean, + onEdit?: () => void, + onDelete?: () => void, +}; + +export function DesignListItemRow({ + icon: Icon, + title, + showIcon = true, + onEdit, + onDelete, +}: DesignListItemRowProps) { + return ( +
+
+
+ {showIcon && ( +
+ +
+ )} + {title} +
+
+ {onEdit && ( + + Edit + + )} + {onDelete && ( + + )} +
+
+ ); +} + +export type DesignUserListRow = { + name: string, + email: string, + time: string, + color?: "cyan" | "blue", +}; + +export type DesignUserListProps = { + users: DesignUserListRow[], + onUserClick?: (user: DesignUserListRow) => void, + showAvatar?: boolean, + gradient?: "blue-purple" | "cyan-blue" | "none", + className?: string, +}; + +const avatarGradients = { + "blue-purple": "from-blue-500 to-purple-500", + "cyan-blue": "from-cyan-500 to-blue-500", + "none": "from-muted-foreground/30 to-muted-foreground/30", +} as const; + +export function DesignUserList({ + users, + onUserClick, + showAvatar = true, + gradient = "blue-purple", + className, +}: DesignUserListProps) { + return ( +
+ {users.map((user) => ( + + ))} +
+ ); +} diff --git a/apps/dashboard/src/components/design-language/menu.tsx b/apps/dashboard/src/components/design-language/menu.tsx new file mode 100644 index 0000000000..bca73dc352 --- /dev/null +++ b/apps/dashboard/src/components/design-language/menu.tsx @@ -0,0 +1,155 @@ +"use client"; + +import { + DropdownMenu, + DropdownMenuCheckboxItem, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { cn } from "@/lib/utils"; +import { DotsThree } from "@phosphor-icons/react"; +import { DesignButton } from "./button"; + +type DesignMenuTrigger = "button" | "icon"; +type DesignMenuItemVariant = "default" | "destructive"; +type DesignMenuAlign = "start" | "center" | "end"; + +export type DesignMenuActionItem = { + id: string, + label: string, + icon?: React.ReactNode, + itemVariant?: DesignMenuItemVariant, + onClick?: () => void | Promise, +}; + +export type DesignMenuSelectorOption = { + id: string, + label: string, +}; + +export type DesignMenuToggleOption = { + id: string, + label: string, + checked: boolean, +}; + +type DesignMenuBaseProps = { + trigger?: DesignMenuTrigger, + triggerLabel?: string, + triggerIcon?: React.ReactNode, + label?: string, + withIcons?: boolean, + align?: DesignMenuAlign, + contentClassName?: string, +}; + +type DesignMenuActionsProps = DesignMenuBaseProps & { + variant: "actions", + items: DesignMenuActionItem[], +}; + +type DesignMenuSelectorProps = DesignMenuBaseProps & { + variant: "selector", + options: DesignMenuSelectorOption[], + value: string, + onValueChange: (value: string) => void, +}; + +type DesignMenuTogglesProps = DesignMenuBaseProps & { + variant: "toggles", + options: DesignMenuToggleOption[], + onToggleChange: (id: string, checked: boolean) => void, +}; + +export type DesignMenuProps = + | DesignMenuActionsProps + | DesignMenuSelectorProps + | DesignMenuTogglesProps; + +const destructiveItemClasses = "text-red-600 dark:text-red-400 focus:bg-red-500/10"; + +export function DesignMenu(props: DesignMenuProps) { + const align = props.align ?? (props.variant === "toggles" ? "end" : "start"); + const triggerLabel = props.triggerLabel ?? "Open Menu"; + const trigger = props.trigger ?? "button"; + const triggerIcon = props.triggerIcon ?? ; + + return ( + + + {trigger === "button" ? ( + + {triggerLabel} + + ) : ( + + {triggerIcon} + + )} + + + {props.label && ( + <> + + {props.label} + + + + )} + + {props.variant === "actions" && props.items.map((item) => { + const itemIcon = props.withIcons ? item.icon : undefined; + const itemClasses = item.itemVariant === "destructive" ? destructiveItemClasses : undefined; + + return ( + + {item.label} + + ); + })} + + {props.variant === "selector" && ( + + {props.options.map((option) => ( + + {option.label} + + ))} + + )} + + {props.variant === "toggles" && props.options.map((option) => ( + props.onToggleChange(option.id, !!checked)} + onSelect={(e) => e.preventDefault()} + > + {option.label} + + ))} + + + ); +} diff --git a/apps/dashboard/src/components/design-language/pill-toggle.tsx b/apps/dashboard/src/components/design-language/pill-toggle.tsx new file mode 100644 index 0000000000..23ab123b64 --- /dev/null +++ b/apps/dashboard/src/components/design-language/pill-toggle.tsx @@ -0,0 +1,145 @@ +"use client"; + +import { useState } from "react"; +import { cn } from "@/lib/utils"; +import { runAsynchronouslyWithAlert } from "@stackframe/stack-shared/dist/utils/promises"; +import { Spinner } from "@/components/ui/spinner"; +import { useGlassmorphicDefault } from "./card"; + +type DesignPillToggleSize = "sm" | "md" | "lg"; +type DesignPillToggleGradient = "blue" | "cyan" | "purple" | "green" | "orange" | "default"; + +export type DesignPillToggleOption = { + id: string, + label: string, + icon?: React.ElementType, +}; + +export type DesignPillToggleProps = { + options: DesignPillToggleOption[], + selected: string, + onSelect: (id: string) => void | Promise, + size?: DesignPillToggleSize, + glassmorphic?: boolean, + gradient?: DesignPillToggleGradient, + /** Show the icon portion of each pill (when the option provides one). Defaults to true. At least one of showIcons/showLabels must be true. */ + showIcons?: boolean, + /** Show the text label of each pill. Defaults to true. At least one of showIcons/showLabels must be true. */ + showLabels?: boolean, + className?: string, +}; + +type SizeClass = { + button: string, + icon: string, +}; + +const sizeClasses = new Map([ + ["sm", { button: "px-3 py-1.5 text-xs", icon: "h-3.5 w-3.5" }], + ["md", { button: "px-4 py-2 text-sm", icon: "h-4 w-4" }], + ["lg", { button: "px-5 py-2.5 text-sm", icon: "h-4 w-4" }], +]); + +const gradientClasses = new Map([ + ["blue", "ring-blue-500/20 dark:ring-blue-400/20"], + ["cyan", "ring-cyan-500/20 dark:ring-cyan-400/20"], + ["purple", "ring-purple-500/20 dark:ring-purple-400/20"], + ["green", "ring-emerald-500/20 dark:ring-emerald-400/20"], + ["orange", "ring-amber-500/20 dark:ring-amber-400/20"], + ["default", "ring-black/[0.12] dark:ring-white/[0.06]"], +]); + +function getMapValueOrThrow(map: Map, key: TKey, mapName: string) { + const value = map.get(key); + if (!value) { + throw new Error(`Missing ${mapName} entry for key "${String(key)}"`); + } + return value; +} + +export function DesignPillToggle({ + options, + selected, + onSelect, + size = "md", + glassmorphic: glassmorphicProp, + gradient = "default", + showIcons = true, + showLabels = true, + className, +}: DesignPillToggleProps) { + const glassmorphic = useGlassmorphicDefault(glassmorphicProp); + const sizeClass = getMapValueOrThrow(sizeClasses, size, "sizeClasses"); + const activeRingClass = getMapValueOrThrow(gradientClasses, gradient, "gradientClasses"); + + // At least one of showIcons/showLabels must be true + const effectiveShowLabels = !showIcons ? true : showLabels; + const effectiveShowIcons = !showLabels ? true : showIcons; + + const [loadingOptionId, setLoadingOptionId] = useState(null); + + const handleClick = (optionId: string) => { + const result = onSelect(optionId); + if (result && typeof (result as Promise).then === "function") { + setLoadingOptionId(optionId); + runAsynchronouslyWithAlert( + Promise.resolve(result).finally(() => setLoadingOptionId(null)) + ); + } + }; + + return ( +
+ {options.map((option) => { + const isActive = selected === option.id; + const Icon = option.icon; + return ( + + ); + })} +
+ ); +} diff --git a/apps/dashboard/src/components/design-language/select.tsx b/apps/dashboard/src/components/design-language/select.tsx new file mode 100644 index 0000000000..5153d67323 --- /dev/null +++ b/apps/dashboard/src/components/design-language/select.tsx @@ -0,0 +1,75 @@ +"use client"; + +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { cn } from "@/lib/utils"; + +type DesignSelectorSize = "sm" | "md" | "lg"; + +export type DesignSelectorOption = { + value: string, + label: string, + disabled?: boolean, +}; + +export type DesignSelectorDropdownProps = { + value: string, + onValueChange: (value: string) => void, + options: DesignSelectorOption[], + disabled?: boolean, + placeholder?: string, + size?: DesignSelectorSize, + className?: string, + triggerClassName?: string, + contentClassName?: string, +}; + +const triggerSizeClasses = new Map([ + ["sm", "h-8 px-3 text-xs rounded-lg"], + ["md", "h-9 px-3 text-sm rounded-xl"], + ["lg", "h-10 px-4 text-sm rounded-xl"], +]); + +function getMapValueOrThrow(map: Map, key: TKey, mapName: string) { + const value = map.get(key); + if (!value) { + throw new Error(`Missing ${mapName} entry for key "${String(key)}"`); + } + return value; +} + +export function DesignSelectorDropdown({ + value, + onValueChange, + options, + disabled = false, + placeholder = "Select", + size = "sm", + className, + triggerClassName, + contentClassName, +}: DesignSelectorDropdownProps) { + const triggerSizeClass = getMapValueOrThrow(triggerSizeClasses, size, "triggerSizeClasses"); + + return ( +
+ +
+ ); +} diff --git a/apps/dashboard/src/components/design-language/table.tsx b/apps/dashboard/src/components/design-language/table.tsx new file mode 100644 index 0000000000..2a6b172cfa --- /dev/null +++ b/apps/dashboard/src/components/design-language/table.tsx @@ -0,0 +1,104 @@ +"use client"; + +import { DataTable, DataTableViewOptions } from "@/components/ui"; +import { cn } from "@/lib/utils"; +import { ColumnDef, ColumnFiltersState, SortingState, Table as TableType } from "@tanstack/react-table"; +import { useState } from "react"; +import { DesignCard } from "./card"; + +export type DesignDataTableProps = { + columns: ColumnDef[], + data: TData[], + title?: string, + subtitle?: string, + icon?: React.ElementType, + defaultColumnFilters?: ColumnFiltersState, + defaultSorting?: SortingState, + showDefaultToolbar?: boolean, + showResetFilters?: boolean, + viewOptions?: boolean, + /** When false, hides the entire header section (title, subtitle, icon, view options). Defaults to true. */ + showHeader?: boolean, + onRowClick?: (row: TData) => void, + className?: string, + contentClassName?: string, +}; + +export function DesignDataTable({ + columns, + data, + title, + subtitle, + icon: Icon, + defaultColumnFilters = [], + defaultSorting = [], + showDefaultToolbar = false, + showResetFilters = false, + viewOptions = false, + showHeader = true, + onRowClick, + className, + contentClassName, +}: DesignDataTableProps) { + const [tableInstance, setTableInstance] = useState | null>(null); + + return ( + + {showHeader && (title || subtitle || Icon || viewOptions) && ( +
+
+
+ {(title || Icon) && ( +
+ {Icon && ( +
+ +
+ )} + {title && ( + + {title} + + )} +
+ )} + {subtitle && ( +

+ {subtitle} +

+ )} +
+ {viewOptions && tableInstance && ( +
+ +
+ )} +
+
+ )} +
+ +
+
+ ); +} diff --git a/apps/dashboard/src/components/design-language/tabs.tsx b/apps/dashboard/src/components/design-language/tabs.tsx new file mode 100644 index 0000000000..a765b4d9ba --- /dev/null +++ b/apps/dashboard/src/components/design-language/tabs.tsx @@ -0,0 +1,197 @@ +"use client"; + +import { useState } from "react"; +import { cn } from "@/lib/utils"; +import { runAsynchronouslyWithAlert } from "@stackframe/stack-shared/dist/utils/promises"; +import { Spinner } from "@/components/ui/spinner"; +import { useGlassmorphicDefault } from "./card"; + +type DesignTabsSize = "sm" | "md"; +type DesignTabsGradient = "blue" | "cyan" | "purple" | "green" | "orange" | "default"; + +export type DesignCategoryTabItem = { + id: string, + label: string, + count?: number, + badgeCount?: number, +}; + +export type DesignCategoryTabsProps = Omit, "onSelect"> & { + categories: DesignCategoryTabItem[], + selectedCategory: string, + onSelect: (id: string) => void | Promise, + showBadge?: boolean, + size?: DesignTabsSize, + glassmorphic?: boolean, + gradient?: DesignTabsGradient, +}; + +type TabSizeClass = { + button: string, + badge: string, +}; + +type GradientClass = { + activeText: string, + activeBadge: string, + underline: string, +}; + +const tabSizeClasses = new Map([ + ["sm", { button: "px-3 py-2 text-xs", badge: "text-[10px] px-1.5 py-0.5" }], + ["md", { button: "px-4 py-3 text-sm", badge: "text-xs px-1.5 py-0.5" }], +]); + +const gradientClasses = new Map([ + [ + "blue", + { + activeText: "text-blue-700 dark:text-blue-400", + activeBadge: "bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-400", + underline: "bg-blue-700 dark:bg-blue-400", + }, + ], + [ + "cyan", + { + activeText: "text-cyan-700 dark:text-cyan-300", + activeBadge: "bg-cyan-100 dark:bg-cyan-900/30 text-cyan-700 dark:text-cyan-300", + underline: "bg-cyan-600 dark:bg-cyan-400", + }, + ], + [ + "purple", + { + activeText: "text-purple-700 dark:text-purple-300", + activeBadge: "bg-purple-100 dark:bg-purple-900/30 text-purple-700 dark:text-purple-300", + underline: "bg-purple-600 dark:bg-purple-400", + }, + ], + [ + "green", + { + activeText: "text-emerald-700 dark:text-emerald-300", + activeBadge: "bg-emerald-100 dark:bg-emerald-900/30 text-emerald-700 dark:text-emerald-300", + underline: "bg-emerald-600 dark:bg-emerald-400", + }, + ], + [ + "orange", + { + activeText: "text-amber-700 dark:text-amber-300", + activeBadge: "bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-300", + underline: "bg-amber-600 dark:bg-amber-400", + }, + ], + [ + "default", + { + activeText: "text-foreground", + activeBadge: "bg-foreground/10 text-foreground", + underline: "bg-foreground/80", + }, + ], +]); + +function getMapValueOrThrow(map: Map, key: TKey, mapName: string) { + const value = map.get(key); + if (!value) { + throw new Error(`Missing ${mapName} entry for key "${String(key)}"`); + } + return value; +} + +export function DesignCategoryTabs({ + categories, + selectedCategory, + onSelect, + showBadge = true, + size = "sm", + glassmorphic: glassmorphicProp, + gradient = "blue", + className, + ...props +}: DesignCategoryTabsProps) { + const glassmorphic = useGlassmorphicDefault(glassmorphicProp); + const sizeClass = getMapValueOrThrow(tabSizeClasses, size, "tabSizeClasses"); + const gradientClass = getMapValueOrThrow(gradientClasses, gradient, "gradientClasses"); + const [loadingCategoryId, setLoadingCategoryId] = useState(null); + + const handleSelect = (categoryId: string) => { + const result = onSelect(categoryId); + if (result && typeof (result as Promise).then === "function") { + setLoadingCategoryId(categoryId); + runAsynchronouslyWithAlert( + Promise.resolve(result).finally(() => setLoadingCategoryId(null)) + ); + } + }; + + return ( +
+ {categories.map((category) => { + const isActive = selectedCategory === category.id; + const badgeValue = category.badgeCount ?? category.count; + const shouldShowBadge = showBadge && badgeValue !== undefined; + + return ( + + ); + })} +
+ ); +} diff --git a/apps/dashboard/src/components/theme-toggle.tsx b/apps/dashboard/src/components/theme-toggle.tsx index 7809938cb1..7b9ec27551 100644 --- a/apps/dashboard/src/components/theme-toggle.tsx +++ b/apps/dashboard/src/components/theme-toggle.tsx @@ -2,7 +2,6 @@ import { Button } from "@/components/ui"; import { MoonIcon, SunIcon } from "@phosphor-icons/react"; import { runAsynchronously } from "@stackframe/stack-shared/dist/utils/promises"; import { useTheme } from "next-themes"; -import { useRef } from "react"; type ViewTransitionWithReady = { ready: Promise, @@ -12,9 +11,10 @@ type DocumentWithViewTransition = globalThis.Document & { startViewTransition?: (callback: () => void) => ViewTransitionWithReady, }; +const TRANSITION_DURATION_MS = 600; + export default function ThemeToggle() { const { resolvedTheme, setTheme } = useTheme(); - const buttonRef = useRef(null); const isReady = resolvedTheme === "dark" || resolvedTheme === "light"; const handleToggle = () => { @@ -31,20 +31,14 @@ export default function ThemeToggle() { const prefersReducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches; const documentWithTransition: DocumentWithViewTransition = document; - const button = buttonRef.current; - if (!documentWithTransition.startViewTransition || prefersReducedMotion || !button) { + if (!documentWithTransition.startViewTransition || prefersReducedMotion) { setTheme(nextTheme); return; } - const rect = button.getBoundingClientRect(); - const x = rect.left + rect.width / 2; - const y = rect.top + rect.height / 2; - const maxRadius = Math.hypot( - Math.max(x, window.innerWidth - x), - Math.max(y, window.innerHeight - y) - ); + // Temporarily kill component-level CSS transitions so colors flip instantly. + document.documentElement.classList.add("vt-disable-transitions"); const transition = documentWithTransition.startViewTransition(() => { setTheme(nextTheme); @@ -52,19 +46,41 @@ export default function ThemeToggle() { runAsynchronously(async () => { await transition.ready; + + // --- Old view: shrinks away into the distance --- document.documentElement.animate( { - clipPath: [ - `circle(0px at ${x}px ${y}px)`, - `circle(${maxRadius}px at ${x}px ${y}px)` - ], + transform: ["scale(1)", "scale(0.82)"], + opacity: [1, 0], + filter: ["blur(0px)", "blur(4px)"], }, { - duration: 450, - easing: "ease-in-out", + duration: TRANSITION_DURATION_MS * 0.45, + easing: "cubic-bezier(0.4, 0, 1, 1)", + pseudoElement: "::view-transition-old(root)", + fill: "forwards", + }, + ); + + // --- New view: rushes in from zoomed-in, lands with a soft bounce --- + document.documentElement.animate( + [ + { transform: "scale(1.18)", opacity: 0, filter: "blur(4px)", offset: 0 }, + { transform: "scale(0.994)", opacity: 1, filter: "blur(0px)", offset: 0.6 }, + { transform: "scale(1.003)", opacity: 1, filter: "blur(0px)", offset: 0.82 }, + { transform: "scale(1)", opacity: 1, filter: "blur(0px)", offset: 1 }, + ], + { + duration: TRANSITION_DURATION_MS, + easing: "cubic-bezier(0.22, 1, 0.36, 1)", pseudoElement: "::view-transition-new(root)", - } + }, ); + + // Re-enable component CSS transitions + setTimeout(() => { + document.documentElement.classList.remove("vt-disable-transitions"); + }, TRANSITION_DURATION_MS); }); }; @@ -74,7 +90,6 @@ export default function ThemeToggle() { size="icon" className="w-8 h-8 hover:bg-muted/50" onClick={handleToggle} - ref={buttonRef} disabled={!isReady} aria-label="Toggle theme" > From ef53c7fe09fce5479e87f9874febe07e11482cc8 Mon Sep 17 00:00:00 2001 From: Developing-Gamer Date: Tue, 10 Feb 2026 21:41:40 -0800 Subject: [PATCH 12/18] Fixes --- .../[projectId]/design-language/page-client.tsx | 1 - apps/dashboard/src/components/theme-provider.tsx | 11 ----------- 2 files changed, 12 deletions(-) diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/design-language/page-client.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/design-language/page-client.tsx index 810db0bb26..853aa23941 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/design-language/page-client.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/design-language/page-client.tsx @@ -33,7 +33,6 @@ import { Envelope, FileText, HardDrive, - Info, MagnifyingGlassIcon, Palette, PencilSimple, diff --git a/apps/dashboard/src/components/theme-provider.tsx b/apps/dashboard/src/components/theme-provider.tsx index 87dee12393..f15aff5d30 100644 --- a/apps/dashboard/src/components/theme-provider.tsx +++ b/apps/dashboard/src/components/theme-provider.tsx @@ -1,19 +1,8 @@ 'use client'; import { ThemeProvider as NextThemeProvider } from "next-themes"; -import { useEffect, useState } from "react"; export function ThemeProvider(props: { children: React.ReactNode }) { - const [mounted, setMounted] = useState(false); - - useEffect(() => { - setMounted(true); - }, []); - - if (!mounted) { - return <>{props.children}; - } - return ( {props.children} From 0b08b97d71c4ba5ca7e0cd7f93b180645c76a864 Mon Sep 17 00:00:00 2001 From: Developing-Gamer Date: Tue, 10 Feb 2026 22:16:07 -0800 Subject: [PATCH 13/18] lint errors fixed --- apps/dashboard/src/components/design-language/badge.tsx | 9 ++++++--- .../endpoints/api/v1/internal/email-drafts.test.ts | 2 +- .../endpoints/api/v1/internal/email-templates.test.ts | 2 +- 3 files changed, 8 insertions(+), 5 deletions(-) diff --git a/apps/dashboard/src/components/design-language/badge.tsx b/apps/dashboard/src/components/design-language/badge.tsx index 81a617ff6b..025ce63b5c 100644 --- a/apps/dashboard/src/components/design-language/badge.tsx +++ b/apps/dashboard/src/components/design-language/badge.tsx @@ -39,15 +39,18 @@ function getShowLabelShowIcon( hasIcon: boolean, ): { showLabel: boolean, showIcon: boolean } { switch (contentMode) { - case "both": + case "both": { return { showLabel: true, showIcon: hasIcon }; - case "text": + } + case "text": { return { showLabel: true, showIcon: false }; - case "icon": + } + case "icon": { if (!hasIcon) { throw new Error("DesignBadge contentMode 'icon' requires the icon prop to be provided."); } return { showLabel: false, showIcon: true }; + } default: { const _exhaustive: never = contentMode; throw new Error(`Unknown contentMode: ${String(_exhaustive)}`); diff --git a/apps/e2e/tests/backend/endpoints/api/v1/internal/email-drafts.test.ts b/apps/e2e/tests/backend/endpoints/api/v1/internal/email-drafts.test.ts index 0c0246dd05..488ce6fd0a 100644 --- a/apps/e2e/tests/backend/endpoints/api/v1/internal/email-drafts.test.ts +++ b/apps/e2e/tests/backend/endpoints/api/v1/internal/email-drafts.test.ts @@ -755,4 +755,4 @@ it("should reject theme_tsx_source that does not export EmailTheme function", as \`, } `); -}); \ No newline at end of file +}); diff --git a/apps/e2e/tests/backend/endpoints/api/v1/internal/email-templates.test.ts b/apps/e2e/tests/backend/endpoints/api/v1/internal/email-templates.test.ts index 5c30ae583b..6e196ae7d1 100644 --- a/apps/e2e/tests/backend/endpoints/api/v1/internal/email-templates.test.ts +++ b/apps/e2e/tests/backend/endpoints/api/v1/internal/email-templates.test.ts @@ -447,4 +447,4 @@ it("should return NotFound when deleting a non-existent template", async ({ expe // Verify that we get a 404 NotFound error expect(deleteResponse.status).toBe(404); expect(deleteResponse.body).toContain("No template found with given id"); -}); \ No newline at end of file +}); From cf07d65169bb13c486698d0f79539c866626c1e4 Mon Sep 17 00:00:00 2001 From: Konstantin Wohlwend Date: Tue, 10 Feb 2026 22:49:56 -0800 Subject: [PATCH 14/18] - show code on playground - playground shows "default" glassmorphic as option - card's variant is automatically determined from title - data table is no longer wrapped with card by default - if data table has no row click, the rows should not have a hover effect - ListItemRow is more customizable now - PillToggle with showLabels=false now shows the label as a tooltip - Small ListItemRow --- .vscode/settings.json | 1 + .../playground/page-client.tsx | 400 ++++++++++++++---- .../design-language/page-client.tsx | 33 +- .../src/components/design-language/card.tsx | 80 ++-- .../src/components/design-language/list.tsx | 248 ++++++++--- .../design-language/pill-toggle.tsx | 35 +- .../src/components/design-language/table.tsx | 92 +--- apps/dashboard/src/components/navbar.tsx | 8 +- .../components/ui/data-table/data-table.tsx | 4 +- 9 files changed, 618 insertions(+), 283 deletions(-) diff --git a/.vscode/settings.json b/.vscode/settings.json index 1fda6ee8b4..ba2dedd57b 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -7,6 +7,7 @@ "typescript.tsdk": "node_modules/typescript/lib", "editor.tabSize": 2, "cSpell.words": [ + "glassmorphic", "sparkline", "Clickhouse", "pushable", diff --git a/apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/playground/page-client.tsx b/apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/playground/page-client.tsx index ec40c4b5bd..309488c498 100644 --- a/apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/playground/page-client.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/playground/page-client.tsx @@ -19,6 +19,7 @@ import { DesignSelectorDropdown, DesignUserList, } from "@/components/design-language"; +import { CodeBlock } from "@/components/code-block"; import { DataTableColumnHeader, Typography } from "@/components/ui"; import { CheckCircle, @@ -143,6 +144,29 @@ function BoolToggle({ ); } +function GlassmorphicToggle({ + value, + onChange, +}: { + value: boolean | undefined, + onChange: (v: boolean | undefined) => void, +}) { + return ( +
+ onChange(v === "default" ? undefined : v === "true")} + options={[ + { value: "default", label: "Default" }, + { value: "true", label: "On" }, + { value: "false", label: "Off" }, + ]} + size="sm" + /> +
+ ); +} + // ─── Demo data ─────────────────────────────────────────────────────────────── type DemoProduct = { @@ -196,16 +220,14 @@ export default function PageClient() { const [btnLoading, setBtnLoading] = useState(false); // Card - const [cardVariant, setCardVariant] = useState<"header" | "compact" | "bodyOnly">("compact"); const [cardTitle, setCardTitle] = useState("Featured Bundle"); const [cardSubtitle, setCardSubtitle] = useState("Save 20% this week."); const [cardGradient, setCardGradient] = useState("default"); - const [cardGlass, setCardGlass] = useState(true); - const [cardShowIcon, setCardShowIcon] = useState(true); + const [cardGlass, setCardGlass] = useState(true); // Category Tabs const [tabSize, setTabSize] = useState<"sm" | "md">("sm"); - const [tabGlass, setTabGlass] = useState(false); + const [tabGlass, setTabGlass] = useState(false); const [tabGradient, setTabGradient] = useState("blue"); const [tabSelected, setTabSelected] = useState("all"); const [tabShowBadge, setTabShowBadge] = useState(true); @@ -220,10 +242,6 @@ export default function PageClient() { const [blastRageRadius, setBlastRageRadius] = useState(60); // Data Table - const [tableTitle, setTableTitle] = useState("Products"); - const [tableSubtitle, setTableSubtitle] = useState("All items in catalog"); - const [tableShowHeader, setTableShowHeader] = useState(true); - const [tableShowIcon, setTableShowIcon] = useState(true); const [tableClickableRows, setTableClickableRows] = useState(false); const [tableLastRowClick, setTableLastRowClick] = useState(""); @@ -245,9 +263,11 @@ export default function PageClient() { // List Item Row const [listTitle, setListTitle] = useState("Premium Support Plan"); - const [listShowIcon, setListShowIcon] = useState(true); - const [listEdit, setListEdit] = useState(true); - const [listDelete, setListDelete] = useState(true); + const [listSubtitle, setListSubtitle] = useState("3 seats remaining"); + const [listSize, setListSize] = useState<"sm" | "lg">("lg"); + const [listWithIcon, setListWithIcon] = useState(true); + const [listShowEditBtn, setListShowEditBtn] = useState(true); + const [listShowMenuBtn, setListShowMenuBtn] = useState(true); const [listLastAction, setListLastAction] = useState(""); // Menu @@ -264,9 +284,9 @@ export default function PageClient() { // Pill Toggle const [pillSize, setPillSize] = useState("md"); - const [pillGlass, setPillGlass] = useState(false); - const [pillShowIcons, setPillShowIcons] = useState(true); + const [pillGlass, setPillGlass] = useState(false); const [pillShowLabels, setPillShowLabels] = useState(true); + const [pillWithIcons, setPillWithIcons] = useState(true); const [pillSelected, setPillSelected] = useState("a"); // Selector Dropdown @@ -442,13 +462,19 @@ export default function PageClient() { ); } if (selected === "card") { + // Title/icon/subtitle props are conditional: when a title is provided, + // icon is required by the DesignCard type system. + const titleProps = cardTitle + ? { + title: cardTitle, + icon: Package, + ...(cardSubtitle ? { subtitle: cardSubtitle } : {}), + } satisfies { title: React.ReactNode, icon: React.ElementType, subtitle?: React.ReactNode } + : {}; return (
@@ -502,10 +528,6 @@ export default function PageClient() { return (
setListLastAction("edit"), + }] : []), + ...(listShowMenuBtn ? [{ + id: "more", + label: "Options", + display: "icon" as const, + onClick: [ + { id: "duplicate", label: "Duplicate", onClick: () => setListLastAction("duplicate") }, + { id: "delete", label: "Delete", itemVariant: "destructive" as const, onClick: () => setListLastAction("delete") }, + ], + }] : []), + ]; return (
setListLastAction("edit") : undefined} - onDelete={listDelete ? () => setListLastAction("delete") : undefined} + subtitle={listSubtitle || undefined} + size={listSize} + buttons={listButtons.length > 0 ? listButtons : undefined} /> {listLastAction && ( @@ -647,15 +685,14 @@ export default function PageClient() { return ( ); @@ -857,29 +894,11 @@ export default function PageClient() { if (selected === "card") { return (
- - { - if (v === "header" || v === "compact" || v === "bodyOnly") { - setCardVariant(v); - return; - } - throw new Error(`Unknown card variant "${v}"`); - }} - options={[ - { value: "header", label: "Header" }, - { value: "compact", label: "Compact" }, - { value: "bodyOnly", label: "Body Only" }, - ]} - size="sm" - /> - - setCardTitle(e.target.value)} /> + setCardTitle(e.target.value)} placeholder="(empty = body only)" /> - setCardSubtitle(e.target.value)} /> + setCardSubtitle(e.target.value)} placeholder="(empty = compact header)" /> - - - - +
); @@ -930,7 +946,7 @@ export default function PageClient() { /> - + @@ -1005,18 +1021,6 @@ export default function PageClient() { if (selected === "data-table") { return (
- - setTableTitle(e.target.value)} /> - - - setTableSubtitle(e.target.value)} /> - - - - - - - @@ -1129,17 +1133,37 @@ export default function PageClient() { if (selected === "list-item-row") { return (
+ + { + if (v === "sm" || v === "lg") { + setListSize(v); + return; + } + throw new Error(`Unknown list size "${v}"`); + }} + options={[ + { value: "sm", label: "Small" }, + { value: "lg", label: "Large" }, + ]} + size="sm" + /> + setListTitle(e.target.value)} /> - - + + setListSubtitle(e.target.value)} placeholder="(empty = none)" /> + + + - - + + - - + +
); @@ -1251,10 +1275,10 @@ export default function PageClient() { /> - + - - + + @@ -1319,6 +1343,217 @@ export default function PageClient() { ); } + // ─── Code generation ───────────────────────────────────────────────────── + + function escapeAttr(s: string): string { + return s.replace(/\\/g, "\\\\").replace(/"/g, '\\"'); + } + + function getComponentCode(): string { + if (selected === "alert") { + return ``; + } + if (selected === "badge") { + const iconProp = badgeContentMode === "icon" + ? 'icon={CheckCircle}' + : (badgeIcon ? "icon={CheckCircle}" : "icon={undefined}"); + return ``; + } + if (selected === "button") { + const child = btnSize === "icon" + ? "" + : `"${escapeAttr(btnLabel || "Button")}"`; + return ` + ${child} +`; + } + if (selected === "card") { + const glassProp = cardGlass === undefined ? "" : `\n glassmorphic={${cardGlass}}`; + const titleProps = cardTitle + ? `\n icon={Package}\n title="${escapeAttr(cardTitle)}"` + (cardSubtitle ? `\n subtitle="${escapeAttr(cardSubtitle)}"` : "") + : ""; + return ` + + Highlight pricing, benefits, or key product details here. + +`; + } + if (selected === "category-tabs") { + return ``; + } + if (selected === "cursor-blast") { + return ``; + } + if (selected === "data-table") { + return ` setLastClickedRow(row.name)" : "undefined"}} +/>`; + } + if (selected === "editable-grid") { + return ``; + } + if (selected === "input") { + const leading = inputPrefix + ? "prefixItem=\"$\"" + : (inputIcon ? "leadingIcon={}" : "leadingIcon={undefined}"); + return ``; + } + if (selected === "list-item-row") { + const btnEntries: string[] = []; + if (listShowEditBtn) { + btnEntries.push(` { id: "edit", label: "Edit", onClick: () => handleEdit() }`); + } + if (listShowMenuBtn) { + btnEntries.push(` {\n id: "more",\n label: "Options",\n display: "icon",\n onClick: [\n { id: "duplicate", label: "Duplicate", onClick: () => handleDuplicate() },\n { id: "delete", label: "Delete", itemVariant: "destructive", onClick: () => handleDelete() },\n ],\n }`); + } + const buttonsProp = btnEntries.length > 0 + ? `\n buttons={[\n${btnEntries.join(",\n")},\n ]}` + : ""; + const iconProp = listWithIcon ? "\n icon={Cube}" : ""; + const subtitleProp = listSubtitle ? `\n subtitle="${escapeAttr(listSubtitle)}"` : ""; + return ``; + } + if (selected === "menu") { + if (menuVariant === "selector") { + return ``; + } + if (menuVariant === "toggles") { + return ` setToggles((prev) => ({ ...prev, [id]: checked }))} +/>`; + } + return `, onClick: () => {} }, + { id: "email", label: "Send email", icon: , onClick: () => {} }, + { id: "delete", label: "Delete", icon: , itemVariant: "${menuActionStyle}", onClick: () => {} }, + ]} +/>`; + } + if (selected === "pill-toggle") { + const iconSuffix = pillWithIcons ? ", icon: Envelope" : ""; + const iconSuffix2 = pillWithIcons ? ", icon: HardDrive" : ""; + const iconSuffix3 = pillWithIcons ? ", icon: Sparkle" : ""; + return ``; + } + if (selected === "selector-dropdown") { + return ``; + } + // user-list + return ` handleUserClick(user)" : "undefined"}} +/>`; + } + // ─── Layout ────────────────────────────────────────────────────────────── return ( @@ -1374,6 +1609,21 @@ export default function PageClient() { {renderControls()}
+ + {/* Code */} +
+ + Code + + +
); diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/design-language/page-client.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/design-language/page-client.tsx index 853aa23941..450b574cfc 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/design-language/page-client.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/design-language/page-client.tsx @@ -647,7 +647,6 @@ export default function PageClient() { description="Header with supporting copy and a simple content area" > - + Placeholder content for the card body. @@ -815,8 +813,13 @@ export default function PageClient() { setListAction("edit")} - onDelete={() => setListAction("delete")} + subtitle="3 templates configured" + buttons={[ + { id: "edit", label: "Edit", onClick: () => setListAction("edit") }, + { id: "more", label: "Options", display: "icon", onClick: [ + { id: "delete", label: "Delete", icon: , itemVariant: "destructive", onClick: () => setListAction("delete") }, + ] }, + ]} /> {listAction ? `Last action: ${listAction}` : "Click edit or delete to preview actions."} @@ -1040,18 +1043,18 @@ export default function PageClient() { title="Data Table" description="Matches the email log table styling and layout." > - + > + +
diff --git a/apps/dashboard/src/components/design-language/card.tsx b/apps/dashboard/src/components/design-language/card.tsx index 6fbcf222c6..241830bdb4 100644 --- a/apps/dashboard/src/components/design-language/card.tsx +++ b/apps/dashboard/src/components/design-language/card.tsx @@ -29,7 +29,6 @@ export function useGlassmorphicDefault(explicit: boolean | undefined): boolean { return explicit ?? insideCard; } -type DesignCardVariant = "header" | "compact" | "bodyOnly"; type DesignCardGradient = "blue" | "cyan" | "purple" | "green" | "orange" | "default"; const hoverTintClasses = new Map([ @@ -52,18 +51,34 @@ const demoTintClasses = new Map([ const bodyPaddingClass = "p-5"; -export type DesignCardProps = { - variant?: DesignCardVariant, - title?: React.ReactNode, - subtitle?: React.ReactNode, - icon?: React.ElementType, +// ─── Discriminated props ────────────────────────────────────────────────── +// - If title is given, icon is required. +// - The layout is derived automatically: +// title + subtitle → "header" (full header block with subtitle) +// title only → "compact" (slim bar with border-b) +// no title → "bodyOnly" (just the body) + +type DesignCardBaseProps = { glassmorphic?: boolean, gradient?: DesignCardGradient, contentClassName?: string, -} & Omit, "title"> +} & Omit, "title">; + +type WithTitleProps = { + title: React.ReactNode, + subtitle?: React.ReactNode, + icon: React.ElementType, +}; + +type WithoutTitleProps = { + title?: never, + subtitle?: never, + icon?: never, +}; + +export type DesignCardProps = DesignCardBaseProps & (WithTitleProps | WithoutTitleProps); export function DesignCard({ - variant = "compact", title, subtitle, icon: Icon, @@ -77,6 +92,11 @@ export function DesignCard({ const glassmorphic = useGlassmorphicDefault(glassmorphicProp); const hoverTintClass = hoverTintClasses.get(gradient) ?? "group-hover:bg-slate-500/[0.02]"; + // Derive layout from which props were provided + const variant = title != null + ? (subtitle != null ? "header" : "compact") + : "bodyOnly"; + return ( {variant === "header" && (
- {(title || subtitle || Icon) && ( -
-
- {(title || Icon) && ( -
- {Icon && ( -
- -
- )} - {title && ( - - {title} - - )} +
+
+
+ {Icon && ( +
+
)} - {subtitle && ( -

- {subtitle} -

- )} + + {title} +
+ {subtitle && ( +

+ {subtitle} +

+ )}
- )} +
)} {variant === "compact" && ( @@ -139,11 +153,9 @@ export function DesignCard({
)} - {title && ( - - {title} - - )} + + {title} +
)}
void | Promise, +}; + +type DesignListItemMenuButton = DesignListItemButtonBase & { + onClick: DesignMenuActionItem[], +}; + +export type DesignListItemButton = DesignListItemDirectButton | DesignListItemMenuButton; + +// ─── DesignListItemRow ──────────────────────────────────────────────────── +// +// size="lg" (default) — card-style row with glassmorphic background, shadow, +// large icon badge, bold title, and optional subtitle. +// +// size="sm" — flat compact row (like UserList items). No card background, +// smaller icon, and a secondary subtitle line. Ideal for dense lists. export type DesignListItemRowProps = { - icon: React.ElementType, + icon?: React.ElementType, title: string, - showIcon?: boolean, - onEdit?: () => void, - onDelete?: () => void, + subtitle?: string, + /** "sm" = flat compact row, "lg" = card-style row. Defaults to "lg". */ + size?: "sm" | "lg", + buttons?: DesignListItemButton[], + onClick?: () => void, + className?: string, }; +function ListItemButtons({ buttons }: { buttons: DesignListItemButton[] }) { + return ( +
+ {buttons.map((button) => { + const display = button.display ?? "text"; + + if (Array.isArray(button.onClick)) { + const menuItems = button.onClick; + return ( + + ); + } + + const handler = button.onClick; + + if (display === "icon") { + return ( + + {button.icon} + + ); + } + + return ( + + {button.label} + + ); + })} +
+ ); +} + export function DesignListItemRow({ icon: Icon, title, - showIcon = true, - onEdit, - onDelete, + subtitle, + size = "lg", + buttons, + onClick, + className, }: DesignListItemRowProps) { + const Wrapper = onClick ? "button" : "div"; + + if (size === "sm") { + return ( + +
+ {Icon && ( +
+ +
+ )} +
+
{title}
+ {subtitle && ( +
{subtitle}
+ )} +
+
+ {buttons && buttons.length > 0 && } +
+ ); + } + + // size === "lg" return ( -
+
- {showIcon && ( + {Icon && (
)} - {title} -
-
- {onEdit && ( - - Edit - - )} - {onDelete && ( - - )} +
+ {title} + {subtitle && ( +
{subtitle}
+ )} +
-
+ {buttons && buttons.length > 0 && } +
); } +// ─── DesignUserList ─────────────────────────────────────────────────────── +// Convenience wrapper around DesignListItemRow for user rows with avatars. + export type DesignUserListRow = { name: string, email: string, @@ -80,11 +188,22 @@ export type DesignUserListProps = { className?: string, }; -const avatarGradients = { - "blue-purple": "from-blue-500 to-purple-500", - "cyan-blue": "from-cyan-500 to-blue-500", - "none": "from-muted-foreground/30 to-muted-foreground/30", -} as const; +const avatarGradients = new Map([ + ["blue-purple", "from-blue-500 to-purple-500"], + ["cyan-blue", "from-cyan-500 to-blue-500"], + ["none", "from-muted-foreground/30 to-muted-foreground/30"], +] as const); + +function UserAvatar({ name, gradient }: { name: string, gradient: string }) { + return ( +
+ {name.charAt(0)} +
+ ); +} export function DesignUserList({ users, @@ -93,30 +212,25 @@ export function DesignUserList({ gradient = "blue-purple", className, }: DesignUserListProps) { + const gradientClass = avatarGradients.get(gradient) ?? avatarGradients.get("blue-purple")!; + return (
{users.map((user) => ( - +
))}
); diff --git a/apps/dashboard/src/components/design-language/pill-toggle.tsx b/apps/dashboard/src/components/design-language/pill-toggle.tsx index 23ab123b64..7c37606a16 100644 --- a/apps/dashboard/src/components/design-language/pill-toggle.tsx +++ b/apps/dashboard/src/components/design-language/pill-toggle.tsx @@ -2,6 +2,8 @@ import { useState } from "react"; import { cn } from "@/lib/utils"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; +import { TooltipPortal } from "@radix-ui/react-tooltip"; import { runAsynchronouslyWithAlert } from "@stackframe/stack-shared/dist/utils/promises"; import { Spinner } from "@/components/ui/spinner"; import { useGlassmorphicDefault } from "./card"; @@ -22,9 +24,7 @@ export type DesignPillToggleProps = { size?: DesignPillToggleSize, glassmorphic?: boolean, gradient?: DesignPillToggleGradient, - /** Show the icon portion of each pill (when the option provides one). Defaults to true. At least one of showIcons/showLabels must be true. */ - showIcons?: boolean, - /** Show the text label of each pill. Defaults to true. At least one of showIcons/showLabels must be true. */ + /** When false, hides labels and shows a tooltip on hover instead. Defaults to true. */ showLabels?: boolean, className?: string, }; @@ -64,7 +64,6 @@ export function DesignPillToggle({ size = "md", glassmorphic: glassmorphicProp, gradient = "default", - showIcons = true, showLabels = true, className, }: DesignPillToggleProps) { @@ -72,10 +71,6 @@ export function DesignPillToggle({ const sizeClass = getMapValueOrThrow(sizeClasses, size, "sizeClasses"); const activeRingClass = getMapValueOrThrow(gradientClasses, gradient, "gradientClasses"); - // At least one of showIcons/showLabels must be true - const effectiveShowLabels = !showIcons ? true : showLabels; - const effectiveShowIcons = !showLabels ? true : showIcons; - const [loadingOptionId, setLoadingOptionId] = useState(null); const handleClick = (optionId: string) => { @@ -101,7 +96,8 @@ export function DesignPillToggle({ {options.map((option) => { const isActive = selected === option.id; const Icon = option.icon; - return ( + + const pill = ( ); + + if (!showLabels) { + return ( + + + {pill} + + + + {option.label} + + + + ); + } + + return pill; })}
); diff --git a/apps/dashboard/src/components/design-language/table.tsx b/apps/dashboard/src/components/design-language/table.tsx index 2a6b172cfa..133bdd9f73 100644 --- a/apps/dashboard/src/components/design-language/table.tsx +++ b/apps/dashboard/src/components/design-language/table.tsx @@ -1,104 +1,46 @@ "use client"; -import { DataTable, DataTableViewOptions } from "@/components/ui"; +import { DataTable } from "@/components/ui"; import { cn } from "@/lib/utils"; -import { ColumnDef, ColumnFiltersState, SortingState, Table as TableType } from "@tanstack/react-table"; -import { useState } from "react"; -import { DesignCard } from "./card"; +import { ColumnDef, ColumnFiltersState, SortingState } from "@tanstack/react-table"; export type DesignDataTableProps = { columns: ColumnDef[], data: TData[], - title?: string, - subtitle?: string, - icon?: React.ElementType, defaultColumnFilters?: ColumnFiltersState, defaultSorting?: SortingState, showDefaultToolbar?: boolean, showResetFilters?: boolean, - viewOptions?: boolean, - /** When false, hides the entire header section (title, subtitle, icon, view options). Defaults to true. */ - showHeader?: boolean, onRowClick?: (row: TData) => void, className?: string, - contentClassName?: string, }; export function DesignDataTable({ columns, data, - title, - subtitle, - icon: Icon, defaultColumnFilters = [], defaultSorting = [], showDefaultToolbar = false, showResetFilters = false, - viewOptions = false, - showHeader = true, onRowClick, className, - contentClassName, }: DesignDataTableProps) { - const [tableInstance, setTableInstance] = useState | null>(null); - return ( - - {showHeader && (title || subtitle || Icon || viewOptions) && ( -
-
-
- {(title || Icon) && ( -
- {Icon && ( -
- -
- )} - {title && ( - - {title} - - )} -
- )} - {subtitle && ( -

- {subtitle} -

- )} -
- {viewOptions && tableInstance && ( -
- -
- )} -
-
+
- -
-
+ > + +
); } diff --git a/apps/dashboard/src/components/navbar.tsx b/apps/dashboard/src/components/navbar.tsx index faa35b878a..8ba27d4160 100644 --- a/apps/dashboard/src/components/navbar.tsx +++ b/apps/dashboard/src/components/navbar.tsx @@ -2,13 +2,12 @@ import { Typography } from "@/components/ui"; import { UserButton } from "@stackframe/stack"; -import { useTheme } from "next-themes"; import { Link } from "./link"; import { Logo } from "./logo"; +import ThemeToggle from "./theme-toggle"; export function Navbar({ ...props }) { - const { resolvedTheme, setTheme } = useTheme(); return (
-
+
Docs +
- setTheme(resolvedTheme === 'light' ? 'dark' : 'light')}/> +
); diff --git a/apps/dashboard/src/components/ui/data-table/data-table.tsx b/apps/dashboard/src/components/ui/data-table/data-table.tsx index be74d2cc31..567a781fbf 100644 --- a/apps/dashboard/src/components/ui/data-table/data-table.tsx +++ b/apps/dashboard/src/components/ui/data-table/data-table.tsx @@ -77,12 +77,12 @@ export function TableView(props: { { + onClick={props.onRowClick ? (ev) => { // only trigger onRowClick if the element is a direct descendant; don't trigger for portals if (ev.target instanceof Node && ev.currentTarget.contains(ev.target)) { props.onRowClick?.(row.original); } - }} + } : undefined} > {row.getVisibleCells().map((cell) => ( From 0e3524014d49195c54b2ce9126a1de492f5abab7 Mon Sep 17 00:00:00 2001 From: Konstantin Wohlwend Date: Tue, 10 Feb 2026 23:06:48 -0800 Subject: [PATCH 15/18] Fix --- .../(outside-dashboard)/playground/page-client.tsx | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/playground/page-client.tsx b/apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/playground/page-client.tsx index 309488c498..c875050992 100644 --- a/apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/playground/page-client.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/playground/page-client.tsx @@ -266,6 +266,7 @@ export default function PageClient() { const [listSubtitle, setListSubtitle] = useState("3 seats remaining"); const [listSize, setListSize] = useState<"sm" | "lg">("lg"); const [listWithIcon, setListWithIcon] = useState(true); + const [listClickable, setListClickable] = useState(false); const [listShowEditBtn, setListShowEditBtn] = useState(true); const [listShowMenuBtn, setListShowMenuBtn] = useState(true); const [listLastAction, setListLastAction] = useState(""); @@ -611,6 +612,7 @@ export default function PageClient() { title={listTitle} subtitle={listSubtitle || undefined} size={listSize} + onClick={listClickable ? () => setListLastAction("row clicked") : undefined} buttons={listButtons.length > 0 ? listButtons : undefined} /> {listLastAction && ( @@ -1159,6 +1161,9 @@ export default function PageClient() { + + + @@ -1464,9 +1469,10 @@ export default function PageClient() { : ""; const iconProp = listWithIcon ? "\n icon={Cube}" : ""; const subtitleProp = listSubtitle ? `\n subtitle="${escapeAttr(listSubtitle)}"` : ""; + const clickProp = listClickable ? "\n onClick={() => handleRowClick()}" : ""; return ``; } if (selected === "menu") { From 632a3421b2940d23cc653da4f90f1cfcf6caacd7 Mon Sep 17 00:00:00 2001 From: Developing-Gamer Date: Wed, 11 Feb 2026 10:44:46 -0800 Subject: [PATCH 16/18] Enhance playground and editable-grid components - Updated playground card width and padding for the editable-grid selection. - Adjusted preview container padding for better layout control. - Ensured full-width styling for boolean and dropdown fields in editable-grid to prevent content shrinkage. - Improved overall responsiveness and visual consistency in the dashboard. --- .../(outside-dashboard)/playground/page-client.tsx | 10 +++++----- .../src/components/design-language/editable-grid.tsx | 12 ++++++------ claude/CLAUDE-KNOWLEDGE.md | 6 ++++++ 3 files changed, 17 insertions(+), 11 deletions(-) diff --git a/apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/playground/page-client.tsx b/apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/playground/page-client.tsx index c875050992..2a6a691a4b 100644 --- a/apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/playground/page-client.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/playground/page-client.tsx @@ -1,5 +1,6 @@ "use client"; +import { CodeBlock } from "@/components/code-block"; import { CursorBlastEffect, DesignAlert, @@ -19,7 +20,6 @@ import { DesignSelectorDropdown, DesignUserList, } from "@/components/design-language"; -import { CodeBlock } from "@/components/code-block"; import { DataTableColumnHeader, Typography } from "@/components/ui"; import { CheckCircle, @@ -544,9 +544,9 @@ export default function PageClient() { } if (selected === "editable-grid") { return ( -
+
-
+
{renderPreview()}
diff --git a/apps/dashboard/src/components/design-language/editable-grid.tsx b/apps/dashboard/src/components/design-language/editable-grid.tsx index 608a429d71..fc8f6bb2ef 100644 --- a/apps/dashboard/src/components/design-language/editable-grid.tsx +++ b/apps/dashboard/src/components/design-language/editable-grid.tsx @@ -180,7 +180,7 @@ function DesignEditableInput({ tabIndex={readOnly ? -1 : undefined} size="sm" className={cn( - "w-full px-3 h-8", + "w-full px-3 h-8 text-sm", !readOnly && "hover:cursor-pointer", !readOnly && "focus:cursor-[unset]", readOnly && "focus-visible:ring-0 cursor-default text-muted-foreground", @@ -266,14 +266,14 @@ function EditableBooleanField({ if (readOnly) { return ( - + {value ? trueLabel : falseLabel} ); } return ( -
+
runAsynchronouslyWithAlert(handleChange(nextValue))} @@ -526,7 +526,7 @@ function GridItemContent({ item, isModified }: { item: DesignEditableGridItem, i return ( <> -
+
diff --git a/claude/CLAUDE-KNOWLEDGE.md b/claude/CLAUDE-KNOWLEDGE.md index db159e9d92..dfe1b4e371 100644 --- a/claude/CLAUDE-KNOWLEDGE.md +++ b/claude/CLAUDE-KNOWLEDGE.md @@ -25,3 +25,9 @@ A: When setting `restricted_by_admin` to false, explicitly clear `restricted_by_ Q: Where should `stackAppInternalsSymbol` be imported from in the dashboard? A: Use the shared `apps/dashboard/src/lib/stack-app-internals.ts` export to avoid duplicating the Symbol.for definition across files. + +Q: Where is the editable-grid preview spacing controlled in the dashboard playground? +A: In `apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/playground/page-client.tsx`, the `selected === "editable-grid"` branch controls the card width/padding and the main preview container now uses `isExpandedPreview` to reduce outer gray padding only for editable-grid. + +Q: Why do editable-grid dropdown/boolean values sometimes not fill the full value column width? +A: In `apps/dashboard/src/components/design-language/editable-grid.tsx`, the value wrappers must be explicitly full-width (`w-full`) for boolean and dropdown fields, and the grid value cell container should also include `w-full`; otherwise controls shrink to content width. From 0acbe567cef63ace12860286cb3931b54c4945a0 Mon Sep 17 00:00:00 2001 From: Developing-Gamer Date: Wed, 11 Feb 2026 11:37:13 -0800 Subject: [PATCH 17/18] Refactor editable components to align with design language - Replaced legacy `EditableGrid` with `DesignEditableGrid` in the product details section. - Updated `EditableInput` to use `DesignInput` and `DesignButton`, enhancing the UI consistency. - Ensured full-width styling for grid items and improved button hover effects for better user experience. --- .../products/[productId]/page-client.tsx | 6 ++--- .../design-language/editable-grid.tsx | 6 ++--- .../src/components/editable-input.tsx | 24 +++++++++++-------- claude/CLAUDE-KNOWLEDGE.md | 3 +++ 4 files changed, 23 insertions(+), 16 deletions(-) diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/[productId]/page-client.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/[productId]/page-client.tsx index 9b29b5f1bb..2f406f1197 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/[productId]/page-client.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/[productId]/page-client.tsx @@ -1,6 +1,6 @@ "use client"; -import { EditableGrid, type EditableGridItem } from "@/components/editable-grid"; +import { DesignEditableGrid, type DesignEditableGridItem } from "@/components/design-language"; import { EditableInput } from "@/components/editable-input"; import { Link, StyledLink } from "@/components/link"; import { useRouter } from "@/components/router"; @@ -559,7 +559,7 @@ function ProductDetailsSection({ productId, product, config }: ProductDetailsSec }; // Build grid items for EditableGrid - const gridItems: EditableGridItem[] = [ + const gridItems: DesignEditableGridItem[] = [ { type: 'text', itemKey: 'displayName', @@ -730,7 +730,7 @@ function ProductDetailsSection({ productId, product, config }: ProductDetailsSec return ( <> - {item.children}; + return
{item.children}
; } } } @@ -559,7 +559,7 @@ function DesignInlineSaveDiscard({ className="h-8 px-3 text-xs font-medium text-muted-foreground hover:text-foreground hover:bg-foreground/[0.05] rounded-lg transition-colors duration-150 hover:transition-none gap-1.5" > - Discard + Discard - Save + Save
); diff --git a/apps/dashboard/src/components/editable-input.tsx b/apps/dashboard/src/components/editable-input.tsx index 2ca1d363e3..a9bc136d08 100644 --- a/apps/dashboard/src/components/editable-input.tsx +++ b/apps/dashboard/src/components/editable-input.tsx @@ -1,9 +1,9 @@ -import { Button, Input } from "@/components/ui"; +import { DesignButton, DesignInput } from "@/components/design-language"; import { cn } from "@/lib/utils"; import { Check, X } from "@phosphor-icons/react"; import { useAsyncCallback } from "@stackframe/stack-shared/dist/hooks/use-async-callback"; -import { runAsynchronouslyWithAlert } from "@stackframe/stack-shared/dist/utils/promises"; import { throwErr } from "@stackframe/stack-shared/dist/utils/errors"; +import { runAsynchronouslyWithAlert } from "@stackframe/stack-shared/dist/utils/promises"; import { useRef, useState } from "react"; @@ -75,7 +75,7 @@ export function EditableInput({ } }} > - {["accept", "reject"].map((action) => ( - + ))}
; diff --git a/claude/CLAUDE-KNOWLEDGE.md b/claude/CLAUDE-KNOWLEDGE.md index dfe1b4e371..1037ba67e2 100644 --- a/claude/CLAUDE-KNOWLEDGE.md +++ b/claude/CLAUDE-KNOWLEDGE.md @@ -31,3 +31,6 @@ A: In `apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/playground/ Q: Why do editable-grid dropdown/boolean values sometimes not fill the full value column width? A: In `apps/dashboard/src/components/design-language/editable-grid.tsx`, the value wrappers must be explicitly full-width (`w-full`) for boolean and dropdown fields, and the grid value cell container should also include `w-full`; otherwise controls shrink to content width. + +Q: How should dashboard inline editable text fields match the new design-language style? +A: Use `DesignInput` and `DesignButton` in `apps/dashboard/src/components/editable-input.tsx` (instead of legacy `Input`/`Button`) and style accept/reject actions as subtle glassy icon buttons with muted ring/border plus semantic hover tints. From 17d4df730e61f44e4b5807dfadf97a67ffb02537 Mon Sep 17 00:00:00 2001 From: Developing-Gamer Date: Wed, 11 Feb 2026 11:45:43 -0800 Subject: [PATCH 18/18] Enhance page styling and structure for stack handler - Added a wrapper div with a `data-stack-handler-page` attribute to the StackHandler component for improved styling. - Updated global CSS to adjust background color and opacity for non-dark mode when the stack handler page is active, ensuring better visual clarity. --- .../src/app/(main)/handler/[...stack]/page.tsx | 16 ++++++++++------ apps/dashboard/src/app/globals.css | 10 ++++++++++ 2 files changed, 20 insertions(+), 6 deletions(-) diff --git a/apps/dashboard/src/app/(main)/handler/[...stack]/page.tsx b/apps/dashboard/src/app/(main)/handler/[...stack]/page.tsx index 418a6409b1..14c3244e15 100644 --- a/apps/dashboard/src/app/(main)/handler/[...stack]/page.tsx +++ b/apps/dashboard/src/app/(main)/handler/[...stack]/page.tsx @@ -14,10 +14,14 @@ export default function Handler(props: unknown) {
: null} ; - return ; + return ( +
+ +
+ ); } diff --git a/apps/dashboard/src/app/globals.css b/apps/dashboard/src/app/globals.css index 6cfc005b3a..7d76c55280 100644 --- a/apps/dashboard/src/app/globals.css +++ b/apps/dashboard/src/app/globals.css @@ -159,6 +159,16 @@ .dark body::before { opacity: 0; } + + html:not(.dark) body:has([data-stack-handler-page]) { + background-color: white; + background-image: none; + } + + html:not(.dark) body:has([data-stack-handler-page])::before { + opacity: 0; + background-image: none; + } } ::view-transition-old(root),