Settings Modal
Controlled multi-page settings dialog with General, Profile, Tools, and Storage views.
Dialog
A multi-page settings dialog.
function SettingsModalDemo() {
const [open, setOpen] = useState(false);
return (
<>
<Button variant="secondary" onClick={() => setOpen(true)}>
Open settings
</Button>
<SettingsModal isOpen={open} onClose={() => setOpen(false)} />
</>
);
}function SettingsModalDemo() {
const [open, setOpen] = useState(false);
return (
<>
<Button variant="secondary" onClick={() => setOpen(true)}>
Open settings
</Button>
<SettingsModal isOpen={open} onClose={() => setOpen(false)} />
</>
);
}Installation
npx shadcn@latest add https://boardcn.dev/r/settings-modal.jsonnpx shadcn@latest add https://boardcn.dev/r/settings-modal.jsonnpm packages
- @internationalized/date
- @remixicon/react
- react-aria-components
BoardCN dependencies
The CLI installs these for you — you do not need to add them yourself.
Source
The 11 files the CLI copies into your project.
"use client";
import { useEffect, useRef, useState, type ComponentType } from "react";
import { createPortal } from "react-dom";
import {
RiBankCardLine,
RiBookOpenLine,
RiCheckboxCircleFill,
RiCloseLine,
RiCodeBlock,
RiDatabase2Line,
RiGitMergeLine,
RiOrganizationChart,
RiPaletteLine,
RiPlugLine,
RiSchoolLine,
RiSettings6Line,
RiSettingsLine,
RiToolsFill,
} from "@remixicon/react";
import { cx } from "@/utils/cx";
import { SettingsGeneral } from "./settings-general";
import { SettingsProfile } from "./settings-profile";
import { SettingsStorage } from "./settings-storage";
import { SettingsTools } from "./settings-tools";
export type SettingsPage = "general" | "profile" | "storage" | "tools";
export interface SettingsModalProps {
/** Controlled open state, owned by the host page, sidebar, or menu. */
isOpen: boolean;
/** Called by the backdrop, close button, and Escape key. */
onClose: () => void;
/** Page selected each time the modal opens. */
defaultPage?: SettingsPage;
/** Optional product artwork used by the animated Current plan card. */
planArtSrc?: string;
}
type IconComponent = ComponentType<{
className?: string;
"aria-hidden"?: boolean | "true" | "false";
}>;
interface NavEntry {
label: string;
icon: IconComponent;
/** Only pages that exist are navigable; the rest render as static rows. */
page?: SettingsPage;
}
const NAV_GROUPS: { label: string; items: NavEntry[] }[] = [
{
label: "Settings",
items: [
{ label: "General", icon: RiSettings6Line, page: "general" },
{ label: "Profile", icon: RiSchoolLine, page: "profile" },
{ label: "Appearance", icon: RiPaletteLine },
{ label: "Billing", icon: RiBankCardLine },
{ label: "Rules and Workflows", icon: RiOrganizationChart },
{ label: "Tools", icon: RiToolsFill, page: "tools" },
{ label: "Storage", icon: RiDatabase2Line, page: "storage" },
],
},
{
label: "Desktop app",
items: [
{ label: "General", icon: RiSettingsLine },
{ label: "Plugins", icon: RiPlugLine },
{ label: "Developer", icon: RiCodeBlock },
],
},
{
label: "Customize",
items: [
{ label: "Skills", icon: RiBookOpenLine },
{ label: "Git", icon: RiGitMergeLine },
],
},
];
const PAGE_TITLES: Record<SettingsPage, string> = {
general: "General",
profile: "Profile",
storage: "Storage",
tools: "Tools",
};
export function SettingsModal({
isOpen,
onClose,
defaultPage = "general",
planArtSrc,
}: SettingsModalProps) {
const [page, setPage] = useState<SettingsPage>(defaultPage);
// Mount/visible two-phase state so both the enter and the exit play the
// full fade + blur + scale transition before the DOM goes away.
const [mounted, setMounted] = useState(false);
const [visible, setVisible] = useState(false);
const unmountTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const panelRef = useRef<HTMLDivElement>(null);
// "Saved" toast — floats half-out of the panel's bottom edge for ~2s
// whenever a profile field commits a change (Enter / blur). Three phases
// so the motion is directional: it rises in from below ("hidden" starting
// offset) and keeps drifting upward while fading out ("leaving" offset).
const [savedPhase, setSavedPhase] = useState<"hidden" | "shown" | "leaving">("hidden");
const savedTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const showSavedToast = () => {
if (savedTimer.current) clearTimeout(savedTimer.current);
setSavedPhase("shown");
savedTimer.current = setTimeout(() => {
setSavedPhase("leaving");
// Reset to the below-the-edge start once the fade-out finished, so the
// next save rises in from the bottom again (invisible: opacity 0 → 0).
savedTimer.current = setTimeout(() => setSavedPhase("hidden"), 220);
}, 2000);
};
// Top fade over the scrolling page so rows dissolve under the title row
// instead of cutting sharply (same recipe as the medical alerts feed).
const [contentScrolled, setContentScrolled] = useState(false);
useEffect(() => {
if (isOpen) {
if (unmountTimer.current) clearTimeout(unmountTimer.current);
setPage(defaultPage);
setMounted(true);
// Double rAF: the panel must commit its hidden state before the
// transition to visible starts, or the browser skips the animation.
requestAnimationFrame(() => requestAnimationFrame(() => setVisible(true)));
} else {
setVisible(false);
setSavedPhase("hidden");
unmountTimer.current = setTimeout(() => setMounted(false), 320);
}
return () => {
if (unmountTimer.current) clearTimeout(unmountTimer.current);
if (savedTimer.current) clearTimeout(savedTimer.current);
};
// eslint-disable-next-line react-hooks/exhaustive-deps -- defaultPage only matters at the open transition
}, [isOpen]);
// Escape closes; focus moves into the dialog on open.
useEffect(() => {
if (!isOpen) return;
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") onClose();
};
document.addEventListener("keydown", onKeyDown);
panelRef.current?.focus();
return () => document.removeEventListener("keydown", onKeyDown);
}, [isOpen, onClose]);
if (!mounted || typeof document === "undefined") return null;
return createPortal(
<div className="fixed inset-0 z-100 flex items-center justify-center p-4" role="presentation">
{/* Backdrop — dark-mode modal reference uses black at 70%. */}
<button
type="button"
aria-label="Close settings"
tabIndex={-1}
onClick={onClose}
className={cx(
"absolute inset-0 cursor-default bg-black/70 transition-opacity duration-300 ease-out",
visible ? "opacity-100" : "opacity-0",
)}
/>
{/* Animated wrapper — carries the open/close transition for the panel
AND the saved toast, which straddles the panel's bottom edge and so
must live outside the panel's overflow-clip. */}
<div
className={cx(
"relative",
// Kept light on purpose: an 8px blur over the full 871×614 panel
// forces a huge re-filter every frame of the scale-down, which is
// what made the close stutter. 4px + GPU promotion stays smooth.
"transform-gpu transition-[opacity,transform,filter] duration-300 ease-[cubic-bezier(0.32,0.72,0,1)] will-change-[opacity,transform,filter]",
visible ? "scale-100 opacity-100 blur-0" : "scale-[0.85] opacity-0 blur-[4px]",
)}
>
{/* Panel */}
<div
ref={panelRef}
role="dialog"
aria-modal="true"
aria-label="Settings"
tabIndex={-1}
className={cx(
"relative flex h-[614px] max-h-[calc(100dvh-32px)] w-[871px] max-w-[calc(100vw-32px)]",
// overflow-CLIP, not hidden: hidden boxes are still programmatically
// scrollable, so focusing a switch's hidden input in a row clipped
// by the inner scroller made the browser scroll-reveal it through
// the panel too — shifting the whole modal content up with no way
// back. clip forbids scrolling outright.
"overflow-clip rounded-3xl bg-background-full shadow-xs outline-none",
)}
>
{/* Nav rail — the board-team dropdown group/item recipe */}
<nav
aria-label="Settings sections"
className="flex w-[274px] shrink-0 flex-col gap-5 overflow-y-auto border-r border-separator-border bg-background-secondary-default p-2.5"
>
{NAV_GROUPS.map((group) => (
<div key={group.label} className="flex w-full flex-col gap-1.5 pt-1">
<span className="pl-2 text-body-medium text-text-secondary">{group.label}</span>
<div className="flex w-full flex-col gap-1">
{group.items.map((item) => {
const selected = item.page !== undefined && item.page === page;
return (
<button
key={`${group.label}:${item.label}`}
type="button"
aria-current={selected ? "page" : undefined}
onClick={
item.page
? () => {
setPage(item.page!);
setContentScrolled(false);
}
: undefined
}
className={cx(
"flex w-full cursor-pointer items-center gap-2 rounded-2lg p-2 text-left",
"outline-none transition-colors duration-150 ease focus-visible:ring-2 focus-visible:ring-border-focus-ring",
selected
? "bg-background-secondary-hover"
: "hover:bg-background-secondary-hover/60",
)}
>
<item.icon className="size-5 shrink-0 text-foreground-icon-secondary" aria-hidden />
<span
className={cx(
"truncate text-body-medium",
selected ? "text-text-primary" : "text-text-secondary",
)}
>
{item.label}
</span>
</button>
);
})}
</div>
</div>
))}
</nav>
{/* Content pane — fixed title row, scrollable page below */}
<div className="flex min-w-0 flex-1 flex-col">
{/* Storage keeps a tighter title gap: its page already carries
10px of scroll-safe headroom for the upload progress badge, so
the shared pb-3 read as a double margin above the dropzone. */}
<div
className={cx(
"flex shrink-0 items-center justify-between px-8 pt-8",
page === "storage" ? "pb-1.5" : "pb-3",
)}
>
<h2 className="text-title-3-medium text-text-primary">{PAGE_TITLES[page]}</h2>
<button
type="button"
aria-label="Close settings"
onClick={onClose}
className={cx(
"flex size-6 shrink-0 cursor-pointer items-center justify-center rounded-full",
"bg-background-tertiary-default text-foreground-icon-secondary",
"transition-colors duration-150 ease hover:bg-background-tertiary-hover",
"outline-none focus-visible:ring-2 focus-visible:ring-border-focus-ring",
)}
>
<RiCloseLine className="size-4" aria-hidden />
</button>
</div>
<div className="relative min-h-0 flex-1">
<div
className="h-full overflow-y-auto px-8 pb-8"
onScroll={(e) => setContentScrolled(e.currentTarget.scrollTop > 0)}
>
{page === "profile" ? (
<SettingsProfile onSaved={showSavedToast} />
) : page === "storage" ? (
<SettingsStorage />
) : page === "tools" ? (
<SettingsTools />
) : (
<SettingsGeneral planArtSrc={planArtSrc} />
)}
</div>
{/* Progressive top fade — eases in once the page is scrolled so
content dissolves under the title row instead of hard-cutting. */}
<div
aria-hidden
className={cx(
"pointer-events-none absolute inset-x-0 top-0 h-10 bg-linear-to-b from-background-primary-default to-transparent",
"transition-opacity duration-200 ease-out",
contentScrolled ? "opacity-100" : "opacity-0",
)}
/>
</div>
</div>
</div>
{/* Saved toast — straddles the panel's bottom edge, half in / half out. */}
<div
aria-live="polite"
className={cx(
"pointer-events-none absolute bottom-0 left-1/2 z-10 flex -translate-x-1/2 items-center gap-1",
"rounded-full border border-border-button-default bg-background-primary-default py-1 pr-2.5 pl-1.5 shadow-dropdown",
"transition-[opacity,transform,filter] duration-200 ease-out",
savedPhase === "shown" && "translate-y-1/2 opacity-100 scale-100 blur-0",
// Starts below the resting spot, rises in; keeps drifting up on exit.
savedPhase === "hidden" && "translate-y-[calc(50%+12px)] opacity-0 scale-90 blur-[2px]",
savedPhase === "leaving" && "translate-y-[calc(50%-10px)] opacity-0 scale-90 blur-[2px]",
)}
>
<RiCheckboxCircleFill className="size-4 shrink-0 text-lime-600" aria-hidden />
<span className="text-body-2-medium whitespace-nowrap text-text-primary">Saved</span>
</div>
</div>
</div>,
document.body,
);
}"use client";
import { useEffect, useRef, useState, type ComponentType } from "react";
import { createPortal } from "react-dom";
import {
RiBankCardLine,
RiBookOpenLine,
RiCheckboxCircleFill,
RiCloseLine,
RiCodeBlock,
RiDatabase2Line,
RiGitMergeLine,
RiOrganizationChart,
RiPaletteLine,
RiPlugLine,
RiSchoolLine,
RiSettings6Line,
RiSettingsLine,
RiToolsFill,
} from "@remixicon/react";
import { cx } from "@/utils/cx";
import { SettingsGeneral } from "./settings-general";
import { SettingsProfile } from "./settings-profile";
import { SettingsStorage } from "./settings-storage";
import { SettingsTools } from "./settings-tools";
export type SettingsPage = "general" | "profile" | "storage" | "tools";
export interface SettingsModalProps {
/** Controlled open state, owned by the host page, sidebar, or menu. */
isOpen: boolean;
/** Called by the backdrop, close button, and Escape key. */
onClose: () => void;
/** Page selected each time the modal opens. */
defaultPage?: SettingsPage;
/** Optional product artwork used by the animated Current plan card. */
planArtSrc?: string;
}
type IconComponent = ComponentType<{
className?: string;
"aria-hidden"?: boolean | "true" | "false";
}>;
interface NavEntry {
label: string;
icon: IconComponent;
/** Only pages that exist are navigable; the rest render as static rows. */
page?: SettingsPage;
}
const NAV_GROUPS: { label: string; items: NavEntry[] }[] = [
{
label: "Settings",
items: [
{ label: "General", icon: RiSettings6Line, page: "general" },
{ label: "Profile", icon: RiSchoolLine, page: "profile" },
{ label: "Appearance", icon: RiPaletteLine },
{ label: "Billing", icon: RiBankCardLine },
{ label: "Rules and Workflows", icon: RiOrganizationChart },
{ label: "Tools", icon: RiToolsFill, page: "tools" },
{ label: "Storage", icon: RiDatabase2Line, page: "storage" },
],
},
{
label: "Desktop app",
items: [
{ label: "General", icon: RiSettingsLine },
{ label: "Plugins", icon: RiPlugLine },
{ label: "Developer", icon: RiCodeBlock },
],
},
{
label: "Customize",
items: [
{ label: "Skills", icon: RiBookOpenLine },
{ label: "Git", icon: RiGitMergeLine },
],
},
];
const PAGE_TITLES: Record<SettingsPage, string> = {
general: "General",
profile: "Profile",
storage: "Storage",
tools: "Tools",
};
export function SettingsModal({
isOpen,
onClose,
defaultPage = "general",
planArtSrc,
}: SettingsModalProps) {
const [page, setPage] = useState<SettingsPage>(defaultPage);
// Mount/visible two-phase state so both the enter and the exit play the
// full fade + blur + scale transition before the DOM goes away.
const [mounted, setMounted] = useState(false);
const [visible, setVisible] = useState(false);
const unmountTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const panelRef = useRef<HTMLDivElement>(null);
// "Saved" toast — floats half-out of the panel's bottom edge for ~2s
// whenever a profile field commits a change (Enter / blur). Three phases
// so the motion is directional: it rises in from below ("hidden" starting
// offset) and keeps drifting upward while fading out ("leaving" offset).
const [savedPhase, setSavedPhase] = useState<"hidden" | "shown" | "leaving">("hidden");
const savedTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const showSavedToast = () => {
if (savedTimer.current) clearTimeout(savedTimer.current);
setSavedPhase("shown");
savedTimer.current = setTimeout(() => {
setSavedPhase("leaving");
// Reset to the below-the-edge start once the fade-out finished, so the
// next save rises in from the bottom again (invisible: opacity 0 → 0).
savedTimer.current = setTimeout(() => setSavedPhase("hidden"), 220);
}, 2000);
};
// Top fade over the scrolling page so rows dissolve under the title row
// instead of cutting sharply (same recipe as the medical alerts feed).
const [contentScrolled, setContentScrolled] = useState(false);
useEffect(() => {
if (isOpen) {
if (unmountTimer.current) clearTimeout(unmountTimer.current);
setPage(defaultPage);
setMounted(true);
// Double rAF: the panel must commit its hidden state before the
// transition to visible starts, or the browser skips the animation.
requestAnimationFrame(() => requestAnimationFrame(() => setVisible(true)));
} else {
setVisible(false);
setSavedPhase("hidden");
unmountTimer.current = setTimeout(() => setMounted(false), 320);
}
return () => {
if (unmountTimer.current) clearTimeout(unmountTimer.current);
if (savedTimer.current) clearTimeout(savedTimer.current);
};
// eslint-disable-next-line react-hooks/exhaustive-deps -- defaultPage only matters at the open transition
}, [isOpen]);
// Escape closes; focus moves into the dialog on open.
useEffect(() => {
if (!isOpen) return;
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") onClose();
};
document.addEventListener("keydown", onKeyDown);
panelRef.current?.focus();
return () => document.removeEventListener("keydown", onKeyDown);
}, [isOpen, onClose]);
if (!mounted || typeof document === "undefined") return null;
return createPortal(
<div className="fixed inset-0 z-100 flex items-center justify-center p-4" role="presentation">
{/* Backdrop — dark-mode modal reference uses black at 70%. */}
<button
type="button"
aria-label="Close settings"
tabIndex={-1}
onClick={onClose}
className={cx(
"absolute inset-0 cursor-default bg-black/70 transition-opacity duration-300 ease-out",
visible ? "opacity-100" : "opacity-0",
)}
/>
{/* Animated wrapper — carries the open/close transition for the panel
AND the saved toast, which straddles the panel's bottom edge and so
must live outside the panel's overflow-clip. */}
<div
className={cx(
"relative",
// Kept light on purpose: an 8px blur over the full 871×614 panel
// forces a huge re-filter every frame of the scale-down, which is
// what made the close stutter. 4px + GPU promotion stays smooth.
"transform-gpu transition-[opacity,transform,filter] duration-300 ease-[cubic-bezier(0.32,0.72,0,1)] will-change-[opacity,transform,filter]",
visible ? "scale-100 opacity-100 blur-0" : "scale-[0.85] opacity-0 blur-[4px]",
)}
>
{/* Panel */}
<div
ref={panelRef}
role="dialog"
aria-modal="true"
aria-label="Settings"
tabIndex={-1}
className={cx(
"relative flex h-[614px] max-h-[calc(100dvh-32px)] w-[871px] max-w-[calc(100vw-32px)]",
// overflow-CLIP, not hidden: hidden boxes are still programmatically
// scrollable, so focusing a switch's hidden input in a row clipped
// by the inner scroller made the browser scroll-reveal it through
// the panel too — shifting the whole modal content up with no way
// back. clip forbids scrolling outright.
"overflow-clip rounded-3xl bg-background-full shadow-xs outline-none",
)}
>
{/* Nav rail — the board-team dropdown group/item recipe */}
<nav
aria-label="Settings sections"
className="flex w-[274px] shrink-0 flex-col gap-5 overflow-y-auto border-r border-separator-border bg-background-secondary-default p-2.5"
>
{NAV_GROUPS.map((group) => (
<div key={group.label} className="flex w-full flex-col gap-1.5 pt-1">
<span className="pl-2 text-body-medium text-text-secondary">{group.label}</span>
<div className="flex w-full flex-col gap-1">
{group.items.map((item) => {
const selected = item.page !== undefined && item.page === page;
return (
<button
key={`${group.label}:${item.label}`}
type="button"
aria-current={selected ? "page" : undefined}
onClick={
item.page
? () => {
setPage(item.page!);
setContentScrolled(false);
}
: undefined
}
className={cx(
"flex w-full cursor-pointer items-center gap-2 rounded-2lg p-2 text-left",
"outline-none transition-colors duration-150 ease focus-visible:ring-2 focus-visible:ring-border-focus-ring",
selected
? "bg-background-secondary-hover"
: "hover:bg-background-secondary-hover/60",
)}
>
<item.icon className="size-5 shrink-0 text-foreground-icon-secondary" aria-hidden />
<span
className={cx(
"truncate text-body-medium",
selected ? "text-text-primary" : "text-text-secondary",
)}
>
{item.label}
</span>
</button>
);
})}
</div>
</div>
))}
</nav>
{/* Content pane — fixed title row, scrollable page below */}
<div className="flex min-w-0 flex-1 flex-col">
{/* Storage keeps a tighter title gap: its page already carries
10px of scroll-safe headroom for the upload progress badge, so
the shared pb-3 read as a double margin above the dropzone. */}
<div
className={cx(
"flex shrink-0 items-center justify-between px-8 pt-8",
page === "storage" ? "pb-1.5" : "pb-3",
)}
>
<h2 className="text-title-3-medium text-text-primary">{PAGE_TITLES[page]}</h2>
<button
type="button"
aria-label="Close settings"
onClick={onClose}
className={cx(
"flex size-6 shrink-0 cursor-pointer items-center justify-center rounded-full",
"bg-background-tertiary-default text-foreground-icon-secondary",
"transition-colors duration-150 ease hover:bg-background-tertiary-hover",
"outline-none focus-visible:ring-2 focus-visible:ring-border-focus-ring",
)}
>
<RiCloseLine className="size-4" aria-hidden />
</button>
</div>
<div className="relative min-h-0 flex-1">
<div
className="h-full overflow-y-auto px-8 pb-8"
onScroll={(e) => setContentScrolled(e.currentTarget.scrollTop > 0)}
>
{page === "profile" ? (
<SettingsProfile onSaved={showSavedToast} />
) : page === "storage" ? (
<SettingsStorage />
) : page === "tools" ? (
<SettingsTools />
) : (
<SettingsGeneral planArtSrc={planArtSrc} />
)}
</div>
{/* Progressive top fade — eases in once the page is scrolled so
content dissolves under the title row instead of hard-cutting. */}
<div
aria-hidden
className={cx(
"pointer-events-none absolute inset-x-0 top-0 h-10 bg-linear-to-b from-background-primary-default to-transparent",
"transition-opacity duration-200 ease-out",
contentScrolled ? "opacity-100" : "opacity-0",
)}
/>
</div>
</div>
</div>
{/* Saved toast — straddles the panel's bottom edge, half in / half out. */}
<div
aria-live="polite"
className={cx(
"pointer-events-none absolute bottom-0 left-1/2 z-10 flex -translate-x-1/2 items-center gap-1",
"rounded-full border border-border-button-default bg-background-primary-default py-1 pr-2.5 pl-1.5 shadow-dropdown",
"transition-[opacity,transform,filter] duration-200 ease-out",
savedPhase === "shown" && "translate-y-1/2 opacity-100 scale-100 blur-0",
// Starts below the resting spot, rises in; keeps drifting up on exit.
savedPhase === "hidden" && "translate-y-[calc(50%+12px)] opacity-0 scale-90 blur-[2px]",
savedPhase === "leaving" && "translate-y-[calc(50%-10px)] opacity-0 scale-90 blur-[2px]",
)}
>
<RiCheckboxCircleFill className="size-4 shrink-0 text-lime-600" aria-hidden />
<span className="text-body-2-medium whitespace-nowrap text-text-primary">Saved</span>
</div>
</div>
</div>,
document.body,
);
}"use client";
import { useEffect, useRef, useState } from "react";
import { cx } from "@/utils/cx";
/**
* The "Current plan" artwork (settings-plan-art.png) rendered through a WebGL
* fragment shader as a burning, wind-torn flag:
*
* wave the image UVs ripple with two crossed sine waves plus low-freq
* fbm turbulence, amplitude growing toward the edges — center stays
* pinned like a flag on a pole, edges flap.
* burn a slow-drifting fbm field is thresholded against an edge-weighted,
* breathing burn level: past the threshold the cloth is gone (alpha
* holes), just before it a hot ember rim glows orange→yellow, and
* just inside that the fabric chars dark.
* sparks tiny embers detach from burning regions and drift up-right,
* flickering out.
*
* Same raw-WebGL recipe as the effort slider's FlameOverlay (ai-chat-menus):
* fullscreen quad, rAF loop, and a plain <img> fallback when a context can't
* be created. The radial fade into the card bg stays as a DOM overlay above.
*/
const VERT = /* glsl */ `
attribute vec2 a_pos;
varying vec2 v_uv;
void main() {
v_uv = a_pos * 0.5 + 0.5;
gl_Position = vec4(a_pos, 0.0, 1.0);
}
`;
const FRAG = /* glsl */ `
precision mediump float;
uniform sampler2D u_tex;
uniform float u_time;
uniform vec2 u_mouse; // pointer in UV space (y up)
uniform float u_mouseStr; // 0..1, eased in JS
varying vec2 v_uv;
float hash(vec2 p) {
return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453123);
}
float noise(vec2 p) {
vec2 i = floor(p);
vec2 f = fract(p);
vec2 u = f * f * (3.0 - 2.0 * f);
return mix(
mix(hash(i), hash(i + vec2(1.0, 0.0)), u.x),
mix(hash(i + vec2(0.0, 1.0)), hash(i + vec2(1.0, 1.0)), u.x),
u.y
);
}
float fbm(vec2 p) {
float v = 0.0;
float a = 0.5;
for (int i = 0; i < 4; i++) {
v += a * noise(p);
p = p * 2.03 + vec2(17.0, 9.2);
a *= 0.5;
}
return v;
}
void main() {
vec2 uv = v_uv;
// 0 at the center, 1 at the corners — drives both flap amplitude and burn.
float edge = clamp(distance(uv, vec2(0.5)) * 1.6, 0.0, 1.0);
// Pointer heat: a soft hotspot under the cursor that stokes the burn and
// agitates the cloth around it.
float mouseBoost = u_mouseStr * smoothstep(0.2, 0.02, distance(v_uv, u_mouse));
// Flag-in-wind: crossed sines + a pinch of turbulence, pinned at center.
float amp = 0.004 + 0.022 * edge * edge + 0.012 * mouseBoost;
uv.x += sin(uv.y * 9.0 + u_time * 2.2) * amp;
uv.y += sin(uv.x * 12.0 - u_time * 2.7) * amp * 0.85;
uv += (vec2(
fbm(v_uv * 3.0 + vec2(u_time * 0.35, 0.0)),
fbm(v_uv * 3.0 + vec2(0.0, u_time * 0.31) + 31.7)
) - 0.5) * 0.03 * edge;
vec4 img = texture2D(u_tex, uv);
// Burn field: drifting noise vs an edge-weighted, breathing threshold —
// the pointer hotspot raises the local burn level on top.
float n = fbm(uv * 4.5 + vec2(u_time * 0.3, -u_time * 0.48));
float breathe = 0.78 + 0.22 * sin(u_time * 1.1 + n * 7.0);
float burn = min(edge * breathe * 0.85 + mouseBoost * 0.45, 1.05);
float d = n - (1.0 - burn); // > 0 → consumed
float hole = smoothstep(0.0, 0.07, d);
float rim = smoothstep(-0.11, 0.0, d) * (1.0 - hole);
float charr = smoothstep(-0.26, -0.08, d) * (1.0 - rim) * (1.0 - hole);
// Ember rim: deep orange at the outside, white-yellow right at the tear.
float hot = smoothstep(-0.05, 0.0, d);
vec3 ember = mix(vec3(1.0, 0.38, 0.08), vec3(1.0, 0.85, 0.35), hot);
vec3 col = img.rgb;
col = mix(col, col * vec3(0.32, 0.24, 0.22), charr * 0.75); // char darkening
col = mix(col, ember, rim);
col += ember * rim * 0.6; // rim over-glow
float alpha = img.a * (1.0 - hole);
// Burning paper flakes: three parallax layers of small rotated slivers
// that drift up-right while tumbling. Each has its own life cycle —
// ignites bright yellow-white, cools to orange, then dies out — and they
// only spawn near burning fabric (or under the pointer's hotspot).
float gate = smoothstep(0.12, 0.5, edge * breathe) + mouseBoost;
for (int i = 0; i < 3; i++) {
float fi = float(i);
float scale = 13.0 + fi * 8.0;
vec2 sp = v_uv * scale + vec2(-u_time * (0.8 + fi * 0.5), -u_time * (2.0 + fi * 1.1));
vec2 cell = floor(sp);
float sh = hash(cell + fi * 13.7);
if (sh > 0.7) {
vec2 pos = 0.2 + 0.6 * vec2(hash(cell + 3.1), hash(cell + 7.7));
// Tumbling sway while it floats
pos.x += sin(u_time * (2.0 + sh * 3.0) + sh * 20.0) * 0.08;
vec2 delta = fract(sp) - pos;
// Rotated, elongated sliver — reads as a torn paper fragment.
float angle = sh * 6.2831 + u_time * (1.2 + sh * 2.0);
vec2 r = mat2(cos(angle), -sin(angle), sin(angle), cos(angle)) * delta;
float body = smoothstep(0.17, 0.03, length(r * vec2(1.0, 2.6)));
// Life cycle: quick ignite → glow → burn out and vanish.
float life = fract(u_time * (0.35 + sh * 0.5) + sh * 11.0);
float glow = smoothstep(0.0, 0.1, life) * (1.0 - smoothstep(0.5, 0.95, life));
float flicker = 0.75 + 0.25 * sin(u_time * 11.0 + sh * 40.0);
vec3 flakeCol = mix(vec3(1.0, 0.4, 0.07), vec3(1.0, 0.93, 0.55), glow * flicker);
float lum = body * glow * flicker * min(gate, 1.2);
col += flakeCol * lum;
alpha = max(alpha, lum * 0.95);
}
}
gl_FragColor = vec4(col, alpha);
}
`;
/**
* Paints the built-in artwork — the three token-coloured blobs — into an
* offscreen canvas so the shader has something to burn when no `src` is given.
*
* Exists so the component animates on its own: a CLI install ships no image,
* and a static gradient in place of the flame made the packaged component look
* like a different thing from the one in the templates. Colours are read from
* the live custom properties rather than hardcoded, so it follows the theme.
*/
function paintFallbackArt(size: number): HTMLCanvasElement | null {
const canvas = document.createElement("canvas");
canvas.width = size;
canvas.height = size;
const ctx = canvas.getContext("2d");
if (!ctx) return null;
const styles = getComputedStyle(document.documentElement);
const token = (name: string, fallback: string) =>
styles.getPropertyValue(name).trim() || fallback;
// Soft-edged discs, matching the DOM fallback's positions and 70% alpha.
// A radial gradient rather than ctx.filter = blur(): Safari leaves the
// filter unimplemented on some versions, and a hard disc would read as a
// pasted circle instead of a glow.
const blob = (cx: number, cy: number, r: number, color: string) => {
const gradient = ctx.createRadialGradient(cx, cy, 0, cx, cy, r);
gradient.addColorStop(0, color);
gradient.addColorStop(0.55, color);
gradient.addColorStop(1, "transparent");
ctx.fillStyle = gradient;
ctx.beginPath();
ctx.arc(cx, cy, r, 0, Math.PI * 2);
ctx.fill();
};
ctx.fillStyle = token("--color-background-tertiary-default", "#ebebeb");
ctx.fillRect(0, 0, size, size);
ctx.globalAlpha = 0.7;
blob(size * 0.125, size * 0.125, size * 0.42, token("--color-blue-400", "#3392ff"));
blob(size * 0.83, size * 0.58, size * 0.38, token("--color-lime-400", "#9ae600"));
blob(size * 0.5, size * 0.95, size * 0.42, token("--color-orange-400", "#ff8904"));
ctx.globalAlpha = 1;
return canvas;
}
export function PlanArtFlame({ src, className }: { src?: string; className?: string }) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const [failed, setFailed] = useState(false);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const gl = canvas.getContext("webgl", { alpha: true, premultipliedAlpha: false });
if (!gl) {
setFailed(true);
return;
}
const { width, height } = canvas.getBoundingClientRect();
const dpr = window.devicePixelRatio || 1;
canvas.width = width * dpr;
canvas.height = height * dpr;
gl.viewport(0, 0, canvas.width, canvas.height);
const compile = (type: number, source: string) => {
const shader = gl.createShader(type)!;
gl.shaderSource(shader, source);
gl.compileShader(shader);
return shader;
};
const program = gl.createProgram()!;
gl.attachShader(program, compile(gl.VERTEX_SHADER, VERT));
gl.attachShader(program, compile(gl.FRAGMENT_SHADER, FRAG));
gl.linkProgram(program);
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
setFailed(true);
return;
}
gl.useProgram(program);
const buffer = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 1, -1, -1, 1, 1, 1]), gl.STATIC_DRAW);
const aPos = gl.getAttribLocation(program, "a_pos");
gl.enableVertexAttribArray(aPos);
gl.vertexAttribPointer(aPos, 2, gl.FLOAT, false, 0, 0);
const uTime = gl.getUniformLocation(program, "u_time");
const uMouse = gl.getUniformLocation(program, "u_mouse");
const uMouseStr = gl.getUniformLocation(program, "u_mouseStr");
gl.uniform1i(gl.getUniformLocation(program, "u_tex"), 0);
// Pointer tracked on window (overlays sit above the canvas, so listening
// on the canvas itself would never fire); eased per-frame in the loop.
const target = { x: 0.5, y: 0.5, str: 0 };
const eased = { x: 0.5, y: 0.5, str: 0 };
const onPointerMove = (event: PointerEvent) => {
const rect = canvas.getBoundingClientRect();
const x = (event.clientX - rect.left) / rect.width;
const y = 1 - (event.clientY - rect.top) / rect.height; // GL y-up
const inside = x > -0.15 && x < 1.15 && y > -0.15 && y < 1.15;
if (inside) {
target.x = x;
target.y = y;
}
target.str = inside ? 1 : 0;
};
window.addEventListener("pointermove", onPointerMove);
// NPOT-safe texture params (no mipmaps, clamp, linear).
const texture = gl.createTexture();
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, texture);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
gl.enable(gl.BLEND);
gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
let raf = 0;
let disposed = false;
const start = performance.now();
const draw = (now: number) => {
gl.uniform1f(uTime, (now - start) / 1000);
// Ease the pointer hotspot so the heat trails the cursor smoothly.
eased.x += (target.x - eased.x) * 0.12;
eased.y += (target.y - eased.y) * 0.12;
eased.str += (target.str - eased.str) * 0.07;
gl.uniform2f(uMouse, eased.x, eased.y);
gl.uniform1f(uMouseStr, eased.str);
gl.clearColor(0, 0, 0, 0);
gl.clear(gl.COLOR_BUFFER_BIT);
gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
raf = requestAnimationFrame(draw);
};
const upload = (source: TexImageSource) => {
gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, true);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, source);
raf = requestAnimationFrame(draw);
};
if (src) {
const image = new Image();
image.onload = () => {
if (disposed) return;
upload(image);
};
image.onerror = () => setFailed(true);
image.src = src;
} else {
// No artwork supplied: burn the built-in blobs instead of falling back
// to a static gradient.
const art = paintFallbackArt(512);
if (!art) {
setFailed(true);
return;
}
upload(art);
}
return () => {
disposed = true;
cancelAnimationFrame(raf);
window.removeEventListener("pointermove", onPointerMove);
};
}, [src]);
// Only when WebGL itself is unavailable: with no `src` the shader now runs
// on generated artwork, so this is the no-context / broken-image path.
if (failed) {
return (
<div
aria-hidden
className={cx(
"relative overflow-hidden bg-background-tertiary-default",
className,
)}
>
<span className="absolute -top-1/4 -left-1/4 size-3/4 rounded-full bg-blue-400/70 blur-2xl" />
<span className="absolute top-1/4 right-0 size-2/3 rounded-full bg-lime-400/70 blur-2xl" />
<span className="absolute right-1/4 -bottom-1/4 size-3/4 rounded-full bg-orange-400/70 blur-2xl" />
</div>
);
}
return <canvas ref={canvasRef} className={className} aria-hidden />;
}"use client";
import { useEffect, useRef, useState } from "react";
import { cx } from "@/utils/cx";
/**
* The "Current plan" artwork (settings-plan-art.png) rendered through a WebGL
* fragment shader as a burning, wind-torn flag:
*
* wave the image UVs ripple with two crossed sine waves plus low-freq
* fbm turbulence, amplitude growing toward the edges — center stays
* pinned like a flag on a pole, edges flap.
* burn a slow-drifting fbm field is thresholded against an edge-weighted,
* breathing burn level: past the threshold the cloth is gone (alpha
* holes), just before it a hot ember rim glows orange→yellow, and
* just inside that the fabric chars dark.
* sparks tiny embers detach from burning regions and drift up-right,
* flickering out.
*
* Same raw-WebGL recipe as the effort slider's FlameOverlay (ai-chat-menus):
* fullscreen quad, rAF loop, and a plain <img> fallback when a context can't
* be created. The radial fade into the card bg stays as a DOM overlay above.
*/
const VERT = /* glsl */ `
attribute vec2 a_pos;
varying vec2 v_uv;
void main() {
v_uv = a_pos * 0.5 + 0.5;
gl_Position = vec4(a_pos, 0.0, 1.0);
}
`;
const FRAG = /* glsl */ `
precision mediump float;
uniform sampler2D u_tex;
uniform float u_time;
uniform vec2 u_mouse; // pointer in UV space (y up)
uniform float u_mouseStr; // 0..1, eased in JS
varying vec2 v_uv;
float hash(vec2 p) {
return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453123);
}
float noise(vec2 p) {
vec2 i = floor(p);
vec2 f = fract(p);
vec2 u = f * f * (3.0 - 2.0 * f);
return mix(
mix(hash(i), hash(i + vec2(1.0, 0.0)), u.x),
mix(hash(i + vec2(0.0, 1.0)), hash(i + vec2(1.0, 1.0)), u.x),
u.y
);
}
float fbm(vec2 p) {
float v = 0.0;
float a = 0.5;
for (int i = 0; i < 4; i++) {
v += a * noise(p);
p = p * 2.03 + vec2(17.0, 9.2);
a *= 0.5;
}
return v;
}
void main() {
vec2 uv = v_uv;
// 0 at the center, 1 at the corners — drives both flap amplitude and burn.
float edge = clamp(distance(uv, vec2(0.5)) * 1.6, 0.0, 1.0);
// Pointer heat: a soft hotspot under the cursor that stokes the burn and
// agitates the cloth around it.
float mouseBoost = u_mouseStr * smoothstep(0.2, 0.02, distance(v_uv, u_mouse));
// Flag-in-wind: crossed sines + a pinch of turbulence, pinned at center.
float amp = 0.004 + 0.022 * edge * edge + 0.012 * mouseBoost;
uv.x += sin(uv.y * 9.0 + u_time * 2.2) * amp;
uv.y += sin(uv.x * 12.0 - u_time * 2.7) * amp * 0.85;
uv += (vec2(
fbm(v_uv * 3.0 + vec2(u_time * 0.35, 0.0)),
fbm(v_uv * 3.0 + vec2(0.0, u_time * 0.31) + 31.7)
) - 0.5) * 0.03 * edge;
vec4 img = texture2D(u_tex, uv);
// Burn field: drifting noise vs an edge-weighted, breathing threshold —
// the pointer hotspot raises the local burn level on top.
float n = fbm(uv * 4.5 + vec2(u_time * 0.3, -u_time * 0.48));
float breathe = 0.78 + 0.22 * sin(u_time * 1.1 + n * 7.0);
float burn = min(edge * breathe * 0.85 + mouseBoost * 0.45, 1.05);
float d = n - (1.0 - burn); // > 0 → consumed
float hole = smoothstep(0.0, 0.07, d);
float rim = smoothstep(-0.11, 0.0, d) * (1.0 - hole);
float charr = smoothstep(-0.26, -0.08, d) * (1.0 - rim) * (1.0 - hole);
// Ember rim: deep orange at the outside, white-yellow right at the tear.
float hot = smoothstep(-0.05, 0.0, d);
vec3 ember = mix(vec3(1.0, 0.38, 0.08), vec3(1.0, 0.85, 0.35), hot);
vec3 col = img.rgb;
col = mix(col, col * vec3(0.32, 0.24, 0.22), charr * 0.75); // char darkening
col = mix(col, ember, rim);
col += ember * rim * 0.6; // rim over-glow
float alpha = img.a * (1.0 - hole);
// Burning paper flakes: three parallax layers of small rotated slivers
// that drift up-right while tumbling. Each has its own life cycle —
// ignites bright yellow-white, cools to orange, then dies out — and they
// only spawn near burning fabric (or under the pointer's hotspot).
float gate = smoothstep(0.12, 0.5, edge * breathe) + mouseBoost;
for (int i = 0; i < 3; i++) {
float fi = float(i);
float scale = 13.0 + fi * 8.0;
vec2 sp = v_uv * scale + vec2(-u_time * (0.8 + fi * 0.5), -u_time * (2.0 + fi * 1.1));
vec2 cell = floor(sp);
float sh = hash(cell + fi * 13.7);
if (sh > 0.7) {
vec2 pos = 0.2 + 0.6 * vec2(hash(cell + 3.1), hash(cell + 7.7));
// Tumbling sway while it floats
pos.x += sin(u_time * (2.0 + sh * 3.0) + sh * 20.0) * 0.08;
vec2 delta = fract(sp) - pos;
// Rotated, elongated sliver — reads as a torn paper fragment.
float angle = sh * 6.2831 + u_time * (1.2 + sh * 2.0);
vec2 r = mat2(cos(angle), -sin(angle), sin(angle), cos(angle)) * delta;
float body = smoothstep(0.17, 0.03, length(r * vec2(1.0, 2.6)));
// Life cycle: quick ignite → glow → burn out and vanish.
float life = fract(u_time * (0.35 + sh * 0.5) + sh * 11.0);
float glow = smoothstep(0.0, 0.1, life) * (1.0 - smoothstep(0.5, 0.95, life));
float flicker = 0.75 + 0.25 * sin(u_time * 11.0 + sh * 40.0);
vec3 flakeCol = mix(vec3(1.0, 0.4, 0.07), vec3(1.0, 0.93, 0.55), glow * flicker);
float lum = body * glow * flicker * min(gate, 1.2);
col += flakeCol * lum;
alpha = max(alpha, lum * 0.95);
}
}
gl_FragColor = vec4(col, alpha);
}
`;
/**
* Paints the built-in artwork — the three token-coloured blobs — into an
* offscreen canvas so the shader has something to burn when no `src` is given.
*
* Exists so the component animates on its own: a CLI install ships no image,
* and a static gradient in place of the flame made the packaged component look
* like a different thing from the one in the templates. Colours are read from
* the live custom properties rather than hardcoded, so it follows the theme.
*/
function paintFallbackArt(size: number): HTMLCanvasElement | null {
const canvas = document.createElement("canvas");
canvas.width = size;
canvas.height = size;
const ctx = canvas.getContext("2d");
if (!ctx) return null;
const styles = getComputedStyle(document.documentElement);
const token = (name: string, fallback: string) =>
styles.getPropertyValue(name).trim() || fallback;
// Soft-edged discs, matching the DOM fallback's positions and 70% alpha.
// A radial gradient rather than ctx.filter = blur(): Safari leaves the
// filter unimplemented on some versions, and a hard disc would read as a
// pasted circle instead of a glow.
const blob = (cx: number, cy: number, r: number, color: string) => {
const gradient = ctx.createRadialGradient(cx, cy, 0, cx, cy, r);
gradient.addColorStop(0, color);
gradient.addColorStop(0.55, color);
gradient.addColorStop(1, "transparent");
ctx.fillStyle = gradient;
ctx.beginPath();
ctx.arc(cx, cy, r, 0, Math.PI * 2);
ctx.fill();
};
ctx.fillStyle = token("--color-background-tertiary-default", "#ebebeb");
ctx.fillRect(0, 0, size, size);
ctx.globalAlpha = 0.7;
blob(size * 0.125, size * 0.125, size * 0.42, token("--color-blue-400", "#3392ff"));
blob(size * 0.83, size * 0.58, size * 0.38, token("--color-lime-400", "#9ae600"));
blob(size * 0.5, size * 0.95, size * 0.42, token("--color-orange-400", "#ff8904"));
ctx.globalAlpha = 1;
return canvas;
}
export function PlanArtFlame({ src, className }: { src?: string; className?: string }) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const [failed, setFailed] = useState(false);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const gl = canvas.getContext("webgl", { alpha: true, premultipliedAlpha: false });
if (!gl) {
setFailed(true);
return;
}
const { width, height } = canvas.getBoundingClientRect();
const dpr = window.devicePixelRatio || 1;
canvas.width = width * dpr;
canvas.height = height * dpr;
gl.viewport(0, 0, canvas.width, canvas.height);
const compile = (type: number, source: string) => {
const shader = gl.createShader(type)!;
gl.shaderSource(shader, source);
gl.compileShader(shader);
return shader;
};
const program = gl.createProgram()!;
gl.attachShader(program, compile(gl.VERTEX_SHADER, VERT));
gl.attachShader(program, compile(gl.FRAGMENT_SHADER, FRAG));
gl.linkProgram(program);
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
setFailed(true);
return;
}
gl.useProgram(program);
const buffer = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 1, -1, -1, 1, 1, 1]), gl.STATIC_DRAW);
const aPos = gl.getAttribLocation(program, "a_pos");
gl.enableVertexAttribArray(aPos);
gl.vertexAttribPointer(aPos, 2, gl.FLOAT, false, 0, 0);
const uTime = gl.getUniformLocation(program, "u_time");
const uMouse = gl.getUniformLocation(program, "u_mouse");
const uMouseStr = gl.getUniformLocation(program, "u_mouseStr");
gl.uniform1i(gl.getUniformLocation(program, "u_tex"), 0);
// Pointer tracked on window (overlays sit above the canvas, so listening
// on the canvas itself would never fire); eased per-frame in the loop.
const target = { x: 0.5, y: 0.5, str: 0 };
const eased = { x: 0.5, y: 0.5, str: 0 };
const onPointerMove = (event: PointerEvent) => {
const rect = canvas.getBoundingClientRect();
const x = (event.clientX - rect.left) / rect.width;
const y = 1 - (event.clientY - rect.top) / rect.height; // GL y-up
const inside = x > -0.15 && x < 1.15 && y > -0.15 && y < 1.15;
if (inside) {
target.x = x;
target.y = y;
}
target.str = inside ? 1 : 0;
};
window.addEventListener("pointermove", onPointerMove);
// NPOT-safe texture params (no mipmaps, clamp, linear).
const texture = gl.createTexture();
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, texture);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
gl.enable(gl.BLEND);
gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
let raf = 0;
let disposed = false;
const start = performance.now();
const draw = (now: number) => {
gl.uniform1f(uTime, (now - start) / 1000);
// Ease the pointer hotspot so the heat trails the cursor smoothly.
eased.x += (target.x - eased.x) * 0.12;
eased.y += (target.y - eased.y) * 0.12;
eased.str += (target.str - eased.str) * 0.07;
gl.uniform2f(uMouse, eased.x, eased.y);
gl.uniform1f(uMouseStr, eased.str);
gl.clearColor(0, 0, 0, 0);
gl.clear(gl.COLOR_BUFFER_BIT);
gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
raf = requestAnimationFrame(draw);
};
const upload = (source: TexImageSource) => {
gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, true);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, source);
raf = requestAnimationFrame(draw);
};
if (src) {
const image = new Image();
image.onload = () => {
if (disposed) return;
upload(image);
};
image.onerror = () => setFailed(true);
image.src = src;
} else {
// No artwork supplied: burn the built-in blobs instead of falling back
// to a static gradient.
const art = paintFallbackArt(512);
if (!art) {
setFailed(true);
return;
}
upload(art);
}
return () => {
disposed = true;
cancelAnimationFrame(raf);
window.removeEventListener("pointermove", onPointerMove);
};
}, [src]);
// Only when WebGL itself is unavailable: with no `src` the shader now runs
// on generated artwork, so this is the no-context / broken-image path.
if (failed) {
return (
<div
aria-hidden
className={cx(
"relative overflow-hidden bg-background-tertiary-default",
className,
)}
>
<span className="absolute -top-1/4 -left-1/4 size-3/4 rounded-full bg-blue-400/70 blur-2xl" />
<span className="absolute top-1/4 right-0 size-2/3 rounded-full bg-lime-400/70 blur-2xl" />
<span className="absolute right-1/4 -bottom-1/4 size-3/4 rounded-full bg-orange-400/70 blur-2xl" />
</div>
);
}
return <canvas ref={canvasRef} className={className} aria-hidden />;
}"use client";
import { useState } from "react";
import { Button } from "@/components/base/buttons/button";
import { Select, SelectItem } from "@/components/base/select/select";
import { Switch } from "@/components/base/switch/switch";
import { PlanArtFlame } from "./plan-art-flame";
import {
SettingsCard,
SettingsRow,
SettingsSectionLabel,
} from "./settings-rows";
const SELECT_TRIGGER = "h-8 w-auto gap-1 rounded-lg px-2 py-1.5";
export function SettingsGeneral({ planArtSrc }: { planArtSrc?: string }) {
const [toggles, setToggles] = useState({
critical: true,
system: false,
sound: false,
dispatch: false,
});
const setToggle = (key: keyof typeof toggles) => (value: boolean) =>
setToggles((t) => ({ ...t, [key]: value }));
return (
<div className="flex w-full flex-col gap-6">
{/* Current plan */}
<div className="relative w-full overflow-hidden rounded-2xl bg-background-secondary-default">
{/* Artwork bleeding off the right edge, fading into the card bg.
Rendered through a WebGL shader: waving like a wind-torn flag with
a continuous burning-edge effect (see plan-art-flame.tsx). */}
<div aria-hidden className="absolute -top-[11px] left-[328px] size-[277px]">
<PlanArtFlame
src={planArtSrc}
className="size-full object-cover"
/>
{/* Fades into the card, whatever colour that is: the stops mix the
card's own token rather than a hardcoded #f7f7f7, which stayed
white and haloed the artwork in dark mode. Mixing toward 0% of
the token instead of `transparent` also avoids the grey fringe
sRGB interpolation gives when a colour fades to rgba(0,0,0,0). */}
<div
className="absolute inset-0"
style={{
background: `radial-gradient(closest-side at center,
color-mix(in srgb, var(--color-background-secondary-default) 0%, transparent) 13%,
color-mix(in srgb, var(--color-background-secondary-default) 13%, transparent) 37%,
color-mix(in srgb, var(--color-background-secondary-default) 85%, transparent) 86%,
var(--color-background-secondary-default) 100%)`,
}}
/>
</div>
<div className="relative flex flex-col gap-2.5 py-3 pr-2.5 pl-3">
<div className="flex flex-col gap-2">
<span className="inline-flex w-fit items-center rounded-md bg-background-tertiary-default px-1.5 py-0.5 text-body-2-medium text-text-secondary">
Current plan
</span>
<div className="flex flex-col gap-0.5">
<p className="text-headline-medium text-text-primary">Ultra $149/mo</p>
<p className="text-body-2-regular text-text-secondary">
You are on 7x more usage than Regular.
</p>
</div>
</div>
<Button variant="secondary" size="small" className="w-fit">
Upgrade to Max
</Button>
</div>
</div>
{/* Limits */}
<SettingsCard>
<SettingsRow label="Limits" description="You are on 7x more usage than Premium">
<Button variant="secondary" size="small">
Manage limits
</Button>
</SettingsRow>
</SettingsCard>
{/* Pull Requests */}
<div className="flex w-full flex-col gap-2">
<SettingsSectionLabel>Pull Requests</SettingsSectionLabel>
<SettingsCard>
<SettingsRow
label="Review provider"
description="Select Github or other providers for reviews"
>
<Select
aria-label="Review provider"
defaultSelectedKey="github"
triggerClassName={SELECT_TRIGGER}
>
<SelectItem id="github">GitHub</SelectItem>
<SelectItem id="gitlab">GitLab</SelectItem>
<SelectItem id="bitbucket">Bitbucket</SelectItem>
</Select>
</SettingsRow>
<SettingsRow
label="PR destination"
description="Open pull request links inside your app"
>
<Select
aria-label="PR destination"
defaultSelectedKey="inside"
triggerClassName={SELECT_TRIGGER}
>
<SelectItem id="inside">Inside BoardCN</SelectItem>
<SelectItem id="browser">In the browser</SelectItem>
</Select>
</SettingsRow>
</SettingsCard>
</div>
{/* Notifications */}
<div className="flex w-full flex-col gap-2">
<SettingsSectionLabel>Notifications</SettingsSectionLabel>
<SettingsCard>
<SettingsRow
label="Critical requests"
description="Get notified when the mode needs to make a critical decision"
>
<Switch
aria-label="Critical requests"
isSelected={toggles.critical}
onChange={setToggle("critical")}
/>
</SettingsRow>
<SettingsRow
label="System notifications"
description="Show fundamental notifications when an agent completes a task"
>
<Switch
aria-label="System notifications"
isSelected={toggles.system}
onChange={setToggle("system")}
/>
</SettingsRow>
<SettingsRow
label="Completion sound"
description="Sound effect a task is completed"
>
<Switch
aria-label="Completion sound"
isSelected={toggles.sound}
onChange={setToggle("sound")}
/>
</SettingsRow>
<SettingsRow
label="Dispatch alerts"
description="Push notification on your phone when BoardCN messages you"
>
<Switch
aria-label="Dispatch alerts"
isSelected={toggles.dispatch}
onChange={setToggle("dispatch")}
/>
</SettingsRow>
</SettingsCard>
</div>
</div>
);
}"use client";
import { useState } from "react";
import { Button } from "@/components/base/buttons/button";
import { Select, SelectItem } from "@/components/base/select/select";
import { Switch } from "@/components/base/switch/switch";
import { PlanArtFlame } from "./plan-art-flame";
import {
SettingsCard,
SettingsRow,
SettingsSectionLabel,
} from "./settings-rows";
const SELECT_TRIGGER = "h-8 w-auto gap-1 rounded-lg px-2 py-1.5";
export function SettingsGeneral({ planArtSrc }: { planArtSrc?: string }) {
const [toggles, setToggles] = useState({
critical: true,
system: false,
sound: false,
dispatch: false,
});
const setToggle = (key: keyof typeof toggles) => (value: boolean) =>
setToggles((t) => ({ ...t, [key]: value }));
return (
<div className="flex w-full flex-col gap-6">
{/* Current plan */}
<div className="relative w-full overflow-hidden rounded-2xl bg-background-secondary-default">
{/* Artwork bleeding off the right edge, fading into the card bg.
Rendered through a WebGL shader: waving like a wind-torn flag with
a continuous burning-edge effect (see plan-art-flame.tsx). */}
<div aria-hidden className="absolute -top-[11px] left-[328px] size-[277px]">
<PlanArtFlame
src={planArtSrc}
className="size-full object-cover"
/>
{/* Fades into the card, whatever colour that is: the stops mix the
card's own token rather than a hardcoded #f7f7f7, which stayed
white and haloed the artwork in dark mode. Mixing toward 0% of
the token instead of `transparent` also avoids the grey fringe
sRGB interpolation gives when a colour fades to rgba(0,0,0,0). */}
<div
className="absolute inset-0"
style={{
background: `radial-gradient(closest-side at center,
color-mix(in srgb, var(--color-background-secondary-default) 0%, transparent) 13%,
color-mix(in srgb, var(--color-background-secondary-default) 13%, transparent) 37%,
color-mix(in srgb, var(--color-background-secondary-default) 85%, transparent) 86%,
var(--color-background-secondary-default) 100%)`,
}}
/>
</div>
<div className="relative flex flex-col gap-2.5 py-3 pr-2.5 pl-3">
<div className="flex flex-col gap-2">
<span className="inline-flex w-fit items-center rounded-md bg-background-tertiary-default px-1.5 py-0.5 text-body-2-medium text-text-secondary">
Current plan
</span>
<div className="flex flex-col gap-0.5">
<p className="text-headline-medium text-text-primary">Ultra $149/mo</p>
<p className="text-body-2-regular text-text-secondary">
You are on 7x more usage than Regular.
</p>
</div>
</div>
<Button variant="secondary" size="small" className="w-fit">
Upgrade to Max
</Button>
</div>
</div>
{/* Limits */}
<SettingsCard>
<SettingsRow label="Limits" description="You are on 7x more usage than Premium">
<Button variant="secondary" size="small">
Manage limits
</Button>
</SettingsRow>
</SettingsCard>
{/* Pull Requests */}
<div className="flex w-full flex-col gap-2">
<SettingsSectionLabel>Pull Requests</SettingsSectionLabel>
<SettingsCard>
<SettingsRow
label="Review provider"
description="Select Github or other providers for reviews"
>
<Select
aria-label="Review provider"
defaultSelectedKey="github"
triggerClassName={SELECT_TRIGGER}
>
<SelectItem id="github">GitHub</SelectItem>
<SelectItem id="gitlab">GitLab</SelectItem>
<SelectItem id="bitbucket">Bitbucket</SelectItem>
</Select>
</SettingsRow>
<SettingsRow
label="PR destination"
description="Open pull request links inside your app"
>
<Select
aria-label="PR destination"
defaultSelectedKey="inside"
triggerClassName={SELECT_TRIGGER}
>
<SelectItem id="inside">Inside BoardCN</SelectItem>
<SelectItem id="browser">In the browser</SelectItem>
</Select>
</SettingsRow>
</SettingsCard>
</div>
{/* Notifications */}
<div className="flex w-full flex-col gap-2">
<SettingsSectionLabel>Notifications</SettingsSectionLabel>
<SettingsCard>
<SettingsRow
label="Critical requests"
description="Get notified when the mode needs to make a critical decision"
>
<Switch
aria-label="Critical requests"
isSelected={toggles.critical}
onChange={setToggle("critical")}
/>
</SettingsRow>
<SettingsRow
label="System notifications"
description="Show fundamental notifications when an agent completes a task"
>
<Switch
aria-label="System notifications"
isSelected={toggles.system}
onChange={setToggle("system")}
/>
</SettingsRow>
<SettingsRow
label="Completion sound"
description="Sound effect a task is completed"
>
<Switch
aria-label="Completion sound"
isSelected={toggles.sound}
onChange={setToggle("sound")}
/>
</SettingsRow>
<SettingsRow
label="Dispatch alerts"
description="Push notification on your phone when BoardCN messages you"
>
<Switch
aria-label="Dispatch alerts"
isSelected={toggles.dispatch}
onChange={setToggle("dispatch")}
/>
</SettingsRow>
</SettingsCard>
</div>
</div>
);
}"use client";
import { useRef, useState, type ComponentProps } from "react";
import { CalendarDate } from "@internationalized/date";
import { RiCalendarLine, RiExternalLinkLine, RiLogoutCircleLine, RiMailLine } from "@remixicon/react";
import { Button } from "@/components/base/buttons/button";
import { DatePicker } from "@/components/base/date-picker/date-picker";
import { Input } from "@/components/base/input/input";
import { Switch } from "@/components/base/switch/switch";
import { cx } from "@/utils/cx";
import {
SettingsCard,
SettingsRow,
SettingsValueField,
} from "./settings-rows";
const MONTHS = [
"January", "February", "March", "April", "May", "June",
"July", "August", "September", "October", "November", "December",
];
function formatBirthDate(date: CalendarDate) {
return `${date.day} ${MONTHS[date.month - 1]} ${date.year}`;
}
/**
* Design-system Input that commits on Enter / blur: Enter just blurs the
* field, and blur fires `onSaved` only when the value actually changed since
* the last commit — so clicking in and out without typing stays silent.
*/
function SavableInput({
initialValue,
onSaved,
...inputProps
}: { initialValue: string; onSaved?: () => void } & Omit<
ComponentProps<typeof Input>,
"value" | "onChange" | "defaultValue"
>) {
const [value, setValue] = useState(initialValue);
const committed = useRef(initialValue);
return (
<Input
size="small"
{...inputProps}
value={value}
onChange={setValue}
onKeyDown={(event) => {
if (event.key === "Enter") (event.target as HTMLElement).blur();
}}
onBlur={() => {
if (value !== committed.current) {
committed.current = value;
onSaved?.();
}
}}
className={cx("w-[202px] shrink-0", inputProps.className)}
/>
);
}
export function SettingsProfile({ onSaved }: { onSaved?: () => void } = {}) {
const [birthDate, setBirthDate] = useState<CalendarDate>(new CalendarDate(1997, 7, 28));
const [birthOpen, setBirthOpen] = useState(false);
const birthTriggerRef = useRef<HTMLButtonElement>(null);
const [publicProfile, setPublicProfile] = useState(true);
return (
<div className="flex w-full flex-col gap-6">
<SettingsCard>
<SettingsRow label="Email">
<SavableInput
aria-label="Email"
type="email"
leadingIcon={RiMailLine}
initialValue="hi@mertcan.works"
onSaved={onSaved}
/>
</SettingsRow>
<SettingsRow label="First name">
<SavableInput aria-label="First name" initialValue="Mertcan" onSaved={onSaved} />
</SettingsRow>
<SettingsRow label="Last name">
<SavableInput aria-label="Last name" initialValue="Esmergül" onSaved={onSaved} />
</SettingsRow>
<SettingsRow label="Date of birth">
<button
ref={birthTriggerRef}
type="button"
onClick={() => setBirthOpen((o) => !o)}
className={[
"flex h-8 w-[202px] shrink-0 cursor-pointer items-center gap-0.5 rounded-lg px-2",
"border border-border-button-default bg-background-primary-default shadow-xs",
"transition-colors duration-150 ease hover:bg-background-primary-hover",
"outline-none focus-visible:ring-2 focus-visible:ring-border-focus-ring",
].join(" ")}
>
<RiCalendarLine className="size-[18px] shrink-0 text-foreground-icon-primary" aria-hidden />
<span className="px-0.5 text-body-regular whitespace-nowrap text-text-primary">
{formatBirthDate(birthDate)}
</span>
</button>
<DatePicker
aria-label="Date of birth"
triggerRef={birthTriggerRef}
isOpen={birthOpen}
onOpenChange={setBirthOpen}
value={birthDate}
onChange={(next) => next && setBirthDate(next)}
/>
</SettingsRow>
</SettingsCard>
<SettingsCard>
<SettingsRow label="BoardCN account">
<Button variant="secondary" size="small" leadingIcon={RiExternalLinkLine}>
Manage
</Button>
</SettingsRow>
<SettingsRow
label="Public profile"
description="When enabled your profile page will be visible to anyone"
>
<Switch
aria-label="Public profile"
isSelected={publicProfile}
onChange={setPublicProfile}
/>
</SettingsRow>
<SettingsRow label="Device ID">
<SettingsValueField muted>593e2611-b9e3-44e2-1289-ab3f9d21</SettingsValueField>
</SettingsRow>
<SettingsRow label="Log out from all devices">
<Button variant="secondary" size="small" leadingIcon={RiLogoutCircleLine}>
Logout
</Button>
</SettingsRow>
</SettingsCard>
</div>
);
}"use client";
import { useRef, useState, type ComponentProps } from "react";
import { CalendarDate } from "@internationalized/date";
import { RiCalendarLine, RiExternalLinkLine, RiLogoutCircleLine, RiMailLine } from "@remixicon/react";
import { Button } from "@/components/base/buttons/button";
import { DatePicker } from "@/components/base/date-picker/date-picker";
import { Input } from "@/components/base/input/input";
import { Switch } from "@/components/base/switch/switch";
import { cx } from "@/utils/cx";
import {
SettingsCard,
SettingsRow,
SettingsValueField,
} from "./settings-rows";
const MONTHS = [
"January", "February", "March", "April", "May", "June",
"July", "August", "September", "October", "November", "December",
];
function formatBirthDate(date: CalendarDate) {
return `${date.day} ${MONTHS[date.month - 1]} ${date.year}`;
}
/**
* Design-system Input that commits on Enter / blur: Enter just blurs the
* field, and blur fires `onSaved` only when the value actually changed since
* the last commit — so clicking in and out without typing stays silent.
*/
function SavableInput({
initialValue,
onSaved,
...inputProps
}: { initialValue: string; onSaved?: () => void } & Omit<
ComponentProps<typeof Input>,
"value" | "onChange" | "defaultValue"
>) {
const [value, setValue] = useState(initialValue);
const committed = useRef(initialValue);
return (
<Input
size="small"
{...inputProps}
value={value}
onChange={setValue}
onKeyDown={(event) => {
if (event.key === "Enter") (event.target as HTMLElement).blur();
}}
onBlur={() => {
if (value !== committed.current) {
committed.current = value;
onSaved?.();
}
}}
className={cx("w-[202px] shrink-0", inputProps.className)}
/>
);
}
export function SettingsProfile({ onSaved }: { onSaved?: () => void } = {}) {
const [birthDate, setBirthDate] = useState<CalendarDate>(new CalendarDate(1997, 7, 28));
const [birthOpen, setBirthOpen] = useState(false);
const birthTriggerRef = useRef<HTMLButtonElement>(null);
const [publicProfile, setPublicProfile] = useState(true);
return (
<div className="flex w-full flex-col gap-6">
<SettingsCard>
<SettingsRow label="Email">
<SavableInput
aria-label="Email"
type="email"
leadingIcon={RiMailLine}
initialValue="hi@mertcan.works"
onSaved={onSaved}
/>
</SettingsRow>
<SettingsRow label="First name">
<SavableInput aria-label="First name" initialValue="Mertcan" onSaved={onSaved} />
</SettingsRow>
<SettingsRow label="Last name">
<SavableInput aria-label="Last name" initialValue="Esmergül" onSaved={onSaved} />
</SettingsRow>
<SettingsRow label="Date of birth">
<button
ref={birthTriggerRef}
type="button"
onClick={() => setBirthOpen((o) => !o)}
className={[
"flex h-8 w-[202px] shrink-0 cursor-pointer items-center gap-0.5 rounded-lg px-2",
"border border-border-button-default bg-background-primary-default shadow-xs",
"transition-colors duration-150 ease hover:bg-background-primary-hover",
"outline-none focus-visible:ring-2 focus-visible:ring-border-focus-ring",
].join(" ")}
>
<RiCalendarLine className="size-[18px] shrink-0 text-foreground-icon-primary" aria-hidden />
<span className="px-0.5 text-body-regular whitespace-nowrap text-text-primary">
{formatBirthDate(birthDate)}
</span>
</button>
<DatePicker
aria-label="Date of birth"
triggerRef={birthTriggerRef}
isOpen={birthOpen}
onOpenChange={setBirthOpen}
value={birthDate}
onChange={(next) => next && setBirthDate(next)}
/>
</SettingsRow>
</SettingsCard>
<SettingsCard>
<SettingsRow label="BoardCN account">
<Button variant="secondary" size="small" leadingIcon={RiExternalLinkLine}>
Manage
</Button>
</SettingsRow>
<SettingsRow
label="Public profile"
description="When enabled your profile page will be visible to anyone"
>
<Switch
aria-label="Public profile"
isSelected={publicProfile}
onChange={setPublicProfile}
/>
</SettingsRow>
<SettingsRow label="Device ID">
<SettingsValueField muted>593e2611-b9e3-44e2-1289-ab3f9d21</SettingsValueField>
</SettingsRow>
<SettingsRow label="Log out from all devices">
<Button variant="secondary" size="small" leadingIcon={RiLogoutCircleLine}>
Logout
</Button>
</SettingsRow>
</SettingsCard>
</div>
);
}import type { ComponentType, ReactNode } from "react";
import { cx } from "@/utils/cx";
type IconComponent = ComponentType<{
className?: string;
"aria-hidden"?: boolean | "true" | "false";
}>;
/** Grouped card — rows divide themselves with borders that respect pl-12. */
export function SettingsCard({ className, children }: { className?: string; children: ReactNode }) {
return (
<div
className={cx(
"flex w-full flex-col rounded-2xl bg-background-secondary-default pl-3",
className,
)}
>
{children}
</div>
);
}
/** Muted 13px section heading above a card ("Pull Requests", "Notifications"). */
export function SettingsSectionLabel({ className, children }: { className?: string; children: ReactNode }) {
return (
<p className={cx("w-full px-3 text-body-2-medium text-text-secondary", className)}>{children}</p>
);
}
/** One label + control row. Rows separate themselves; the last has no border. */
export function SettingsRow({
label,
description,
children,
}: {
label: string;
description?: string;
children?: ReactNode;
}) {
return (
<div
className={cx(
"flex min-h-[52px] w-full items-center justify-between gap-4 py-2.5 pr-2.5",
"border-b border-separator-border last:border-b-0",
)}
>
<div className="flex min-w-0 flex-col">
<p className="text-body-regular text-text-primary">{label}</p>
{description && (
<p className="text-body-2-regular text-text-secondary">{description}</p>
)}
</div>
{children}
</div>
);
}
export function SettingsValueField({
icon: Icon,
children,
muted = false,
className,
}: {
icon?: IconComponent;
children: ReactNode;
/** Secondary text color (e.g. the truncated Device ID). */
muted?: boolean;
className?: string;
}) {
return (
<div
className={cx(
"flex h-8 w-[202px] shrink-0 items-center gap-0.5 rounded-2lg bg-background-tertiary-default px-1.5",
className,
)}
>
{Icon && <Icon className="size-5 shrink-0 text-foreground-icon-secondary" aria-hidden />}
<span
className={cx(
"truncate pl-1 text-body-regular",
muted ? "text-text-secondary" : "text-text-primary",
)}
>
{children}
</span>
</div>
);
}import type { ComponentType, ReactNode } from "react";
import { cx } from "@/utils/cx";
type IconComponent = ComponentType<{
className?: string;
"aria-hidden"?: boolean | "true" | "false";
}>;
/** Grouped card — rows divide themselves with borders that respect pl-12. */
export function SettingsCard({ className, children }: { className?: string; children: ReactNode }) {
return (
<div
className={cx(
"flex w-full flex-col rounded-2xl bg-background-secondary-default pl-3",
className,
)}
>
{children}
</div>
);
}
/** Muted 13px section heading above a card ("Pull Requests", "Notifications"). */
export function SettingsSectionLabel({ className, children }: { className?: string; children: ReactNode }) {
return (
<p className={cx("w-full px-3 text-body-2-medium text-text-secondary", className)}>{children}</p>
);
}
/** One label + control row. Rows separate themselves; the last has no border. */
export function SettingsRow({
label,
description,
children,
}: {
label: string;
description?: string;
children?: ReactNode;
}) {
return (
<div
className={cx(
"flex min-h-[52px] w-full items-center justify-between gap-4 py-2.5 pr-2.5",
"border-b border-separator-border last:border-b-0",
)}
>
<div className="flex min-w-0 flex-col">
<p className="text-body-regular text-text-primary">{label}</p>
{description && (
<p className="text-body-2-regular text-text-secondary">{description}</p>
)}
</div>
{children}
</div>
);
}
export function SettingsValueField({
icon: Icon,
children,
muted = false,
className,
}: {
icon?: IconComponent;
children: ReactNode;
/** Secondary text color (e.g. the truncated Device ID). */
muted?: boolean;
className?: string;
}) {
return (
<div
className={cx(
"flex h-8 w-[202px] shrink-0 items-center gap-0.5 rounded-2lg bg-background-tertiary-default px-1.5",
className,
)}
>
{Icon && <Icon className="size-5 shrink-0 text-foreground-icon-secondary" aria-hidden />}
<span
className={cx(
"truncate pl-1 text-body-regular",
muted ? "text-text-secondary" : "text-text-primary",
)}
>
{children}
</span>
</div>
);
}"use client";
import {
useEffect,
useMemo,
useRef,
useState,
type ReactNode,
} from "react";
import {
RiDeleteBin6Line,
RiDownload2Line,
RiEditLine,
RiFileCopyLine,
RiMore2Fill,
RiSearchLine,
} from "@remixicon/react";
import { Focusable } from "react-aria-components";
import { Chip } from "@/components/base/badges/chip";
import { IconButton } from "@/components/base/buttons/icon-button";
import { Checkbox } from "@/components/base/checkbox/checkbox";
import {
Dropdown,
DropdownGroup,
DropdownItem,
DropdownPopover,
DropdownTrigger,
} from "@/components/base/dropdown/dropdown";
import { FileUpload, formatFileSize } from "@/components/base/file-upload/file-upload";
import { InputBase } from "@/components/base/input/input";
import { Pagination } from "@/components/base/pagination/pagination";
import { Select, SelectItem } from "@/components/base/select/select";
import { Tooltip, TooltipTrigger } from "@/components/base/tooltip/tooltip";
import { ChevronSortDown } from "@/components/foundations/icons/chevrons";
import { cx } from "@/utils/cx";
/* ------------------------------------------------------------------ files */
type FileKind = "document" | "spreadsheet" | "video";
const FILE_ICONS: Record<FileKind, { light: string; dark?: string }> = {
document: {
light: "/ai-chat/plugin-documents.svg",
dark: "/ai-chat/plugin-documents-dark.svg",
},
spreadsheet: { light: "/ai-chat/plugin-spreadsheets.svg" },
video: { light: "/ai-chat/plugin-videos.svg" },
};
const KIND_LABELS: Record<FileKind, string> = {
document: "Documents",
spreadsheet: "Spreadsheets",
video: "Videos",
};
interface StoredFile {
id: string;
name: string;
kind: FileKind;
/** Display label, e.g. "May 11, 2026". */
uploadedOn: string;
/** Epoch-ish ordinal used for the Modified sort (bigger = newer). */
uploadedStamp: number;
sizeLabel: string;
bytes: number;
selected?: boolean;
}
function kindForFile(name: string): FileKind {
const ext = name.split(".").pop()?.toLowerCase() ?? "";
if (ext === "xlsx") return "spreadsheet";
if (["mp4", "mov", "webm"].includes(ext)) return "video";
return "document";
}
function StoredFileIcon({ kind }: { kind: FileKind }) {
const { light, dark } = FILE_ICONS[kind];
if (dark) {
return (
<>
<img src={light} alt="" className="theme-asset-light size-6 shrink-0 object-contain" />
<img src={dark} alt="" className="theme-asset-dark size-6 shrink-0 object-contain" />
</>
);
}
return <img src={light} alt="" className="size-6 shrink-0 object-contain" />;
}
/** mulberry32 — deterministic PRNG so the mock inventory is stable across
* renders/SSR (same recipe as the customers table). */
function makeRng(seed: number) {
let a = seed;
return () => {
a |= 0;
a = (a + 0x6d2b79f5) | 0;
let t = Math.imul(a ^ (a >>> 15), 1 | a);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
const GENERATED_NAMES = [
"Invoice", "Contract", "Payroll Sheet", "Quarterly report", "Pitch deck",
"Budget plan", "Onboarding video", "Team photo", "Meeting notes", "Roadmap",
];
const MONTH_LABELS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
const STORED_FILES: StoredFile[] = (() => {
const pinned: Array<Omit<StoredFile, "id" | "uploadedStamp">> = [
{ name: "Invoice 1", kind: "document", uploadedOn: "May 11, 2026", sizeLabel: "4 MB", bytes: 4 * 1024 * 1024 },
{ name: "Payroll Sheet", kind: "spreadsheet", uploadedOn: "May 11, 2026", sizeLabel: "539 KB", bytes: 539 * 1024, selected: true },
{ name: "Welcome video", kind: "video", uploadedOn: "May 11, 2026", sizeLabel: "36 MB", bytes: 36 * 1024 * 1024 },
{ name: "Payroll Sheet", kind: "spreadsheet", uploadedOn: "May 11, 2026", sizeLabel: "539 KB", bytes: 539 * 1024, selected: true },
{ name: "Invoice 1", kind: "document", uploadedOn: "May 11, 2026", sizeLabel: "4 MB", bytes: 4 * 1024 * 1024 },
];
const rng = makeRng(26);
const pick = <T,>(arr: T[]) => arr[Math.floor(rng() * arr.length)];
const kinds: FileKind[] = ["document", "document", "spreadsheet", "video"];
const total = 1262;
return Array.from({ length: total }, (_, i): StoredFile => {
if (i < pinned.length) {
return { ...pinned[i], id: `file-${i}`, uploadedStamp: total - i };
}
const kind = pick(kinds);
const bytes = Math.floor(
kind === "video" ? (4 + rng() * 60) * 1024 * 1024 : 40 * 1024 + rng() * 7 * 1024 * 1024,
);
const month = Math.floor(rng() * 5); // Jan–May 2026, before the pinned rows
const day = 1 + Math.floor(rng() * 28);
return {
id: `file-${i}`,
name: `${pick(GENERATED_NAMES)} ${1 + Math.floor(rng() * 40)}`,
kind,
uploadedOn: `${MONTH_LABELS[month]} ${day}, 2026`,
uploadedStamp: total - i,
sizeLabel: formatFileSize(bytes),
bytes,
};
});
})();
/* ------------------------------------------------------------------- table */
type SortKey = "name" | "uploadedOn" | "bytes";
type SortState = { key: SortKey; dir: "asc" | "desc" } | null;
const PER_PAGE = 6;
function SortableHeader({
label,
sortKey,
sort,
onSort,
}: {
label: string;
sortKey: SortKey;
sort: SortState;
onSort: (key: SortKey) => void;
}) {
const active = sort?.key === sortKey;
return (
<button
type="button"
aria-label={`Sort by ${label}`}
onClick={() => onSort(sortKey)}
className="flex cursor-pointer items-center gap-0.5 rounded-sm outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-border-focus-ring"
>
<span className={cx("text-body-medium whitespace-nowrap", active ? "text-text-primary" : "text-text-tertiary")}>
{label}
</span>
<span className="flex size-6 shrink-0 items-center justify-center">
<ChevronSortDown
className={cx(
"size-6 transition-[transform,color] duration-150 ease",
active ? "text-text-secondary" : "text-text-tertiary",
active && sort?.dir === "asc" && "rotate-180",
)}
/>
</span>
</button>
);
}
function RowActionButton({ icon, label }: { icon: typeof RiDeleteBin6Line; label: string }) {
return (
<TooltipTrigger delay={200}>
<Focusable>
<IconButton icon={icon} size="small" aria-label={label} />
</Focusable>
<Tooltip size="md">{label}</Tooltip>
</TooltipTrigger>
);
}
const MORE_MENU_ACTIONS = [
{ icon: RiDownload2Line, label: "Download file" },
{ icon: RiEditLine, label: "Rename" },
{ icon: RiFileCopyLine, label: "Copy link" },
] as const;
/** The "⋮" action: tooltip on hover, contextual menu on click — same recipe
* as the customers table's more-menu (trigger styled as a small secondary
* IconButton because nesting the real one would nest <button>s). */
function RowMoreMenu({ name }: { name: string }) {
const [isOpen, setIsOpen] = useState(false);
return (
<Dropdown isOpen={isOpen} onOpenChange={setIsOpen}>
<TooltipTrigger delay={200}>
<DropdownTrigger
aria-label={`More actions for ${name}`}
className={cx(
"relative inline-flex size-8 shrink-0 items-center justify-center rounded-2lg",
"border border-border-button-default bg-background-primary-default text-foreground-icon-primary shadow-xs",
"transition-[background-color,border-color,box-shadow,color] duration-150 ease",
"hover:border-border-button-hover hover:bg-background-primary-hover",
isOpen && "border-border-button-active bg-background-primary-active",
)}
>
<RiMore2Fill className="size-4 shrink-0" aria-hidden />
</DropdownTrigger>
<Tooltip size="md">More actions</Tooltip>
</TooltipTrigger>
<DropdownPopover aria-label={`More actions for ${name}`} placement="bottom end" className="w-[200px] p-2">
<DropdownGroup>
{MORE_MENU_ACTIONS.map(({ icon: Icon, label }) => (
<DropdownItem key={label} onSelect={() => setIsOpen(false)} className="px-2 py-1.5">
<Icon className="size-[18px] shrink-0 text-foreground-icon-secondary" aria-hidden />
<span className="truncate text-body-medium whitespace-nowrap text-text-primary">{label}</span>
</DropdownItem>
))}
</DropdownGroup>
</DropdownPopover>
</Dropdown>
);
}
type RowAnimationPhase = "idle" | "entering" | "exiting";
const ROW_EXIT_DURATION_MS = 225;
/** Shared row transition shell. Entry reveals a newly uploaded row from
* beneath the header; exit plays the exact structural reverse before the
* item is removed from state. */
function AnimatedRow({
phase,
children,
}: {
phase: RowAnimationPhase;
children: ReactNode;
}) {
return (
<div
className={cx(
"grid grid-rows-[1fr]",
phase === "entering" && "animate-row-grow-in",
phase === "exiting" && "pointer-events-none animate-row-collapse-out",
)}
>
<div className="overflow-hidden">
<div
className={cx(
phase === "entering" && "animate-row-slide-in",
phase === "exiting" && "animate-row-slide-out",
)}
>
{children}
</div>
</div>
</div>
);
}
/* -------------------------------------------------------------------- page */
export function SettingsStorage() {
const [files, setFiles] = useState<StoredFile[]>(STORED_FILES);
const [selected, setSelected] = useState<Set<string>>(
() => new Set(STORED_FILES.filter((f) => f.selected).map((f) => f.id)),
);
const [page, setPage] = useState(1);
const [kindFilter, setKindFilter] = useState("all");
const [recency, setRecency] = useState<"newest" | "oldest">("newest");
const [query, setQuery] = useState("");
const [sort, setSort] = useState<SortState>(null);
// The row that just arrived from the dropzone — its table row plays the
// grow-in entrance; cleared once the animation has run so pagination
// round-trips don't replay it.
const [justAddedId, setJustAddedId] = useState<string | null>(null);
const [deletingIds, setDeletingIds] = useState<Set<string>>(() => new Set());
const justAddedTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const deleteTimers = useRef<Map<string, ReturnType<typeof setTimeout>>>(new Map());
useEffect(
() => () => {
if (justAddedTimer.current) clearTimeout(justAddedTimer.current);
for (const timer of deleteTimers.current.values()) clearTimeout(timer);
deleteTimers.current.clear();
},
[],
);
const onSort = (key: SortKey) => {
setSort((prev) => {
if (!prev || prev.key !== key) return { key, dir: "asc" };
if (prev.dir === "asc") return { key, dir: "desc" };
return null;
});
};
const filtered = useMemo(() => {
const q = query.trim().toLowerCase();
const rows = files.filter(
(f) =>
(kindFilter === "all" || f.kind === kindFilter) &&
(q === "" || f.name.toLowerCase().includes(q)),
);
if (sort) {
const { key, dir } = sort;
rows.sort((a, b) => {
const cmp =
key === "name"
? a.name.localeCompare(b.name)
: key === "bytes"
? a.bytes - b.bytes
: a.uploadedStamp - b.uploadedStamp;
return dir === "asc" ? cmp : -cmp;
});
} else {
rows.sort((a, b) =>
recency === "newest" ? b.uploadedStamp - a.uploadedStamp : a.uploadedStamp - b.uploadedStamp,
);
}
return rows;
}, [files, kindFilter, query, sort, recency]);
const totalPages = Math.max(1, Math.ceil(filtered.length / PER_PAGE));
const currentPage = Math.min(page, totalPages);
const rows = filtered.slice((currentPage - 1) * PER_PAGE, currentPage * PER_PAGE);
const selectedOnPage = rows.filter((f) => selected.has(f.id)).length;
const allOnPageSelected = rows.length > 0 && selectedOnPage === rows.length;
const someOnPageSelected = selectedOnPage > 0 && !allOnPageSelected;
const toggleRow = (id: string, isSelected: boolean) => {
setSelected((prev) => {
const next = new Set(prev);
if (isSelected) next.add(id);
else next.delete(id);
return next;
});
};
const toggleAllOnPage = (isSelected: boolean) => {
setSelected((prev) => {
const next = new Set(prev);
for (const f of rows) {
if (isSelected) next.add(f.id);
else next.delete(f.id);
}
return next;
});
};
const deleteFile = (id: string) => {
if (deleteTimers.current.has(id)) return;
setDeletingIds((prev) => {
const next = new Set(prev);
next.add(id);
return next;
});
deleteTimers.current.set(
id,
setTimeout(() => {
setFiles((prev) => prev.filter((f) => f.id !== id));
setSelected((prev) => {
if (!prev.has(id)) return prev;
const next = new Set(prev);
next.delete(id);
return next;
});
setDeletingIds((prev) => {
const next = new Set(prev);
next.delete(id);
return next;
});
deleteTimers.current.delete(id);
}, ROW_EXIT_DURATION_MS),
);
};
return (
// pt-2.5 gives the percentage badge (which straddles the dropzone's top
// border, overhanging 9.5px) headroom inside the modal's scroll clip.
<div className="flex w-full flex-col gap-6 pt-2.5">
<FileUpload
renderFileIcon={(file) => <StoredFileIcon kind={kindForFile(file.name)} />}
onUploadComplete={(file) => {
const now = new Date();
const storedFile: StoredFile = {
id: `upload-${now.getTime()}`,
name: file.name.replace(/\.[^.]+$/, ""),
kind: kindForFile(file.name),
uploadedOn: now.toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
}),
uploadedStamp: 10_000 + now.getTime() / 1000,
sizeLabel: formatFileSize(file.size),
bytes: file.size,
};
setFiles((prev) => [storedFile, ...prev]);
setPage(1);
setJustAddedId(storedFile.id);
if (justAddedTimer.current) clearTimeout(justAddedTimer.current);
justAddedTimer.current = setTimeout(() => setJustAddedId(null), 700);
}}
/>
<section className="flex w-full flex-col rounded-2xl border border-border-table pt-2 pb-3">
{/* Toolbar */}
<div className="flex w-full flex-col items-start gap-3 px-3 py-1 sm:flex-row sm:items-center sm:justify-between">
<div className="flex flex-col justify-center">
<p className="text-body-medium whitespace-nowrap text-text-tertiary">Stored in</p>
<p className="text-body-medium whitespace-nowrap text-text-primary">
{filtered.length.toLocaleString()} files
</p>
</div>
<div className="flex items-center gap-2.5">
<Select
aria-label="Filter by file type"
className="shrink-0"
selectedKey={kindFilter}
onSelectionChange={(k) => {
setKindFilter(String(k));
setPage(1);
}}
>
<SelectItem id="all" textValue="File type">
File type
</SelectItem>
{(Object.keys(KIND_LABELS) as FileKind[]).map((kind) => (
<SelectItem key={kind} id={kind} textValue={KIND_LABELS[kind]}>
{KIND_LABELS[kind]}
</SelectItem>
))}
</Select>
<Select
aria-label="Order by"
className="shrink-0"
selectedKey={recency}
onSelectionChange={(k) => {
setRecency(k as "newest" | "oldest");
setSort(null);
setPage(1);
}}
>
<SelectItem id="newest" textValue="Modified">
Modified
</SelectItem>
<SelectItem id="oldest" textValue="Oldest first">
Oldest first
</SelectItem>
</Select>
<InputBase
aria-label="Search files"
placeholder="Search"
leadingIcon={RiSearchLine}
value={query}
onChange={(e) => {
setQuery(e.target.value);
setPage(1);
}}
fieldClassName="w-[153px] rounded-full bg-background-secondary-default"
className="text-body-medium"
/>
</div>
</div>
{/* Column headers */}
<div className="mt-2 flex w-full items-center border-y border-separator-border bg-background-secondary-default pl-3">
<div className="flex min-w-0 flex-1 items-center gap-2 py-2.5">
<Checkbox
isSelected={allOnPageSelected}
isIndeterminate={someOnPageSelected}
onChange={toggleAllOnPage}
aria-label="Select all files on this page"
/>
<SortableHeader label="File name" sortKey="name" sort={sort} onSort={onSort} />
</div>
<div className="flex w-[150px] shrink-0 items-center px-3 py-2.5">
<SortableHeader label="Uploaded on" sortKey="uploadedOn" sort={sort} onSort={onSort} />
</div>
<div className="flex w-[104px] shrink-0 items-center px-3 py-2.5">
<SortableHeader label="File size" sortKey="bytes" sort={sort} onSort={onSort} />
</div>
<div className="flex w-[116px] shrink-0 items-center px-3 py-2.5">
<span className="text-body-medium whitespace-nowrap text-text-tertiary">Actions</span>
</div>
</div>
{/* Rows */}
<div className="flex w-full flex-col pl-3">
{rows.length > 0 ? (
rows.map((file) => {
const phase: RowAnimationPhase = deletingIds.has(file.id)
? "exiting"
: file.id === justAddedId
? "entering"
: "idle";
return (
<AnimatedRow key={file.id} phase={phase}>
<div className="flex w-full items-center border-b border-separator-border">
<div className="flex min-w-0 flex-1 items-center gap-2 py-2.5">
<Checkbox
isSelected={selected.has(file.id)}
onChange={(isSelected) => toggleRow(file.id, isSelected)}
aria-label={`Select ${file.name}`}
/>
<div className="flex min-w-0 items-center gap-2">
<StoredFileIcon kind={file.kind} />
<span className="truncate text-body-medium text-text-primary">
{file.name}
</span>
</div>
</div>
<div className="flex w-[150px] shrink-0 items-center px-3 py-2.5">
<span className="text-body-medium whitespace-nowrap text-text-primary">
{file.uploadedOn}
</span>
</div>
<div className="flex w-[104px] shrink-0 items-center px-3 py-2.5">
<Chip variant="subtle" color="gray">
{file.sizeLabel}
</Chip>
</div>
<div className="flex w-[116px] shrink-0 items-center justify-end gap-2.5 px-3 py-2.5">
<span onClick={() => deleteFile(file.id)}>
<RowActionButton icon={RiDeleteBin6Line} label="Delete file" />
</span>
<RowMoreMenu name={file.name} />
</div>
</div>
</AnimatedRow>
);
})
) : (
<div className="flex w-full items-center justify-center py-10 pr-3">
<span className="text-body-medium text-text-tertiary">No files match your filters.</span>
</div>
)}
</div>
{/* Pagination */}
<div className="px-3 pt-3">
<Pagination page={currentPage} totalPages={totalPages} onChange={setPage} />
</div>
</section>
</div>
);
}"use client";
import {
useEffect,
useMemo,
useRef,
useState,
type ReactNode,
} from "react";
import {
RiDeleteBin6Line,
RiDownload2Line,
RiEditLine,
RiFileCopyLine,
RiMore2Fill,
RiSearchLine,
} from "@remixicon/react";
import { Focusable } from "react-aria-components";
import { Chip } from "@/components/base/badges/chip";
import { IconButton } from "@/components/base/buttons/icon-button";
import { Checkbox } from "@/components/base/checkbox/checkbox";
import {
Dropdown,
DropdownGroup,
DropdownItem,
DropdownPopover,
DropdownTrigger,
} from "@/components/base/dropdown/dropdown";
import { FileUpload, formatFileSize } from "@/components/base/file-upload/file-upload";
import { InputBase } from "@/components/base/input/input";
import { Pagination } from "@/components/base/pagination/pagination";
import { Select, SelectItem } from "@/components/base/select/select";
import { Tooltip, TooltipTrigger } from "@/components/base/tooltip/tooltip";
import { ChevronSortDown } from "@/components/foundations/icons/chevrons";
import { cx } from "@/utils/cx";
/* ------------------------------------------------------------------ files */
type FileKind = "document" | "spreadsheet" | "video";
const FILE_ICONS: Record<FileKind, { light: string; dark?: string }> = {
document: {
light: "/ai-chat/plugin-documents.svg",
dark: "/ai-chat/plugin-documents-dark.svg",
},
spreadsheet: { light: "/ai-chat/plugin-spreadsheets.svg" },
video: { light: "/ai-chat/plugin-videos.svg" },
};
const KIND_LABELS: Record<FileKind, string> = {
document: "Documents",
spreadsheet: "Spreadsheets",
video: "Videos",
};
interface StoredFile {
id: string;
name: string;
kind: FileKind;
/** Display label, e.g. "May 11, 2026". */
uploadedOn: string;
/** Epoch-ish ordinal used for the Modified sort (bigger = newer). */
uploadedStamp: number;
sizeLabel: string;
bytes: number;
selected?: boolean;
}
function kindForFile(name: string): FileKind {
const ext = name.split(".").pop()?.toLowerCase() ?? "";
if (ext === "xlsx") return "spreadsheet";
if (["mp4", "mov", "webm"].includes(ext)) return "video";
return "document";
}
function StoredFileIcon({ kind }: { kind: FileKind }) {
const { light, dark } = FILE_ICONS[kind];
if (dark) {
return (
<>
<img src={light} alt="" className="theme-asset-light size-6 shrink-0 object-contain" />
<img src={dark} alt="" className="theme-asset-dark size-6 shrink-0 object-contain" />
</>
);
}
return <img src={light} alt="" className="size-6 shrink-0 object-contain" />;
}
/** mulberry32 — deterministic PRNG so the mock inventory is stable across
* renders/SSR (same recipe as the customers table). */
function makeRng(seed: number) {
let a = seed;
return () => {
a |= 0;
a = (a + 0x6d2b79f5) | 0;
let t = Math.imul(a ^ (a >>> 15), 1 | a);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
const GENERATED_NAMES = [
"Invoice", "Contract", "Payroll Sheet", "Quarterly report", "Pitch deck",
"Budget plan", "Onboarding video", "Team photo", "Meeting notes", "Roadmap",
];
const MONTH_LABELS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
const STORED_FILES: StoredFile[] = (() => {
const pinned: Array<Omit<StoredFile, "id" | "uploadedStamp">> = [
{ name: "Invoice 1", kind: "document", uploadedOn: "May 11, 2026", sizeLabel: "4 MB", bytes: 4 * 1024 * 1024 },
{ name: "Payroll Sheet", kind: "spreadsheet", uploadedOn: "May 11, 2026", sizeLabel: "539 KB", bytes: 539 * 1024, selected: true },
{ name: "Welcome video", kind: "video", uploadedOn: "May 11, 2026", sizeLabel: "36 MB", bytes: 36 * 1024 * 1024 },
{ name: "Payroll Sheet", kind: "spreadsheet", uploadedOn: "May 11, 2026", sizeLabel: "539 KB", bytes: 539 * 1024, selected: true },
{ name: "Invoice 1", kind: "document", uploadedOn: "May 11, 2026", sizeLabel: "4 MB", bytes: 4 * 1024 * 1024 },
];
const rng = makeRng(26);
const pick = <T,>(arr: T[]) => arr[Math.floor(rng() * arr.length)];
const kinds: FileKind[] = ["document", "document", "spreadsheet", "video"];
const total = 1262;
return Array.from({ length: total }, (_, i): StoredFile => {
if (i < pinned.length) {
return { ...pinned[i], id: `file-${i}`, uploadedStamp: total - i };
}
const kind = pick(kinds);
const bytes = Math.floor(
kind === "video" ? (4 + rng() * 60) * 1024 * 1024 : 40 * 1024 + rng() * 7 * 1024 * 1024,
);
const month = Math.floor(rng() * 5); // Jan–May 2026, before the pinned rows
const day = 1 + Math.floor(rng() * 28);
return {
id: `file-${i}`,
name: `${pick(GENERATED_NAMES)} ${1 + Math.floor(rng() * 40)}`,
kind,
uploadedOn: `${MONTH_LABELS[month]} ${day}, 2026`,
uploadedStamp: total - i,
sizeLabel: formatFileSize(bytes),
bytes,
};
});
})();
/* ------------------------------------------------------------------- table */
type SortKey = "name" | "uploadedOn" | "bytes";
type SortState = { key: SortKey; dir: "asc" | "desc" } | null;
const PER_PAGE = 6;
function SortableHeader({
label,
sortKey,
sort,
onSort,
}: {
label: string;
sortKey: SortKey;
sort: SortState;
onSort: (key: SortKey) => void;
}) {
const active = sort?.key === sortKey;
return (
<button
type="button"
aria-label={`Sort by ${label}`}
onClick={() => onSort(sortKey)}
className="flex cursor-pointer items-center gap-0.5 rounded-sm outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-border-focus-ring"
>
<span className={cx("text-body-medium whitespace-nowrap", active ? "text-text-primary" : "text-text-tertiary")}>
{label}
</span>
<span className="flex size-6 shrink-0 items-center justify-center">
<ChevronSortDown
className={cx(
"size-6 transition-[transform,color] duration-150 ease",
active ? "text-text-secondary" : "text-text-tertiary",
active && sort?.dir === "asc" && "rotate-180",
)}
/>
</span>
</button>
);
}
function RowActionButton({ icon, label }: { icon: typeof RiDeleteBin6Line; label: string }) {
return (
<TooltipTrigger delay={200}>
<Focusable>
<IconButton icon={icon} size="small" aria-label={label} />
</Focusable>
<Tooltip size="md">{label}</Tooltip>
</TooltipTrigger>
);
}
const MORE_MENU_ACTIONS = [
{ icon: RiDownload2Line, label: "Download file" },
{ icon: RiEditLine, label: "Rename" },
{ icon: RiFileCopyLine, label: "Copy link" },
] as const;
/** The "⋮" action: tooltip on hover, contextual menu on click — same recipe
* as the customers table's more-menu (trigger styled as a small secondary
* IconButton because nesting the real one would nest <button>s). */
function RowMoreMenu({ name }: { name: string }) {
const [isOpen, setIsOpen] = useState(false);
return (
<Dropdown isOpen={isOpen} onOpenChange={setIsOpen}>
<TooltipTrigger delay={200}>
<DropdownTrigger
aria-label={`More actions for ${name}`}
className={cx(
"relative inline-flex size-8 shrink-0 items-center justify-center rounded-2lg",
"border border-border-button-default bg-background-primary-default text-foreground-icon-primary shadow-xs",
"transition-[background-color,border-color,box-shadow,color] duration-150 ease",
"hover:border-border-button-hover hover:bg-background-primary-hover",
isOpen && "border-border-button-active bg-background-primary-active",
)}
>
<RiMore2Fill className="size-4 shrink-0" aria-hidden />
</DropdownTrigger>
<Tooltip size="md">More actions</Tooltip>
</TooltipTrigger>
<DropdownPopover aria-label={`More actions for ${name}`} placement="bottom end" className="w-[200px] p-2">
<DropdownGroup>
{MORE_MENU_ACTIONS.map(({ icon: Icon, label }) => (
<DropdownItem key={label} onSelect={() => setIsOpen(false)} className="px-2 py-1.5">
<Icon className="size-[18px] shrink-0 text-foreground-icon-secondary" aria-hidden />
<span className="truncate text-body-medium whitespace-nowrap text-text-primary">{label}</span>
</DropdownItem>
))}
</DropdownGroup>
</DropdownPopover>
</Dropdown>
);
}
type RowAnimationPhase = "idle" | "entering" | "exiting";
const ROW_EXIT_DURATION_MS = 225;
/** Shared row transition shell. Entry reveals a newly uploaded row from
* beneath the header; exit plays the exact structural reverse before the
* item is removed from state. */
function AnimatedRow({
phase,
children,
}: {
phase: RowAnimationPhase;
children: ReactNode;
}) {
return (
<div
className={cx(
"grid grid-rows-[1fr]",
phase === "entering" && "animate-row-grow-in",
phase === "exiting" && "pointer-events-none animate-row-collapse-out",
)}
>
<div className="overflow-hidden">
<div
className={cx(
phase === "entering" && "animate-row-slide-in",
phase === "exiting" && "animate-row-slide-out",
)}
>
{children}
</div>
</div>
</div>
);
}
/* -------------------------------------------------------------------- page */
export function SettingsStorage() {
const [files, setFiles] = useState<StoredFile[]>(STORED_FILES);
const [selected, setSelected] = useState<Set<string>>(
() => new Set(STORED_FILES.filter((f) => f.selected).map((f) => f.id)),
);
const [page, setPage] = useState(1);
const [kindFilter, setKindFilter] = useState("all");
const [recency, setRecency] = useState<"newest" | "oldest">("newest");
const [query, setQuery] = useState("");
const [sort, setSort] = useState<SortState>(null);
// The row that just arrived from the dropzone — its table row plays the
// grow-in entrance; cleared once the animation has run so pagination
// round-trips don't replay it.
const [justAddedId, setJustAddedId] = useState<string | null>(null);
const [deletingIds, setDeletingIds] = useState<Set<string>>(() => new Set());
const justAddedTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const deleteTimers = useRef<Map<string, ReturnType<typeof setTimeout>>>(new Map());
useEffect(
() => () => {
if (justAddedTimer.current) clearTimeout(justAddedTimer.current);
for (const timer of deleteTimers.current.values()) clearTimeout(timer);
deleteTimers.current.clear();
},
[],
);
const onSort = (key: SortKey) => {
setSort((prev) => {
if (!prev || prev.key !== key) return { key, dir: "asc" };
if (prev.dir === "asc") return { key, dir: "desc" };
return null;
});
};
const filtered = useMemo(() => {
const q = query.trim().toLowerCase();
const rows = files.filter(
(f) =>
(kindFilter === "all" || f.kind === kindFilter) &&
(q === "" || f.name.toLowerCase().includes(q)),
);
if (sort) {
const { key, dir } = sort;
rows.sort((a, b) => {
const cmp =
key === "name"
? a.name.localeCompare(b.name)
: key === "bytes"
? a.bytes - b.bytes
: a.uploadedStamp - b.uploadedStamp;
return dir === "asc" ? cmp : -cmp;
});
} else {
rows.sort((a, b) =>
recency === "newest" ? b.uploadedStamp - a.uploadedStamp : a.uploadedStamp - b.uploadedStamp,
);
}
return rows;
}, [files, kindFilter, query, sort, recency]);
const totalPages = Math.max(1, Math.ceil(filtered.length / PER_PAGE));
const currentPage = Math.min(page, totalPages);
const rows = filtered.slice((currentPage - 1) * PER_PAGE, currentPage * PER_PAGE);
const selectedOnPage = rows.filter((f) => selected.has(f.id)).length;
const allOnPageSelected = rows.length > 0 && selectedOnPage === rows.length;
const someOnPageSelected = selectedOnPage > 0 && !allOnPageSelected;
const toggleRow = (id: string, isSelected: boolean) => {
setSelected((prev) => {
const next = new Set(prev);
if (isSelected) next.add(id);
else next.delete(id);
return next;
});
};
const toggleAllOnPage = (isSelected: boolean) => {
setSelected((prev) => {
const next = new Set(prev);
for (const f of rows) {
if (isSelected) next.add(f.id);
else next.delete(f.id);
}
return next;
});
};
const deleteFile = (id: string) => {
if (deleteTimers.current.has(id)) return;
setDeletingIds((prev) => {
const next = new Set(prev);
next.add(id);
return next;
});
deleteTimers.current.set(
id,
setTimeout(() => {
setFiles((prev) => prev.filter((f) => f.id !== id));
setSelected((prev) => {
if (!prev.has(id)) return prev;
const next = new Set(prev);
next.delete(id);
return next;
});
setDeletingIds((prev) => {
const next = new Set(prev);
next.delete(id);
return next;
});
deleteTimers.current.delete(id);
}, ROW_EXIT_DURATION_MS),
);
};
return (
// pt-2.5 gives the percentage badge (which straddles the dropzone's top
// border, overhanging 9.5px) headroom inside the modal's scroll clip.
<div className="flex w-full flex-col gap-6 pt-2.5">
<FileUpload
renderFileIcon={(file) => <StoredFileIcon kind={kindForFile(file.name)} />}
onUploadComplete={(file) => {
const now = new Date();
const storedFile: StoredFile = {
id: `upload-${now.getTime()}`,
name: file.name.replace(/\.[^.]+$/, ""),
kind: kindForFile(file.name),
uploadedOn: now.toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
}),
uploadedStamp: 10_000 + now.getTime() / 1000,
sizeLabel: formatFileSize(file.size),
bytes: file.size,
};
setFiles((prev) => [storedFile, ...prev]);
setPage(1);
setJustAddedId(storedFile.id);
if (justAddedTimer.current) clearTimeout(justAddedTimer.current);
justAddedTimer.current = setTimeout(() => setJustAddedId(null), 700);
}}
/>
<section className="flex w-full flex-col rounded-2xl border border-border-table pt-2 pb-3">
{/* Toolbar */}
<div className="flex w-full flex-col items-start gap-3 px-3 py-1 sm:flex-row sm:items-center sm:justify-between">
<div className="flex flex-col justify-center">
<p className="text-body-medium whitespace-nowrap text-text-tertiary">Stored in</p>
<p className="text-body-medium whitespace-nowrap text-text-primary">
{filtered.length.toLocaleString()} files
</p>
</div>
<div className="flex items-center gap-2.5">
<Select
aria-label="Filter by file type"
className="shrink-0"
selectedKey={kindFilter}
onSelectionChange={(k) => {
setKindFilter(String(k));
setPage(1);
}}
>
<SelectItem id="all" textValue="File type">
File type
</SelectItem>
{(Object.keys(KIND_LABELS) as FileKind[]).map((kind) => (
<SelectItem key={kind} id={kind} textValue={KIND_LABELS[kind]}>
{KIND_LABELS[kind]}
</SelectItem>
))}
</Select>
<Select
aria-label="Order by"
className="shrink-0"
selectedKey={recency}
onSelectionChange={(k) => {
setRecency(k as "newest" | "oldest");
setSort(null);
setPage(1);
}}
>
<SelectItem id="newest" textValue="Modified">
Modified
</SelectItem>
<SelectItem id="oldest" textValue="Oldest first">
Oldest first
</SelectItem>
</Select>
<InputBase
aria-label="Search files"
placeholder="Search"
leadingIcon={RiSearchLine}
value={query}
onChange={(e) => {
setQuery(e.target.value);
setPage(1);
}}
fieldClassName="w-[153px] rounded-full bg-background-secondary-default"
className="text-body-medium"
/>
</div>
</div>
{/* Column headers */}
<div className="mt-2 flex w-full items-center border-y border-separator-border bg-background-secondary-default pl-3">
<div className="flex min-w-0 flex-1 items-center gap-2 py-2.5">
<Checkbox
isSelected={allOnPageSelected}
isIndeterminate={someOnPageSelected}
onChange={toggleAllOnPage}
aria-label="Select all files on this page"
/>
<SortableHeader label="File name" sortKey="name" sort={sort} onSort={onSort} />
</div>
<div className="flex w-[150px] shrink-0 items-center px-3 py-2.5">
<SortableHeader label="Uploaded on" sortKey="uploadedOn" sort={sort} onSort={onSort} />
</div>
<div className="flex w-[104px] shrink-0 items-center px-3 py-2.5">
<SortableHeader label="File size" sortKey="bytes" sort={sort} onSort={onSort} />
</div>
<div className="flex w-[116px] shrink-0 items-center px-3 py-2.5">
<span className="text-body-medium whitespace-nowrap text-text-tertiary">Actions</span>
</div>
</div>
{/* Rows */}
<div className="flex w-full flex-col pl-3">
{rows.length > 0 ? (
rows.map((file) => {
const phase: RowAnimationPhase = deletingIds.has(file.id)
? "exiting"
: file.id === justAddedId
? "entering"
: "idle";
return (
<AnimatedRow key={file.id} phase={phase}>
<div className="flex w-full items-center border-b border-separator-border">
<div className="flex min-w-0 flex-1 items-center gap-2 py-2.5">
<Checkbox
isSelected={selected.has(file.id)}
onChange={(isSelected) => toggleRow(file.id, isSelected)}
aria-label={`Select ${file.name}`}
/>
<div className="flex min-w-0 items-center gap-2">
<StoredFileIcon kind={file.kind} />
<span className="truncate text-body-medium text-text-primary">
{file.name}
</span>
</div>
</div>
<div className="flex w-[150px] shrink-0 items-center px-3 py-2.5">
<span className="text-body-medium whitespace-nowrap text-text-primary">
{file.uploadedOn}
</span>
</div>
<div className="flex w-[104px] shrink-0 items-center px-3 py-2.5">
<Chip variant="subtle" color="gray">
{file.sizeLabel}
</Chip>
</div>
<div className="flex w-[116px] shrink-0 items-center justify-end gap-2.5 px-3 py-2.5">
<span onClick={() => deleteFile(file.id)}>
<RowActionButton icon={RiDeleteBin6Line} label="Delete file" />
</span>
<RowMoreMenu name={file.name} />
</div>
</div>
</AnimatedRow>
);
})
) : (
<div className="flex w-full items-center justify-center py-10 pr-3">
<span className="text-body-medium text-text-tertiary">No files match your filters.</span>
</div>
)}
</div>
{/* Pagination */}
<div className="px-3 pt-3">
<Pagination page={currentPage} totalPages={totalPages} onChange={setPage} />
</div>
</section>
</div>
);
}"use client";
import { useState, type ReactNode } from "react";
import { RiAddLine, RiMoreFill } from "@remixicon/react";
import { Chip } from "@/components/base/badges/chip";
import { Button } from "@/components/base/buttons/button";
import { PillTab, PillTabList } from "@/components/base/tabs/pill-tab";
import {
Dropdown,
DropdownGroup,
DropdownItem,
DropdownPopover,
DropdownTrigger,
} from "@/components/base/dropdown/dropdown";
import { Switch } from "@/components/base/switch/switch";
import { ChevronUpDownSmall } from "@/components/foundations/icons/chevrons";
import { cx } from "@/utils/cx";
import { SettingsCard, SettingsRow, SettingsSectionLabel } from "./settings-rows";
/* ------------------------------------------------------------------- data */
type ServerStatus = "connected" | "error";
interface McpServer {
id: string;
name: string;
/** Letter tile — bg/text pair from the swatch palette. */
initial: string;
tileClass: string;
status: ServerStatus;
/** e.g. "26 tools, 1 prompts, 104 resources enabled" */
summary?: string;
/** Names revealed by the expander chevron. */
tools?: string[];
}
const SERVERS: Record<string, McpServer> = {
astro: {
id: "astro",
name: "astro",
initial: "A",
tileClass: "bg-background-tertiary-default text-text-secondary",
status: "error",
},
figma: {
id: "figma",
name: "Figma",
initial: "F",
tileClass: "bg-pink-200 text-pink-700",
status: "connected",
summary: "26 tools, 1 prompts, 104 resources enabled",
tools: ["get_design_context", "get_metadata", "get_screenshot", "get_variable_defs", "create_new_file"],
},
paper: {
id: "paper",
name: "paper",
initial: "P",
tileClass: "bg-blue-200 text-blue-700",
status: "error",
},
posthog: {
id: "posthog",
name: "posthog",
initial: "P",
tileClass: "bg-amber-200 text-amber-700",
status: "connected",
summary: "521 tools, 173 resources enabled",
tools: ["query_insights", "list_dashboards", "capture_event", "feature_flags", "session_recordings"],
},
vercel: {
id: "vercel",
name: "vercel",
initial: "V",
tileClass: "bg-neutral-950 text-white",
status: "connected",
summary: "30 tools, 13 prompts enabled",
tools: ["list_deployments", "get_build_logs", "promote_deployment", "env_variables"],
},
};
/** Per-scope server lists — switching a pill swaps the section below. */
const SCOPES: { id: string; label: string; servers: McpServer[] }[] = [
{ id: "home", label: "Home", servers: [SERVERS.astro, SERVERS.figma] },
{ id: "boardcn", label: "boardcn", servers: [SERVERS.figma, SERVERS.vercel] },
{ id: "iospoke", label: "iospoke", servers: [SERVERS.astro] },
{ id: "mideo", label: "mideo", servers: [SERVERS.posthog] },
{ id: "bereal", label: "BeReal Task", servers: [SERVERS.figma] },
{ id: "poke", label: "poke-1", servers: [] },
{ id: "cloud", label: "Cloud", servers: [SERVERS.vercel, SERVERS.posthog] },
];
const PLUGIN_SERVERS: McpServer[] = [SERVERS.paper, SERVERS.posthog, SERVERS.vercel];
/* ----------------------------------------------------------------- pieces */
/** 32px rounded letter tile with a connection dot pinned to its corner,
* ringed in the card background so it reads as punched-through. */
function ServerTile({ server }: { server: McpServer }) {
return (
<span className="relative flex size-8 shrink-0 items-center justify-center rounded-lg">
<span
className={cx(
"flex size-8 items-center justify-center rounded-lg text-body-2-medium",
server.tileClass,
)}
>
{server.initial}
</span>
<span
aria-hidden
className={cx(
"absolute -bottom-0.5 -left-0.5 size-2.5 rounded-full ring-2 ring-background-secondary-default",
server.status === "connected" ? "bg-green-500" : "bg-red-600",
)}
/>
</span>
);
}
/** Bare "…" row action — quieter than IconButton on the grey card, same
* hover language as the sidebar rows. Opens the shared dropdown menu. */
function ServerMenu({ name }: { name: string }) {
const [isOpen, setIsOpen] = useState(false);
return (
<Dropdown isOpen={isOpen} onOpenChange={setIsOpen}>
<DropdownTrigger
aria-label={`Actions for ${name}`}
className={cx(
"flex size-7 shrink-0 cursor-pointer items-center justify-center rounded-md",
"text-foreground-icon-secondary outline-none transition-colors duration-150 ease",
"hover:bg-background-secondary-hover focus-visible:ring-2 focus-visible:ring-border-focus-ring",
isOpen && "bg-background-secondary-hover",
)}
>
<RiMoreFill className="size-4 shrink-0" aria-hidden />
</DropdownTrigger>
<DropdownPopover aria-label={`Actions for ${name}`} placement="bottom end" className="w-[180px] p-2">
<DropdownGroup>
{["Show output", "Refresh tools", "Remove server"].map((label) => (
<DropdownItem key={label} onSelect={() => setIsOpen(false)} className="px-2 py-1.5">
<span className="truncate text-body-medium whitespace-nowrap text-text-primary">{label}</span>
</DropdownItem>
))}
</DropdownGroup>
</DropdownPopover>
</Dropdown>
);
}
/** Quiet inline text action ("Logout", "Show Output"). */
function InlineAction({ children, onClick }: { children: ReactNode; onClick?: () => void }) {
return (
<button
type="button"
onClick={onClick}
className={cx(
"cursor-pointer rounded-sm text-body-2-regular whitespace-nowrap text-text-tertiary",
"outline-none transition-colors duration-150 ease",
"hover:text-text-secondary focus-visible:ring-2 focus-visible:ring-border-focus-ring",
)}
>
{children}
</button>
);
}
function ServerRow({ server }: { server: McpServer }) {
const [expanded, setExpanded] = useState(false);
return (
<div className="border-b border-separator-border py-2.5 pr-2.5 last:border-b-0">
<div className="flex w-full items-center gap-2.5">
<ServerTile server={server} />
<div className="flex min-w-0 flex-1 flex-col">
<div className="flex items-baseline gap-2">
<p className="text-body-medium whitespace-nowrap text-text-primary">{server.name}</p>
<InlineAction>Logout</InlineAction>
</div>
{server.status === "connected" ? (
<div className="flex items-center gap-1">
<p className="truncate text-body-2-regular text-text-secondary">{server.summary}</p>
{server.tools && (
<button
type="button"
aria-label={`${expanded ? "Hide" : "Show"} ${server.name} tools`}
aria-expanded={expanded}
onClick={() => setExpanded((v) => !v)}
className={cx(
"flex size-4 shrink-0 cursor-pointer items-center justify-center rounded-xs",
"text-foreground-icon-tertiary outline-none transition-colors duration-150 ease",
"hover:text-foreground-icon-secondary focus-visible:ring-2 focus-visible:ring-border-focus-ring",
)}
>
<ChevronUpDownSmall className="size-4" aria-hidden />
</button>
)}
</div>
) : (
<div className="flex items-center gap-1">
<p className="text-body-2-regular whitespace-nowrap text-text-secondary">Error</p>
<span className="text-body-2-regular text-text-tertiary">–</span>
<InlineAction>Show Output</InlineAction>
</div>
)}
</div>
<ServerMenu name={server.name} />
</div>
{/* Expandable tool chips — same grid-rows grow transition as the
Storage table's new-row entrance, but as a two-way toggle. */}
{server.tools && (
<div
className={cx(
"grid transition-[grid-template-rows,opacity] duration-300 ease-out",
expanded ? "grid-rows-[1fr] opacity-100" : "grid-rows-[0fr] opacity-0",
)}
>
<div className="overflow-hidden">
<div className="flex flex-wrap gap-1.5 pt-2 pl-[42px]">
{server.tools.map((tool) => (
<Chip key={tool} variant="caption" color="soft">
{tool}
</Chip>
))}
</div>
</div>
</div>
)}
</div>
);
}
function NewServerRow() {
return (
<button
type="button"
className={cx(
"group flex w-full cursor-pointer items-center gap-2.5 py-2.5 pr-2.5 text-left",
"outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-border-focus-ring",
)}
>
<span
className={cx(
"flex size-8 shrink-0 items-center justify-center rounded-lg bg-background-tertiary-default",
"transition-colors duration-150 ease group-hover:bg-background-tertiary-hover",
)}
>
<RiAddLine className="size-5 shrink-0 text-foreground-icon-secondary" aria-hidden />
</span>
<span className="flex min-w-0 flex-col">
<span className="text-body-medium whitespace-nowrap text-text-primary">New MCP Server</span>
<span className="text-body-2-regular whitespace-nowrap text-text-secondary">
Add a Custom MCP Server
</span>
</span>
</button>
);
}
/* ------------------------------------------------------------------- page */
export function SettingsTools() {
const [scopeId, setScopeId] = useState("home");
const [waitForAuth, setWaitForAuth] = useState(true);
const scope = SCOPES.find((s) => s.id === scopeId) ?? SCOPES[0];
return (
<div className="flex w-full flex-col gap-6">
{/* Scope switcher — the shared fully-rounded pill tabs (same component
as the AI chat panel's Changes/Browser switcher). */}
<PillTabList
aria-label="Project scope"
className="-mx-1 flex w-[calc(100%+8px)] overflow-x-auto px-1 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
>
{SCOPES.map((s) => (
<PillTab key={s.id} variant="gray" isSelected={s.id === scopeId} onSelect={() => setScopeId(s.id)}>
{s.label}
</PillTab>
))}
</PillTabList>
{/* Authentication */}
<div className="flex w-full flex-col gap-2">
<SettingsSectionLabel className="px-2">Authentication</SettingsSectionLabel>
<SettingsCard>
<SettingsRow
label="Wait for MCP Authentication"
description="Wait indefinitely to authenticate when prompted. When off, skip authentication prompts after 30 seconds."
>
<Switch isSelected={waitForAuth} onChange={setWaitForAuth} aria-label="Wait for MCP authentication" />
</SettingsRow>
</SettingsCard>
</div>
{/* Scope servers */}
<div className="flex w-full flex-col gap-2">
<div className="flex w-full flex-col gap-0.5">
<SettingsSectionLabel className="px-2">{scope.label} MCP Servers</SettingsSectionLabel>
<p className="w-full px-2 text-body-2-regular text-text-tertiary">
Servers available from {scope.label}.
</p>
</div>
<SettingsCard>
{scope.servers.map((server) => (
<ServerRow key={server.id} server={server} />
))}
<NewServerRow />
</SettingsCard>
</div>
{/* Team servers — empty state */}
<div className="flex w-full flex-col gap-2">
<div className="flex w-full items-end justify-between gap-3">
<div className="flex min-w-0 flex-col gap-0.5">
<SettingsSectionLabel className="px-2">Team MCP Servers</SettingsSectionLabel>
<p className="w-full px-2 text-body-2-regular text-text-tertiary">
Configured in the dashboard
</p>
</div>
<Button variant="secondary" size="small" className="shrink-0">
Manage
</Button>
</div>
<div className="flex w-full flex-col items-center gap-3 rounded-2xl bg-background-secondary-default px-6 py-8">
<div className="flex flex-col items-center gap-1 text-center">
<p className="text-body-medium text-text-primary">No Team MCP Servers</p>
<p className="max-w-[360px] text-body-2-regular text-text-secondary">
Configure MCP servers in the dashboard to make them available in BoardCN on desktop
and in the cloud.
</p>
</div>
<Button variant="secondary" size="small">
Configure Team MCP Servers
</Button>
</div>
</div>
{/* Plugin servers */}
<div className="flex w-full flex-col gap-2">
<SettingsSectionLabel className="px-2">Plugin MCP Servers</SettingsSectionLabel>
<SettingsCard>
{PLUGIN_SERVERS.map((server) => (
<ServerRow key={server.id} server={server} />
))}
</SettingsCard>
</div>
</div>
);
}"use client";
import { useState, type ReactNode } from "react";
import { RiAddLine, RiMoreFill } from "@remixicon/react";
import { Chip } from "@/components/base/badges/chip";
import { Button } from "@/components/base/buttons/button";
import { PillTab, PillTabList } from "@/components/base/tabs/pill-tab";
import {
Dropdown,
DropdownGroup,
DropdownItem,
DropdownPopover,
DropdownTrigger,
} from "@/components/base/dropdown/dropdown";
import { Switch } from "@/components/base/switch/switch";
import { ChevronUpDownSmall } from "@/components/foundations/icons/chevrons";
import { cx } from "@/utils/cx";
import { SettingsCard, SettingsRow, SettingsSectionLabel } from "./settings-rows";
/* ------------------------------------------------------------------- data */
type ServerStatus = "connected" | "error";
interface McpServer {
id: string;
name: string;
/** Letter tile — bg/text pair from the swatch palette. */
initial: string;
tileClass: string;
status: ServerStatus;
/** e.g. "26 tools, 1 prompts, 104 resources enabled" */
summary?: string;
/** Names revealed by the expander chevron. */
tools?: string[];
}
const SERVERS: Record<string, McpServer> = {
astro: {
id: "astro",
name: "astro",
initial: "A",
tileClass: "bg-background-tertiary-default text-text-secondary",
status: "error",
},
figma: {
id: "figma",
name: "Figma",
initial: "F",
tileClass: "bg-pink-200 text-pink-700",
status: "connected",
summary: "26 tools, 1 prompts, 104 resources enabled",
tools: ["get_design_context", "get_metadata", "get_screenshot", "get_variable_defs", "create_new_file"],
},
paper: {
id: "paper",
name: "paper",
initial: "P",
tileClass: "bg-blue-200 text-blue-700",
status: "error",
},
posthog: {
id: "posthog",
name: "posthog",
initial: "P",
tileClass: "bg-amber-200 text-amber-700",
status: "connected",
summary: "521 tools, 173 resources enabled",
tools: ["query_insights", "list_dashboards", "capture_event", "feature_flags", "session_recordings"],
},
vercel: {
id: "vercel",
name: "vercel",
initial: "V",
tileClass: "bg-neutral-950 text-white",
status: "connected",
summary: "30 tools, 13 prompts enabled",
tools: ["list_deployments", "get_build_logs", "promote_deployment", "env_variables"],
},
};
/** Per-scope server lists — switching a pill swaps the section below. */
const SCOPES: { id: string; label: string; servers: McpServer[] }[] = [
{ id: "home", label: "Home", servers: [SERVERS.astro, SERVERS.figma] },
{ id: "boardcn", label: "boardcn", servers: [SERVERS.figma, SERVERS.vercel] },
{ id: "iospoke", label: "iospoke", servers: [SERVERS.astro] },
{ id: "mideo", label: "mideo", servers: [SERVERS.posthog] },
{ id: "bereal", label: "BeReal Task", servers: [SERVERS.figma] },
{ id: "poke", label: "poke-1", servers: [] },
{ id: "cloud", label: "Cloud", servers: [SERVERS.vercel, SERVERS.posthog] },
];
const PLUGIN_SERVERS: McpServer[] = [SERVERS.paper, SERVERS.posthog, SERVERS.vercel];
/* ----------------------------------------------------------------- pieces */
/** 32px rounded letter tile with a connection dot pinned to its corner,
* ringed in the card background so it reads as punched-through. */
function ServerTile({ server }: { server: McpServer }) {
return (
<span className="relative flex size-8 shrink-0 items-center justify-center rounded-lg">
<span
className={cx(
"flex size-8 items-center justify-center rounded-lg text-body-2-medium",
server.tileClass,
)}
>
{server.initial}
</span>
<span
aria-hidden
className={cx(
"absolute -bottom-0.5 -left-0.5 size-2.5 rounded-full ring-2 ring-background-secondary-default",
server.status === "connected" ? "bg-green-500" : "bg-red-600",
)}
/>
</span>
);
}
/** Bare "…" row action — quieter than IconButton on the grey card, same
* hover language as the sidebar rows. Opens the shared dropdown menu. */
function ServerMenu({ name }: { name: string }) {
const [isOpen, setIsOpen] = useState(false);
return (
<Dropdown isOpen={isOpen} onOpenChange={setIsOpen}>
<DropdownTrigger
aria-label={`Actions for ${name}`}
className={cx(
"flex size-7 shrink-0 cursor-pointer items-center justify-center rounded-md",
"text-foreground-icon-secondary outline-none transition-colors duration-150 ease",
"hover:bg-background-secondary-hover focus-visible:ring-2 focus-visible:ring-border-focus-ring",
isOpen && "bg-background-secondary-hover",
)}
>
<RiMoreFill className="size-4 shrink-0" aria-hidden />
</DropdownTrigger>
<DropdownPopover aria-label={`Actions for ${name}`} placement="bottom end" className="w-[180px] p-2">
<DropdownGroup>
{["Show output", "Refresh tools", "Remove server"].map((label) => (
<DropdownItem key={label} onSelect={() => setIsOpen(false)} className="px-2 py-1.5">
<span className="truncate text-body-medium whitespace-nowrap text-text-primary">{label}</span>
</DropdownItem>
))}
</DropdownGroup>
</DropdownPopover>
</Dropdown>
);
}
/** Quiet inline text action ("Logout", "Show Output"). */
function InlineAction({ children, onClick }: { children: ReactNode; onClick?: () => void }) {
return (
<button
type="button"
onClick={onClick}
className={cx(
"cursor-pointer rounded-sm text-body-2-regular whitespace-nowrap text-text-tertiary",
"outline-none transition-colors duration-150 ease",
"hover:text-text-secondary focus-visible:ring-2 focus-visible:ring-border-focus-ring",
)}
>
{children}
</button>
);
}
function ServerRow({ server }: { server: McpServer }) {
const [expanded, setExpanded] = useState(false);
return (
<div className="border-b border-separator-border py-2.5 pr-2.5 last:border-b-0">
<div className="flex w-full items-center gap-2.5">
<ServerTile server={server} />
<div className="flex min-w-0 flex-1 flex-col">
<div className="flex items-baseline gap-2">
<p className="text-body-medium whitespace-nowrap text-text-primary">{server.name}</p>
<InlineAction>Logout</InlineAction>
</div>
{server.status === "connected" ? (
<div className="flex items-center gap-1">
<p className="truncate text-body-2-regular text-text-secondary">{server.summary}</p>
{server.tools && (
<button
type="button"
aria-label={`${expanded ? "Hide" : "Show"} ${server.name} tools`}
aria-expanded={expanded}
onClick={() => setExpanded((v) => !v)}
className={cx(
"flex size-4 shrink-0 cursor-pointer items-center justify-center rounded-xs",
"text-foreground-icon-tertiary outline-none transition-colors duration-150 ease",
"hover:text-foreground-icon-secondary focus-visible:ring-2 focus-visible:ring-border-focus-ring",
)}
>
<ChevronUpDownSmall className="size-4" aria-hidden />
</button>
)}
</div>
) : (
<div className="flex items-center gap-1">
<p className="text-body-2-regular whitespace-nowrap text-text-secondary">Error</p>
<span className="text-body-2-regular text-text-tertiary">–</span>
<InlineAction>Show Output</InlineAction>
</div>
)}
</div>
<ServerMenu name={server.name} />
</div>
{/* Expandable tool chips — same grid-rows grow transition as the
Storage table's new-row entrance, but as a two-way toggle. */}
{server.tools && (
<div
className={cx(
"grid transition-[grid-template-rows,opacity] duration-300 ease-out",
expanded ? "grid-rows-[1fr] opacity-100" : "grid-rows-[0fr] opacity-0",
)}
>
<div className="overflow-hidden">
<div className="flex flex-wrap gap-1.5 pt-2 pl-[42px]">
{server.tools.map((tool) => (
<Chip key={tool} variant="caption" color="soft">
{tool}
</Chip>
))}
</div>
</div>
</div>
)}
</div>
);
}
function NewServerRow() {
return (
<button
type="button"
className={cx(
"group flex w-full cursor-pointer items-center gap-2.5 py-2.5 pr-2.5 text-left",
"outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-border-focus-ring",
)}
>
<span
className={cx(
"flex size-8 shrink-0 items-center justify-center rounded-lg bg-background-tertiary-default",
"transition-colors duration-150 ease group-hover:bg-background-tertiary-hover",
)}
>
<RiAddLine className="size-5 shrink-0 text-foreground-icon-secondary" aria-hidden />
</span>
<span className="flex min-w-0 flex-col">
<span className="text-body-medium whitespace-nowrap text-text-primary">New MCP Server</span>
<span className="text-body-2-regular whitespace-nowrap text-text-secondary">
Add a Custom MCP Server
</span>
</span>
</button>
);
}
/* ------------------------------------------------------------------- page */
export function SettingsTools() {
const [scopeId, setScopeId] = useState("home");
const [waitForAuth, setWaitForAuth] = useState(true);
const scope = SCOPES.find((s) => s.id === scopeId) ?? SCOPES[0];
return (
<div className="flex w-full flex-col gap-6">
{/* Scope switcher — the shared fully-rounded pill tabs (same component
as the AI chat panel's Changes/Browser switcher). */}
<PillTabList
aria-label="Project scope"
className="-mx-1 flex w-[calc(100%+8px)] overflow-x-auto px-1 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
>
{SCOPES.map((s) => (
<PillTab key={s.id} variant="gray" isSelected={s.id === scopeId} onSelect={() => setScopeId(s.id)}>
{s.label}
</PillTab>
))}
</PillTabList>
{/* Authentication */}
<div className="flex w-full flex-col gap-2">
<SettingsSectionLabel className="px-2">Authentication</SettingsSectionLabel>
<SettingsCard>
<SettingsRow
label="Wait for MCP Authentication"
description="Wait indefinitely to authenticate when prompted. When off, skip authentication prompts after 30 seconds."
>
<Switch isSelected={waitForAuth} onChange={setWaitForAuth} aria-label="Wait for MCP authentication" />
</SettingsRow>
</SettingsCard>
</div>
{/* Scope servers */}
<div className="flex w-full flex-col gap-2">
<div className="flex w-full flex-col gap-0.5">
<SettingsSectionLabel className="px-2">{scope.label} MCP Servers</SettingsSectionLabel>
<p className="w-full px-2 text-body-2-regular text-text-tertiary">
Servers available from {scope.label}.
</p>
</div>
<SettingsCard>
{scope.servers.map((server) => (
<ServerRow key={server.id} server={server} />
))}
<NewServerRow />
</SettingsCard>
</div>
{/* Team servers — empty state */}
<div className="flex w-full flex-col gap-2">
<div className="flex w-full items-end justify-between gap-3">
<div className="flex min-w-0 flex-col gap-0.5">
<SettingsSectionLabel className="px-2">Team MCP Servers</SettingsSectionLabel>
<p className="w-full px-2 text-body-2-regular text-text-tertiary">
Configured in the dashboard
</p>
</div>
<Button variant="secondary" size="small" className="shrink-0">
Manage
</Button>
</div>
<div className="flex w-full flex-col items-center gap-3 rounded-2xl bg-background-secondary-default px-6 py-8">
<div className="flex flex-col items-center gap-1 text-center">
<p className="text-body-medium text-text-primary">No Team MCP Servers</p>
<p className="max-w-[360px] text-body-2-regular text-text-secondary">
Configure MCP servers in the dashboard to make them available in BoardCN on desktop
and in the cloud.
</p>
</div>
<Button variant="secondary" size="small">
Configure Team MCP Servers
</Button>
</div>
</div>
{/* Plugin servers */}
<div className="flex w-full flex-col gap-2">
<SettingsSectionLabel className="px-2">Plugin MCP Servers</SettingsSectionLabel>
<SettingsCard>
{PLUGIN_SERVERS.map((server) => (
<ServerRow key={server.id} server={server} />
))}
</SettingsCard>
</div>
</div>
);
}<svg width="24" height="28" viewBox="0 0 24 28" fill="none" xmlns="http://www.w3.org/2000/svg">
<g filter="url(#filter0_i_4198_7769)">
<path d="M4 4.97648C4 3.33261 5.33261 2 6.97648 2H14.5702C15.4041 2 16.1997 2.34981 16.7635 2.96429L17.683 3.96664L18.9319 5.01007C19.6088 5.5756 20 6.4122 20 7.29424V19.0235C20 20.6674 18.6674 22 17.0235 22H6.97648C5.33262 22 4 20.6674 4 19.0235V4.97648Z" fill="#6E6E6E"/>
</g>
<g filter="url(#filter1_dd_4198_7769)">
<path d="M9.06328 14.5944C9.37593 14.5968 9.6288 14.8497 9.63121 15.1624L9.66213 19.1778C9.66455 19.4939 9.92281 19.7522 10.2389 19.7546C10.5551 19.7571 10.8094 19.5028 10.8069 19.1866L10.7716 14.6076C10.7643 13.6593 9.98958 12.8846 9.04125 12.8772C8.09289 12.8699 7.33003 13.6328 7.33732 14.5811L7.37262 19.1602C7.38479 20.7407 8.67597 22.0319 10.2566 22.0441C11.8372 22.0563 13.1086 20.7849 13.0964 19.2043L13.0656 15.2064C13.0631 14.8869 13.3228 14.6273 13.6423 14.6297C13.955 14.6321 14.2078 14.885 14.2102 15.1976L14.2412 19.2131C14.2582 21.4259 12.4782 23.2059 10.2654 23.1889C8.05258 23.1718 6.24493 21.3642 6.22786 19.1513L6.19257 14.5723C6.18038 12.9917 7.45185 11.7203 9.03243 11.7325C10.613 11.7446 11.9042 13.0358 11.9164 14.6164L11.9517 19.1955C11.959 20.1438 11.1961 20.9067 10.2478 20.8994C9.29941 20.8921 8.52469 20.1174 8.51738 19.169L8.48656 15.1712C8.4841 14.8516 8.74375 14.592 9.06328 14.5944Z" fill="white"/>
<path d="M9.06328 14.5944C9.37593 14.5968 9.6288 14.8497 9.63121 15.1624L9.66213 19.1778C9.66455 19.4939 9.92281 19.7522 10.2389 19.7546C10.5551 19.7571 10.8094 19.5028 10.8069 19.1866L10.7716 14.6076C10.7643 13.6593 9.98958 12.8846 9.04125 12.8772C8.09289 12.8699 7.33003 13.6328 7.33732 14.5811L7.37262 19.1602C7.38479 20.7407 8.67597 22.0319 10.2566 22.0441C11.8372 22.0563 13.1086 20.7849 13.0964 19.2043L13.0656 15.2064C13.0631 14.8869 13.3228 14.6273 13.6423 14.6297C13.955 14.6321 14.2078 14.885 14.2102 15.1976L14.2412 19.2131C14.2582 21.4259 12.4782 23.2059 10.2654 23.1889C8.05258 23.1718 6.24493 21.3642 6.22786 19.1513L6.19257 14.5723C6.18038 12.9917 7.45185 11.7203 9.03243 11.7325C10.613 11.7446 11.9042 13.0358 11.9164 14.6164L11.9517 19.1955C11.959 20.1438 11.1961 20.9067 10.2478 20.8994C9.29941 20.8921 8.52469 20.1174 8.51738 19.169L8.48656 15.1712C8.4841 14.8516 8.74375 14.592 9.06328 14.5944Z" stroke="white" stroke-width="0.25"/>
</g>
<mask id="mask0_4198_7769" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="4" y="2" width="16" height="20">
<path d="M4 4.97648C4 3.33261 5.33261 2 6.97648 2H14.7553C15.4794 2 16.1786 2.26393 16.7221 2.74237L17.8182 3.70732L19.1346 5.03176C19.6889 5.58943 20 6.34376 20 7.13004V19.0235C20 20.6674 18.6674 22 17.0235 22H6.97648C5.33262 22 4 20.6674 4 19.0235V4.97648Z" fill="url(#paint0_linear_4198_7769)"/>
</mask>
<g mask="url(#mask0_4198_7769)">
<g filter="url(#filter2_ddi_4198_7769)">
<path d="M21.0276 6.26997C21.419 6.59701 21.1877 7.23438 20.6777 7.23438H16.1791C15.5763 7.23438 15.0877 6.74575 15.0877 6.143V2.41606C15.0877 1.95021 15.6338 1.69857 15.9879 2.00124L18.9894 4.56657L21.0276 6.26997Z" fill="#B2B2B2"/>
</g>
</g>
<defs>
<filter id="filter0_i_4198_7769" x="4" y="2" width="16" height="20" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset dy="-0.49608"/>
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1"/>
<feColorMatrix type="matrix" values="0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 0.52 0"/>
<feBlend mode="normal" in2="shape" result="effect1_innerShadow_4198_7769"/>
</filter>
<filter id="filter1_dd_4198_7769" x="1.06738" y="5.60742" width="18.2988" height="21.707" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset dy="0.49608"/>
<feGaussianBlur stdDeviation="0.12402"/>
<feComposite in2="hardAlpha" operator="out"/>
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.08 0"/>
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_4198_7769"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset dy="-1"/>
<feGaussianBlur stdDeviation="2.5"/>
<feComposite in2="hardAlpha" operator="out"/>
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.17 0"/>
<feBlend mode="normal" in2="effect1_dropShadow_4198_7769" result="effect2_dropShadow_4198_7769"/>
<feBlend mode="normal" in="SourceGraphic" in2="effect2_dropShadow_4198_7769" result="shape"/>
</filter>
<filter id="filter2_ddi_4198_7769" x="11.6153" y="0.380902" width="11.0975" height="10.326" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset dx="-0.24804" dy="0.24804"/>
<feGaussianBlur stdDeviation="0.12402"/>
<feComposite in2="hardAlpha" operator="out"/>
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.06 0"/>
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_4198_7769"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset dx="-0.992159" dy="0.992159"/>
<feGaussianBlur stdDeviation="1.2402"/>
<feComposite in2="hardAlpha" operator="out"/>
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.11 0"/>
<feBlend mode="normal" in2="effect1_dropShadow_4198_7769" result="effect2_dropShadow_4198_7769"/>
<feBlend mode="normal" in="SourceGraphic" in2="effect2_dropShadow_4198_7769" result="shape"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset dy="-0.545688"/>
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1"/>
<feColorMatrix type="matrix" values="0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 0.22 0"/>
<feBlend mode="normal" in2="shape" result="effect3_innerShadow_4198_7769"/>
</filter>
<linearGradient id="paint0_linear_4198_7769" x1="12" y1="2" x2="12" y2="22" gradientUnits="userSpaceOnUse">
<stop offset="0.254043" stop-color="white"/>
<stop offset="0.730769" stop-color="#FFCAFC"/>
<stop offset="1" stop-color="#8ABBFF"/>
</linearGradient>
</defs>
</svg><svg width="24" height="28" viewBox="0 0 24 28" fill="none" xmlns="http://www.w3.org/2000/svg">
<g filter="url(#filter0_i_4198_7769)">
<path d="M4 4.97648C4 3.33261 5.33261 2 6.97648 2H14.5702C15.4041 2 16.1997 2.34981 16.7635 2.96429L17.683 3.96664L18.9319 5.01007C19.6088 5.5756 20 6.4122 20 7.29424V19.0235C20 20.6674 18.6674 22 17.0235 22H6.97648C5.33262 22 4 20.6674 4 19.0235V4.97648Z" fill="#6E6E6E"/>
</g>
<g filter="url(#filter1_dd_4198_7769)">
<path d="M9.06328 14.5944C9.37593 14.5968 9.6288 14.8497 9.63121 15.1624L9.66213 19.1778C9.66455 19.4939 9.92281 19.7522 10.2389 19.7546C10.5551 19.7571 10.8094 19.5028 10.8069 19.1866L10.7716 14.6076C10.7643 13.6593 9.98958 12.8846 9.04125 12.8772C8.09289 12.8699 7.33003 13.6328 7.33732 14.5811L7.37262 19.1602C7.38479 20.7407 8.67597 22.0319 10.2566 22.0441C11.8372 22.0563 13.1086 20.7849 13.0964 19.2043L13.0656 15.2064C13.0631 14.8869 13.3228 14.6273 13.6423 14.6297C13.955 14.6321 14.2078 14.885 14.2102 15.1976L14.2412 19.2131C14.2582 21.4259 12.4782 23.2059 10.2654 23.1889C8.05258 23.1718 6.24493 21.3642 6.22786 19.1513L6.19257 14.5723C6.18038 12.9917 7.45185 11.7203 9.03243 11.7325C10.613 11.7446 11.9042 13.0358 11.9164 14.6164L11.9517 19.1955C11.959 20.1438 11.1961 20.9067 10.2478 20.8994C9.29941 20.8921 8.52469 20.1174 8.51738 19.169L8.48656 15.1712C8.4841 14.8516 8.74375 14.592 9.06328 14.5944Z" fill="white"/>
<path d="M9.06328 14.5944C9.37593 14.5968 9.6288 14.8497 9.63121 15.1624L9.66213 19.1778C9.66455 19.4939 9.92281 19.7522 10.2389 19.7546C10.5551 19.7571 10.8094 19.5028 10.8069 19.1866L10.7716 14.6076C10.7643 13.6593 9.98958 12.8846 9.04125 12.8772C8.09289 12.8699 7.33003 13.6328 7.33732 14.5811L7.37262 19.1602C7.38479 20.7407 8.67597 22.0319 10.2566 22.0441C11.8372 22.0563 13.1086 20.7849 13.0964 19.2043L13.0656 15.2064C13.0631 14.8869 13.3228 14.6273 13.6423 14.6297C13.955 14.6321 14.2078 14.885 14.2102 15.1976L14.2412 19.2131C14.2582 21.4259 12.4782 23.2059 10.2654 23.1889C8.05258 23.1718 6.24493 21.3642 6.22786 19.1513L6.19257 14.5723C6.18038 12.9917 7.45185 11.7203 9.03243 11.7325C10.613 11.7446 11.9042 13.0358 11.9164 14.6164L11.9517 19.1955C11.959 20.1438 11.1961 20.9067 10.2478 20.8994C9.29941 20.8921 8.52469 20.1174 8.51738 19.169L8.48656 15.1712C8.4841 14.8516 8.74375 14.592 9.06328 14.5944Z" stroke="white" stroke-width="0.25"/>
</g>
<mask id="mask0_4198_7769" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="4" y="2" width="16" height="20">
<path d="M4 4.97648C4 3.33261 5.33261 2 6.97648 2H14.7553C15.4794 2 16.1786 2.26393 16.7221 2.74237L17.8182 3.70732L19.1346 5.03176C19.6889 5.58943 20 6.34376 20 7.13004V19.0235C20 20.6674 18.6674 22 17.0235 22H6.97648C5.33262 22 4 20.6674 4 19.0235V4.97648Z" fill="url(#paint0_linear_4198_7769)"/>
</mask>
<g mask="url(#mask0_4198_7769)">
<g filter="url(#filter2_ddi_4198_7769)">
<path d="M21.0276 6.26997C21.419 6.59701 21.1877 7.23438 20.6777 7.23438H16.1791C15.5763 7.23438 15.0877 6.74575 15.0877 6.143V2.41606C15.0877 1.95021 15.6338 1.69857 15.9879 2.00124L18.9894 4.56657L21.0276 6.26997Z" fill="#B2B2B2"/>
</g>
</g>
<defs>
<filter id="filter0_i_4198_7769" x="4" y="2" width="16" height="20" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset dy="-0.49608"/>
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1"/>
<feColorMatrix type="matrix" values="0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 0.52 0"/>
<feBlend mode="normal" in2="shape" result="effect1_innerShadow_4198_7769"/>
</filter>
<filter id="filter1_dd_4198_7769" x="1.06738" y="5.60742" width="18.2988" height="21.707" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset dy="0.49608"/>
<feGaussianBlur stdDeviation="0.12402"/>
<feComposite in2="hardAlpha" operator="out"/>
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.08 0"/>
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_4198_7769"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset dy="-1"/>
<feGaussianBlur stdDeviation="2.5"/>
<feComposite in2="hardAlpha" operator="out"/>
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.17 0"/>
<feBlend mode="normal" in2="effect1_dropShadow_4198_7769" result="effect2_dropShadow_4198_7769"/>
<feBlend mode="normal" in="SourceGraphic" in2="effect2_dropShadow_4198_7769" result="shape"/>
</filter>
<filter id="filter2_ddi_4198_7769" x="11.6153" y="0.380902" width="11.0975" height="10.326" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset dx="-0.24804" dy="0.24804"/>
<feGaussianBlur stdDeviation="0.12402"/>
<feComposite in2="hardAlpha" operator="out"/>
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.06 0"/>
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_4198_7769"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset dx="-0.992159" dy="0.992159"/>
<feGaussianBlur stdDeviation="1.2402"/>
<feComposite in2="hardAlpha" operator="out"/>
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.11 0"/>
<feBlend mode="normal" in2="effect1_dropShadow_4198_7769" result="effect2_dropShadow_4198_7769"/>
<feBlend mode="normal" in="SourceGraphic" in2="effect2_dropShadow_4198_7769" result="shape"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset dy="-0.545688"/>
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1"/>
<feColorMatrix type="matrix" values="0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 0.22 0"/>
<feBlend mode="normal" in2="shape" result="effect3_innerShadow_4198_7769"/>
</filter>
<linearGradient id="paint0_linear_4198_7769" x1="12" y1="2" x2="12" y2="22" gradientUnits="userSpaceOnUse">
<stop offset="0.254043" stop-color="white"/>
<stop offset="0.730769" stop-color="#FFCAFC"/>
<stop offset="1" stop-color="#8ABBFF"/>
</linearGradient>
</defs>
</svg><svg width="24" height="24" viewBox="0.000 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<g id="documents_icon">
<g id="Group 25">
<g id="Rectangle 10" filter="url(#filter1_i_0_1)">
<path d="M4 4.97648C4 3.33261 5.33261 2 6.97648 2H14.5702C15.4041 2 16.1997 2.34981 16.7635 2.96429L17.683 3.96664L18.9319 5.01007C19.6088 5.5756 20 6.4122 20 7.29424V19.0235C20 20.6674 18.6674 22 17.0235 22H6.97648C5.33262 22 4 20.6674 4 19.0235V4.97648Z" fill="#C0C0C0"/>
</g>
<g id="Frame 427322585">
<g id="Vector" filter="url(#filter2_dd_0_1)">
<path d="M9.06328 14.5954C9.37593 14.5978 9.6288 14.8507 9.63121 15.1633L9.66213 19.1788C9.66455 19.4949 9.92281 19.7531 10.2389 19.7556C10.5551 19.758 10.8094 19.5037 10.8069 19.1876L10.7716 14.6086C10.7643 13.6602 9.98958 12.8855 9.04125 12.8782C8.09289 12.8709 7.33003 13.6338 7.33732 14.5821L7.37262 19.1612C7.38479 20.7417 8.67597 22.0329 10.2566 22.0451C11.8372 22.0573 13.1086 20.7859 13.0964 19.2053L13.0656 15.2074C13.0631 14.8879 13.3228 14.6282 13.6423 14.6307C13.955 14.6331 14.2078 14.8859 14.2102 15.1986L14.2412 19.2141C14.2582 21.4269 12.4782 23.2069 10.2654 23.1899C8.05258 23.1728 6.24493 21.3652 6.22786 19.1523L6.19257 14.5733C6.18038 12.9927 7.45185 11.7212 9.03243 11.7334C10.613 11.7456 11.9042 13.0368 11.9164 14.6174L11.9517 19.1965C11.959 20.1448 11.1961 20.9076 10.2478 20.9004C9.29941 20.893 8.52469 20.1183 8.51738 19.17L8.48656 15.1721C8.4841 14.8526 8.74375 14.593 9.06328 14.5954Z" fill="white"/>
<path d="M9.06328 14.5954C9.37593 14.5978 9.6288 14.8507 9.63121 15.1633L9.66213 19.1788C9.66455 19.4949 9.92281 19.7531 10.2389 19.7556C10.5551 19.758 10.8094 19.5037 10.8069 19.1876L10.7716 14.6086C10.7643 13.6602 9.98958 12.8855 9.04125 12.8782C8.09289 12.8709 7.33003 13.6338 7.33732 14.5821L7.37262 19.1612C7.38479 20.7417 8.67597 22.0329 10.2566 22.0451C11.8372 22.0573 13.1086 20.7859 13.0964 19.2053L13.0656 15.2074C13.0631 14.8879 13.3228 14.6282 13.6423 14.6307C13.955 14.6331 14.2078 14.8859 14.2102 15.1986L14.2412 19.2141C14.2582 21.4269 12.4782 23.2069 10.2654 23.1899C8.05258 23.1728 6.24493 21.3652 6.22786 19.1523L6.19257 14.5733C6.18038 12.9927 7.45185 11.7212 9.03243 11.7334C10.613 11.7456 11.9042 13.0368 11.9164 14.6174L11.9517 19.1965C11.959 20.1448 11.1961 20.9076 10.2478 20.9004C9.29941 20.893 8.52469 20.1183 8.51738 19.17L8.48656 15.1721C8.4841 14.8526 8.74375 14.593 9.06328 14.5954Z" stroke="white" stroke-width="0.25"/>
</g>
</g>
<g id="Mask group">
<mask id="mask0_0_1" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="4" y="2" width="16" height="20">
<path id="Rectangle 13" d="M4 4.97648C4 3.33262 5.33261 2 6.97648 2H14.7553C15.4794 2 16.1786 2.26393 16.7221 2.74237L17.8182 3.70732L19.1346 5.03176C19.6889 5.58943 20 6.34376 20 7.13004V19.0235C20 20.6674 18.6674 22 17.0235 22H6.97648C5.33262 22 4 20.6674 4 19.0235V4.97648Z" fill="url(#paint0_linear_0_1)"/>
</mask>
<g mask="url(#mask0_0_1)">
<g id="Rectangle 12" filter="url(#filter3_ddi_0_1)">
<path d="M21.0276 6.26899C21.419 6.59603 21.1877 7.2334 20.6777 7.2334H16.1791C15.5763 7.2334 15.0877 6.74477 15.0877 6.14202V2.41508C15.0877 1.94923 15.6338 1.69759 15.9879 2.00026L18.9894 4.56559L21.0276 6.26899Z" fill="#EFEFEF"/>
</g>
</g>
</g>
</g>
</g>
<defs>
<filter id="filter0_dd_0_1" x="-24" y="-190" width="377" height="360" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset dy="4"/>
<feGaussianBlur stdDeviation="4"/>
<feComposite in2="hardAlpha" operator="out"/>
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.02 0"/>
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_0_1"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset dy="1"/>
<feGaussianBlur stdDeviation="1"/>
<feComposite in2="hardAlpha" operator="out"/>
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.04 0"/>
<feBlend mode="normal" in2="effect1_dropShadow_0_1" result="effect2_dropShadow_0_1"/>
<feBlend mode="normal" in="SourceGraphic" in2="effect2_dropShadow_0_1" result="shape"/>
</filter>
<filter id="filter1_i_0_1" x="4" y="2" width="16" height="20" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset dy="-0.49608"/>
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1"/>
<feColorMatrix type="matrix" values="0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 0.52 0"/>
<feBlend mode="normal" in2="shape" result="effect1_innerShadow_0_1"/>
</filter>
<filter id="filter2_dd_0_1" x="1.06836" y="5.6084" width="18.2988" height="21.707" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset dy="0.49608"/>
<feGaussianBlur stdDeviation="0.12402"/>
<feComposite in2="hardAlpha" operator="out"/>
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.08 0"/>
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_0_1"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset dy="-1"/>
<feGaussianBlur stdDeviation="2.5"/>
<feComposite in2="hardAlpha" operator="out"/>
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.17 0"/>
<feBlend mode="normal" in2="effect1_dropShadow_0_1" result="effect2_dropShadow_0_1"/>
<feBlend mode="normal" in="SourceGraphic" in2="effect2_dropShadow_0_1" result="shape"/>
</filter>
<filter id="filter3_ddi_0_1" x="11.6153" y="0.379925" width="11.0975" height="10.326" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset dx="-0.24804" dy="0.24804"/>
<feGaussianBlur stdDeviation="0.12402"/>
<feComposite in2="hardAlpha" operator="out"/>
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.06 0"/>
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_0_1"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset dx="-0.992159" dy="0.992159"/>
<feGaussianBlur stdDeviation="1.2402"/>
<feComposite in2="hardAlpha" operator="out"/>
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.11 0"/>
<feBlend mode="normal" in2="effect1_dropShadow_0_1" result="effect2_dropShadow_0_1"/>
<feBlend mode="normal" in="SourceGraphic" in2="effect2_dropShadow_0_1" result="shape"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset dy="-0.545688"/>
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1"/>
<feColorMatrix type="matrix" values="0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 0.22 0"/>
<feBlend mode="normal" in2="shape" result="effect3_innerShadow_0_1"/>
</filter>
<linearGradient id="paint0_linear_0_1" x1="12" y1="2" x2="12" y2="22" gradientUnits="userSpaceOnUse">
<stop offset="0.254043" stop-color="white"/>
<stop offset="0.730769" stop-color="#FFCAFC"/>
<stop offset="1" stop-color="#8ABBFF"/>
</linearGradient>
</defs>
</svg><svg width="24" height="24" viewBox="0.000 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<g id="documents_icon">
<g id="Group 25">
<g id="Rectangle 10" filter="url(#filter1_i_0_1)">
<path d="M4 4.97648C4 3.33261 5.33261 2 6.97648 2H14.5702C15.4041 2 16.1997 2.34981 16.7635 2.96429L17.683 3.96664L18.9319 5.01007C19.6088 5.5756 20 6.4122 20 7.29424V19.0235C20 20.6674 18.6674 22 17.0235 22H6.97648C5.33262 22 4 20.6674 4 19.0235V4.97648Z" fill="#C0C0C0"/>
</g>
<g id="Frame 427322585">
<g id="Vector" filter="url(#filter2_dd_0_1)">
<path d="M9.06328 14.5954C9.37593 14.5978 9.6288 14.8507 9.63121 15.1633L9.66213 19.1788C9.66455 19.4949 9.92281 19.7531 10.2389 19.7556C10.5551 19.758 10.8094 19.5037 10.8069 19.1876L10.7716 14.6086C10.7643 13.6602 9.98958 12.8855 9.04125 12.8782C8.09289 12.8709 7.33003 13.6338 7.33732 14.5821L7.37262 19.1612C7.38479 20.7417 8.67597 22.0329 10.2566 22.0451C11.8372 22.0573 13.1086 20.7859 13.0964 19.2053L13.0656 15.2074C13.0631 14.8879 13.3228 14.6282 13.6423 14.6307C13.955 14.6331 14.2078 14.8859 14.2102 15.1986L14.2412 19.2141C14.2582 21.4269 12.4782 23.2069 10.2654 23.1899C8.05258 23.1728 6.24493 21.3652 6.22786 19.1523L6.19257 14.5733C6.18038 12.9927 7.45185 11.7212 9.03243 11.7334C10.613 11.7456 11.9042 13.0368 11.9164 14.6174L11.9517 19.1965C11.959 20.1448 11.1961 20.9076 10.2478 20.9004C9.29941 20.893 8.52469 20.1183 8.51738 19.17L8.48656 15.1721C8.4841 14.8526 8.74375 14.593 9.06328 14.5954Z" fill="white"/>
<path d="M9.06328 14.5954C9.37593 14.5978 9.6288 14.8507 9.63121 15.1633L9.66213 19.1788C9.66455 19.4949 9.92281 19.7531 10.2389 19.7556C10.5551 19.758 10.8094 19.5037 10.8069 19.1876L10.7716 14.6086C10.7643 13.6602 9.98958 12.8855 9.04125 12.8782C8.09289 12.8709 7.33003 13.6338 7.33732 14.5821L7.37262 19.1612C7.38479 20.7417 8.67597 22.0329 10.2566 22.0451C11.8372 22.0573 13.1086 20.7859 13.0964 19.2053L13.0656 15.2074C13.0631 14.8879 13.3228 14.6282 13.6423 14.6307C13.955 14.6331 14.2078 14.8859 14.2102 15.1986L14.2412 19.2141C14.2582 21.4269 12.4782 23.2069 10.2654 23.1899C8.05258 23.1728 6.24493 21.3652 6.22786 19.1523L6.19257 14.5733C6.18038 12.9927 7.45185 11.7212 9.03243 11.7334C10.613 11.7456 11.9042 13.0368 11.9164 14.6174L11.9517 19.1965C11.959 20.1448 11.1961 20.9076 10.2478 20.9004C9.29941 20.893 8.52469 20.1183 8.51738 19.17L8.48656 15.1721C8.4841 14.8526 8.74375 14.593 9.06328 14.5954Z" stroke="white" stroke-width="0.25"/>
</g>
</g>
<g id="Mask group">
<mask id="mask0_0_1" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="4" y="2" width="16" height="20">
<path id="Rectangle 13" d="M4 4.97648C4 3.33262 5.33261 2 6.97648 2H14.7553C15.4794 2 16.1786 2.26393 16.7221 2.74237L17.8182 3.70732L19.1346 5.03176C19.6889 5.58943 20 6.34376 20 7.13004V19.0235C20 20.6674 18.6674 22 17.0235 22H6.97648C5.33262 22 4 20.6674 4 19.0235V4.97648Z" fill="url(#paint0_linear_0_1)"/>
</mask>
<g mask="url(#mask0_0_1)">
<g id="Rectangle 12" filter="url(#filter3_ddi_0_1)">
<path d="M21.0276 6.26899C21.419 6.59603 21.1877 7.2334 20.6777 7.2334H16.1791C15.5763 7.2334 15.0877 6.74477 15.0877 6.14202V2.41508C15.0877 1.94923 15.6338 1.69759 15.9879 2.00026L18.9894 4.56559L21.0276 6.26899Z" fill="#EFEFEF"/>
</g>
</g>
</g>
</g>
</g>
<defs>
<filter id="filter0_dd_0_1" x="-24" y="-190" width="377" height="360" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset dy="4"/>
<feGaussianBlur stdDeviation="4"/>
<feComposite in2="hardAlpha" operator="out"/>
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.02 0"/>
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_0_1"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset dy="1"/>
<feGaussianBlur stdDeviation="1"/>
<feComposite in2="hardAlpha" operator="out"/>
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.04 0"/>
<feBlend mode="normal" in2="effect1_dropShadow_0_1" result="effect2_dropShadow_0_1"/>
<feBlend mode="normal" in="SourceGraphic" in2="effect2_dropShadow_0_1" result="shape"/>
</filter>
<filter id="filter1_i_0_1" x="4" y="2" width="16" height="20" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset dy="-0.49608"/>
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1"/>
<feColorMatrix type="matrix" values="0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 0.52 0"/>
<feBlend mode="normal" in2="shape" result="effect1_innerShadow_0_1"/>
</filter>
<filter id="filter2_dd_0_1" x="1.06836" y="5.6084" width="18.2988" height="21.707" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset dy="0.49608"/>
<feGaussianBlur stdDeviation="0.12402"/>
<feComposite in2="hardAlpha" operator="out"/>
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.08 0"/>
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_0_1"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset dy="-1"/>
<feGaussianBlur stdDeviation="2.5"/>
<feComposite in2="hardAlpha" operator="out"/>
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.17 0"/>
<feBlend mode="normal" in2="effect1_dropShadow_0_1" result="effect2_dropShadow_0_1"/>
<feBlend mode="normal" in="SourceGraphic" in2="effect2_dropShadow_0_1" result="shape"/>
</filter>
<filter id="filter3_ddi_0_1" x="11.6153" y="0.379925" width="11.0975" height="10.326" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset dx="-0.24804" dy="0.24804"/>
<feGaussianBlur stdDeviation="0.12402"/>
<feComposite in2="hardAlpha" operator="out"/>
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.06 0"/>
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_0_1"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset dx="-0.992159" dy="0.992159"/>
<feGaussianBlur stdDeviation="1.2402"/>
<feComposite in2="hardAlpha" operator="out"/>
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.11 0"/>
<feBlend mode="normal" in2="effect1_dropShadow_0_1" result="effect2_dropShadow_0_1"/>
<feBlend mode="normal" in="SourceGraphic" in2="effect2_dropShadow_0_1" result="shape"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset dy="-0.545688"/>
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1"/>
<feColorMatrix type="matrix" values="0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 0.22 0"/>
<feBlend mode="normal" in2="shape" result="effect3_innerShadow_0_1"/>
</filter>
<linearGradient id="paint0_linear_0_1" x1="12" y1="2" x2="12" y2="22" gradientUnits="userSpaceOnUse">
<stop offset="0.254043" stop-color="white"/>
<stop offset="0.730769" stop-color="#FFCAFC"/>
<stop offset="1" stop-color="#8ABBFF"/>
</linearGradient>
</defs>
</svg><svg width="24" height="24" viewBox="0.441 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<g id="spreadsheets_icon">
<g id="Group 25">
<g id="Rectangle 10" filter="url(#filter1_i_0_1)">
<path d="M4.44141 4.97648C4.44141 3.33261 5.77402 2 7.41788 2H15.0116C15.8455 2 16.6411 2.34981 17.2049 2.96429L18.1244 3.96664L19.3733 5.01007C20.0502 5.5756 20.4414 6.4122 20.4414 7.29424V19.0235C20.4414 20.6674 19.1088 22 17.4649 22H7.41788C5.77402 22 4.44141 20.6674 4.44141 19.0235V4.97648Z" fill="#3392FF"/>
</g>
<g id="Frame 427322585">
<g id="Union" filter="url(#filter2_dd_0_1)">
<path d="M17.4414 18.0078C17.4414 18.5558 16.9972 19 16.4492 19H8.43356C7.88561 19 7.44141 18.5558 7.44141 18.0078V10.9922C7.44141 10.4442 7.88561 10 8.43357 10H16.4492C16.9972 10 17.4414 10.4442 17.4414 10.9922V18.0078ZM11.4414 17.0078C11.4414 17.5558 11.8856 18 12.4336 18H15.4492C15.9972 18 16.4414 17.5558 16.4414 17.0078V15.9922C16.4414 15.4442 15.9972 15 15.4492 15H12.4336C11.8856 15 11.4414 15.4442 11.4414 15.9922V17.0078ZM8.44141 17.0078C8.44141 17.5558 8.89345 18 9.44141 18C9.98936 18 10.4414 17.5558 10.4414 17.0078V15.9922C10.4414 15.4442 9.98936 15 9.44141 15C8.89345 15 8.44141 15.4442 8.44141 15.9922V17.0078ZM11.4414 13.0078C11.4414 13.5558 11.8856 14 12.4336 14H15.4492C15.9972 14 16.4414 13.5558 16.4414 13.0078V11.9922C16.4414 11.4442 15.9972 11 15.4492 11H12.4336C11.8856 11 11.4414 11.4442 11.4414 11.9922V13.0078ZM8.44141 13.0078C8.44141 13.5558 8.89345 14 9.44141 14C9.98936 14 10.4414 13.5558 10.4414 13.0078V11.9922C10.4414 11.4442 9.98936 11 9.44141 11C8.89345 11 8.44141 11.4442 8.44141 11.9922V13.0078Z" fill="white"/>
</g>
</g>
<g id="Mask group">
<mask id="mask0_0_1" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="4" y="2" width="17" height="20">
<path id="Rectangle 13" d="M4.44141 4.97648C4.44141 3.33262 5.77402 2 7.41788 2H15.1967C15.9208 2 16.62 2.26393 17.1635 2.74237L18.2596 3.70732L19.576 5.03176C20.1303 5.58943 20.4414 6.34376 20.4414 7.13004V19.0235C20.4414 20.6674 19.1088 22 17.4649 22H7.41788C5.77402 22 4.44141 20.6674 4.44141 19.0235V4.97648Z" fill="url(#paint0_linear_0_1)"/>
</mask>
<g mask="url(#mask0_0_1)">
<g id="Rectangle 12" filter="url(#filter3_ddi_0_1)">
<path d="M21.469 6.26899C21.8604 6.59603 21.6291 7.2334 21.1191 7.2334H16.6205C16.0177 7.2334 15.5291 6.74477 15.5291 6.14202V2.41508C15.5291 1.94923 16.0752 1.69759 16.4293 2.00026L19.4308 4.56559L21.469 6.26899Z" fill="#BEDBFF"/>
</g>
</g>
</g>
</g>
</g>
<defs>
<filter id="filter0_dd_0_1" x="-23.5586" y="-230" width="377" height="360" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset dy="4"/>
<feGaussianBlur stdDeviation="4"/>
<feComposite in2="hardAlpha" operator="out"/>
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.02 0"/>
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_0_1"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset dy="1"/>
<feGaussianBlur stdDeviation="1"/>
<feComposite in2="hardAlpha" operator="out"/>
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.04 0"/>
<feBlend mode="normal" in2="effect1_dropShadow_0_1" result="effect2_dropShadow_0_1"/>
<feBlend mode="normal" in="SourceGraphic" in2="effect2_dropShadow_0_1" result="shape"/>
</filter>
<filter id="filter1_i_0_1" x="4.44141" y="2" width="16" height="20" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset dy="-0.49608"/>
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1"/>
<feColorMatrix type="matrix" values="0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 0.52 0"/>
<feBlend mode="normal" in2="shape" result="effect1_innerShadow_0_1"/>
</filter>
<filter id="filter2_dd_0_1" x="0.000211239" y="2.5588" width="24.8824" height="23.8824" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset dy="0.49608"/>
<feGaussianBlur stdDeviation="0.12402"/>
<feComposite in2="hardAlpha" operator="out"/>
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.1 0"/>
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_0_1"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset/>
<feGaussianBlur stdDeviation="3.7206"/>
<feComposite in2="hardAlpha" operator="out"/>
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.24 0"/>
<feBlend mode="normal" in2="effect1_dropShadow_0_1" result="effect2_dropShadow_0_1"/>
<feBlend mode="normal" in="SourceGraphic" in2="effect2_dropShadow_0_1" result="shape"/>
</filter>
<filter id="filter3_ddi_0_1" x="12.0567" y="0.379925" width="11.0975" height="10.326" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset dx="-0.24804" dy="0.24804"/>
<feGaussianBlur stdDeviation="0.12402"/>
<feComposite in2="hardAlpha" operator="out"/>
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.06 0"/>
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_0_1"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset dx="-0.992159" dy="0.992159"/>
<feGaussianBlur stdDeviation="1.2402"/>
<feComposite in2="hardAlpha" operator="out"/>
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.11 0"/>
<feBlend mode="normal" in2="effect1_dropShadow_0_1" result="effect2_dropShadow_0_1"/>
<feBlend mode="normal" in="SourceGraphic" in2="effect2_dropShadow_0_1" result="shape"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset dy="-0.545688"/>
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1"/>
<feColorMatrix type="matrix" values="0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 0.22 0"/>
<feBlend mode="normal" in2="shape" result="effect3_innerShadow_0_1"/>
</filter>
<linearGradient id="paint0_linear_0_1" x1="12.4414" y1="2" x2="12.4414" y2="22" gradientUnits="userSpaceOnUse">
<stop offset="0.254043" stop-color="white"/>
<stop offset="0.730769" stop-color="#FFCAFC"/>
<stop offset="1" stop-color="#8ABBFF"/>
</linearGradient>
</defs>
</svg><svg width="24" height="24" viewBox="0.441 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<g id="spreadsheets_icon">
<g id="Group 25">
<g id="Rectangle 10" filter="url(#filter1_i_0_1)">
<path d="M4.44141 4.97648C4.44141 3.33261 5.77402 2 7.41788 2H15.0116C15.8455 2 16.6411 2.34981 17.2049 2.96429L18.1244 3.96664L19.3733 5.01007C20.0502 5.5756 20.4414 6.4122 20.4414 7.29424V19.0235C20.4414 20.6674 19.1088 22 17.4649 22H7.41788C5.77402 22 4.44141 20.6674 4.44141 19.0235V4.97648Z" fill="#3392FF"/>
</g>
<g id="Frame 427322585">
<g id="Union" filter="url(#filter2_dd_0_1)">
<path d="M17.4414 18.0078C17.4414 18.5558 16.9972 19 16.4492 19H8.43356C7.88561 19 7.44141 18.5558 7.44141 18.0078V10.9922C7.44141 10.4442 7.88561 10 8.43357 10H16.4492C16.9972 10 17.4414 10.4442 17.4414 10.9922V18.0078ZM11.4414 17.0078C11.4414 17.5558 11.8856 18 12.4336 18H15.4492C15.9972 18 16.4414 17.5558 16.4414 17.0078V15.9922C16.4414 15.4442 15.9972 15 15.4492 15H12.4336C11.8856 15 11.4414 15.4442 11.4414 15.9922V17.0078ZM8.44141 17.0078C8.44141 17.5558 8.89345 18 9.44141 18C9.98936 18 10.4414 17.5558 10.4414 17.0078V15.9922C10.4414 15.4442 9.98936 15 9.44141 15C8.89345 15 8.44141 15.4442 8.44141 15.9922V17.0078ZM11.4414 13.0078C11.4414 13.5558 11.8856 14 12.4336 14H15.4492C15.9972 14 16.4414 13.5558 16.4414 13.0078V11.9922C16.4414 11.4442 15.9972 11 15.4492 11H12.4336C11.8856 11 11.4414 11.4442 11.4414 11.9922V13.0078ZM8.44141 13.0078C8.44141 13.5558 8.89345 14 9.44141 14C9.98936 14 10.4414 13.5558 10.4414 13.0078V11.9922C10.4414 11.4442 9.98936 11 9.44141 11C8.89345 11 8.44141 11.4442 8.44141 11.9922V13.0078Z" fill="white"/>
</g>
</g>
<g id="Mask group">
<mask id="mask0_0_1" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="4" y="2" width="17" height="20">
<path id="Rectangle 13" d="M4.44141 4.97648C4.44141 3.33262 5.77402 2 7.41788 2H15.1967C15.9208 2 16.62 2.26393 17.1635 2.74237L18.2596 3.70732L19.576 5.03176C20.1303 5.58943 20.4414 6.34376 20.4414 7.13004V19.0235C20.4414 20.6674 19.1088 22 17.4649 22H7.41788C5.77402 22 4.44141 20.6674 4.44141 19.0235V4.97648Z" fill="url(#paint0_linear_0_1)"/>
</mask>
<g mask="url(#mask0_0_1)">
<g id="Rectangle 12" filter="url(#filter3_ddi_0_1)">
<path d="M21.469 6.26899C21.8604 6.59603 21.6291 7.2334 21.1191 7.2334H16.6205C16.0177 7.2334 15.5291 6.74477 15.5291 6.14202V2.41508C15.5291 1.94923 16.0752 1.69759 16.4293 2.00026L19.4308 4.56559L21.469 6.26899Z" fill="#BEDBFF"/>
</g>
</g>
</g>
</g>
</g>
<defs>
<filter id="filter0_dd_0_1" x="-23.5586" y="-230" width="377" height="360" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset dy="4"/>
<feGaussianBlur stdDeviation="4"/>
<feComposite in2="hardAlpha" operator="out"/>
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.02 0"/>
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_0_1"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset dy="1"/>
<feGaussianBlur stdDeviation="1"/>
<feComposite in2="hardAlpha" operator="out"/>
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.04 0"/>
<feBlend mode="normal" in2="effect1_dropShadow_0_1" result="effect2_dropShadow_0_1"/>
<feBlend mode="normal" in="SourceGraphic" in2="effect2_dropShadow_0_1" result="shape"/>
</filter>
<filter id="filter1_i_0_1" x="4.44141" y="2" width="16" height="20" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset dy="-0.49608"/>
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1"/>
<feColorMatrix type="matrix" values="0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 0.52 0"/>
<feBlend mode="normal" in2="shape" result="effect1_innerShadow_0_1"/>
</filter>
<filter id="filter2_dd_0_1" x="0.000211239" y="2.5588" width="24.8824" height="23.8824" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset dy="0.49608"/>
<feGaussianBlur stdDeviation="0.12402"/>
<feComposite in2="hardAlpha" operator="out"/>
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.1 0"/>
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_0_1"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset/>
<feGaussianBlur stdDeviation="3.7206"/>
<feComposite in2="hardAlpha" operator="out"/>
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.24 0"/>
<feBlend mode="normal" in2="effect1_dropShadow_0_1" result="effect2_dropShadow_0_1"/>
<feBlend mode="normal" in="SourceGraphic" in2="effect2_dropShadow_0_1" result="shape"/>
</filter>
<filter id="filter3_ddi_0_1" x="12.0567" y="0.379925" width="11.0975" height="10.326" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset dx="-0.24804" dy="0.24804"/>
<feGaussianBlur stdDeviation="0.12402"/>
<feComposite in2="hardAlpha" operator="out"/>
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.06 0"/>
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_0_1"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset dx="-0.992159" dy="0.992159"/>
<feGaussianBlur stdDeviation="1.2402"/>
<feComposite in2="hardAlpha" operator="out"/>
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.11 0"/>
<feBlend mode="normal" in2="effect1_dropShadow_0_1" result="effect2_dropShadow_0_1"/>
<feBlend mode="normal" in="SourceGraphic" in2="effect2_dropShadow_0_1" result="shape"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset dy="-0.545688"/>
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1"/>
<feColorMatrix type="matrix" values="0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 0.22 0"/>
<feBlend mode="normal" in2="shape" result="effect3_innerShadow_0_1"/>
</filter>
<linearGradient id="paint0_linear_0_1" x1="12.4414" y1="2" x2="12.4414" y2="22" gradientUnits="userSpaceOnUse">
<stop offset="0.254043" stop-color="white"/>
<stop offset="0.730769" stop-color="#FFCAFC"/>
<stop offset="1" stop-color="#8ABBFF"/>
</linearGradient>
</defs>
</svg><svg width="24" height="25" viewBox="0 0 24 25" fill="none" xmlns="http://www.w3.org/2000/svg">
<g filter="url(#filter0_i_4106_26757)">
<path d="M2 6.97648C2 5.33262 3.33261 4 4.97648 4H16.4505C17.4218 4 18.3321 4.47337 18.89 5.2685L19.1038 5.57331L21.2386 7.9517C21.7288 8.49789 22 9.20595 22 9.93989V17.0235C22 18.6674 20.6674 20 19.0235 20H4.97648C3.33261 20 2 18.6674 2 17.0235V6.97648Z" fill="#7C86FF"/>
</g>
<g filter="url(#filter1_dd_4106_26757)">
<path d="M14.4189 11.1679C15.0212 11.5592 15.0212 12.4408 14.4189 12.8321L11.2325 14.9017C10.5725 15.3304 9.69995 14.8567 9.69995 14.0696L9.69995 9.93039C9.69995 9.14332 10.5725 8.66962 11.2325 9.09833L14.4189 11.1679Z" fill="white"/>
<path d="M14.4189 11.1679C15.0212 11.5592 15.0212 12.4408 14.4189 12.8321L11.2325 14.9017C10.5725 15.3304 9.69995 14.8567 9.69995 14.0696L9.69995 9.93039C9.69995 9.14332 10.5725 8.66962 11.2325 9.09833L14.4189 11.1679Z" stroke="white" stroke-width="0.25"/>
</g>
<mask id="mask0_4106_26757" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="6" y="4" width="16" height="20">
<path d="M6 6.97648C6 5.33261 7.33261 4 8.97648 4H16.7553C17.4794 4 18.1786 4.26393 18.7221 4.74237L19.8182 5.70732L21.1346 7.03176C21.6889 7.58943 22 8.34376 22 9.13004V21.0235C22 22.6674 20.6674 24 19.0235 24H8.97648C7.33262 24 6 22.6674 6 21.0235V6.97648Z" fill="url(#paint0_linear_4106_26757)"/>
</mask>
<g mask="url(#mask0_4106_26757)">
<g filter="url(#filter2_ddi_4106_26757)">
<path d="M23.0274 8.26899C23.4187 8.59603 23.1875 9.2334 22.6775 9.2334H18.1788C17.5761 9.2334 17.0875 8.74477 17.0875 8.14202V4.41508C17.0875 3.94923 17.6336 3.69759 17.9877 4.00026L20.9891 6.56559L23.0274 8.26899Z" fill="#C6D2FF"/>
</g>
</g>
<defs>
<filter id="filter0_i_4106_26757" x="2" y="4" width="20" height="16" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset dy="-0.49608"/>
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1"/>
<feColorMatrix type="matrix" values="0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 0.52 0"/>
<feBlend mode="normal" in2="shape" result="effect1_innerShadow_4106_26757"/>
</filter>
<filter id="filter1_dd_4106_26757" x="4.57495" y="2.81152" width="15.4207" height="16.377" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset dy="0.49608"/>
<feGaussianBlur stdDeviation="0.12402"/>
<feComposite in2="hardAlpha" operator="out"/>
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.08 0"/>
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_4106_26757"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset dy="-1"/>
<feGaussianBlur stdDeviation="2.5"/>
<feComposite in2="hardAlpha" operator="out"/>
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.17 0"/>
<feBlend mode="normal" in2="effect1_dropShadow_4106_26757" result="effect2_dropShadow_4106_26757"/>
<feBlend mode="normal" in="SourceGraphic" in2="effect2_dropShadow_4106_26757" result="shape"/>
</filter>
<filter id="filter2_ddi_4106_26757" x="13.6148" y="2.37993" width="11.0975" height="10.326" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset dx="-0.24804" dy="0.24804"/>
<feGaussianBlur stdDeviation="0.12402"/>
<feComposite in2="hardAlpha" operator="out"/>
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.06 0"/>
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_4106_26757"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset dx="-0.992159" dy="0.992159"/>
<feGaussianBlur stdDeviation="1.2402"/>
<feComposite in2="hardAlpha" operator="out"/>
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.11 0"/>
<feBlend mode="normal" in2="effect1_dropShadow_4106_26757" result="effect2_dropShadow_4106_26757"/>
<feBlend mode="normal" in="SourceGraphic" in2="effect2_dropShadow_4106_26757" result="shape"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset dy="-0.545688"/>
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1"/>
<feColorMatrix type="matrix" values="0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 0.22 0"/>
<feBlend mode="normal" in2="shape" result="effect3_innerShadow_4106_26757"/>
</filter>
<linearGradient id="paint0_linear_4106_26757" x1="14" y1="4" x2="14" y2="24" gradientUnits="userSpaceOnUse">
<stop offset="0.254043" stop-color="white"/>
<stop offset="0.730769" stop-color="#FFCAFC"/>
<stop offset="1" stop-color="#8ABBFF"/>
</linearGradient>
</defs>
</svg><svg width="24" height="25" viewBox="0 0 24 25" fill="none" xmlns="http://www.w3.org/2000/svg">
<g filter="url(#filter0_i_4106_26757)">
<path d="M2 6.97648C2 5.33262 3.33261 4 4.97648 4H16.4505C17.4218 4 18.3321 4.47337 18.89 5.2685L19.1038 5.57331L21.2386 7.9517C21.7288 8.49789 22 9.20595 22 9.93989V17.0235C22 18.6674 20.6674 20 19.0235 20H4.97648C3.33261 20 2 18.6674 2 17.0235V6.97648Z" fill="#7C86FF"/>
</g>
<g filter="url(#filter1_dd_4106_26757)">
<path d="M14.4189 11.1679C15.0212 11.5592 15.0212 12.4408 14.4189 12.8321L11.2325 14.9017C10.5725 15.3304 9.69995 14.8567 9.69995 14.0696L9.69995 9.93039C9.69995 9.14332 10.5725 8.66962 11.2325 9.09833L14.4189 11.1679Z" fill="white"/>
<path d="M14.4189 11.1679C15.0212 11.5592 15.0212 12.4408 14.4189 12.8321L11.2325 14.9017C10.5725 15.3304 9.69995 14.8567 9.69995 14.0696L9.69995 9.93039C9.69995 9.14332 10.5725 8.66962 11.2325 9.09833L14.4189 11.1679Z" stroke="white" stroke-width="0.25"/>
</g>
<mask id="mask0_4106_26757" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="6" y="4" width="16" height="20">
<path d="M6 6.97648C6 5.33261 7.33261 4 8.97648 4H16.7553C17.4794 4 18.1786 4.26393 18.7221 4.74237L19.8182 5.70732L21.1346 7.03176C21.6889 7.58943 22 8.34376 22 9.13004V21.0235C22 22.6674 20.6674 24 19.0235 24H8.97648C7.33262 24 6 22.6674 6 21.0235V6.97648Z" fill="url(#paint0_linear_4106_26757)"/>
</mask>
<g mask="url(#mask0_4106_26757)">
<g filter="url(#filter2_ddi_4106_26757)">
<path d="M23.0274 8.26899C23.4187 8.59603 23.1875 9.2334 22.6775 9.2334H18.1788C17.5761 9.2334 17.0875 8.74477 17.0875 8.14202V4.41508C17.0875 3.94923 17.6336 3.69759 17.9877 4.00026L20.9891 6.56559L23.0274 8.26899Z" fill="#C6D2FF"/>
</g>
</g>
<defs>
<filter id="filter0_i_4106_26757" x="2" y="4" width="20" height="16" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset dy="-0.49608"/>
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1"/>
<feColorMatrix type="matrix" values="0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 0.52 0"/>
<feBlend mode="normal" in2="shape" result="effect1_innerShadow_4106_26757"/>
</filter>
<filter id="filter1_dd_4106_26757" x="4.57495" y="2.81152" width="15.4207" height="16.377" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset dy="0.49608"/>
<feGaussianBlur stdDeviation="0.12402"/>
<feComposite in2="hardAlpha" operator="out"/>
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.08 0"/>
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_4106_26757"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset dy="-1"/>
<feGaussianBlur stdDeviation="2.5"/>
<feComposite in2="hardAlpha" operator="out"/>
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.17 0"/>
<feBlend mode="normal" in2="effect1_dropShadow_4106_26757" result="effect2_dropShadow_4106_26757"/>
<feBlend mode="normal" in="SourceGraphic" in2="effect2_dropShadow_4106_26757" result="shape"/>
</filter>
<filter id="filter2_ddi_4106_26757" x="13.6148" y="2.37993" width="11.0975" height="10.326" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset dx="-0.24804" dy="0.24804"/>
<feGaussianBlur stdDeviation="0.12402"/>
<feComposite in2="hardAlpha" operator="out"/>
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.06 0"/>
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_4106_26757"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset dx="-0.992159" dy="0.992159"/>
<feGaussianBlur stdDeviation="1.2402"/>
<feComposite in2="hardAlpha" operator="out"/>
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.11 0"/>
<feBlend mode="normal" in2="effect1_dropShadow_4106_26757" result="effect2_dropShadow_4106_26757"/>
<feBlend mode="normal" in="SourceGraphic" in2="effect2_dropShadow_4106_26757" result="shape"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset dy="-0.545688"/>
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1"/>
<feColorMatrix type="matrix" values="0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 0.22 0"/>
<feBlend mode="normal" in2="shape" result="effect3_innerShadow_4106_26757"/>
</filter>
<linearGradient id="paint0_linear_4106_26757" x1="14" y1="4" x2="14" y2="24" gradientUnits="userSpaceOnUse">
<stop offset="0.254043" stop-color="white"/>
<stop offset="0.730769" stop-color="#FFCAFC"/>
<stop offset="1" stop-color="#8ABBFF"/>
</linearGradient>
</defs>
</svg>Props
Generated from the component's TypeScript types. Standard DOM and React Aria props are omitted.
PlanArtFlame
| Prop | Type | Default | Description |
|---|---|---|---|
| className | string | — | — |
| src | string | — | — |
SettingsCard
Grouped card — rows divide themselves with borders that respect pl-12.
| Prop | Type | Default | Description |
|---|---|---|---|
| className | string | — | — |
SettingsGeneral
| Prop | Type | Default | Description |
|---|---|---|---|
| planArtSrc | string | — | — |
SettingsModal
| Prop | Type | Default | Description |
|---|---|---|---|
| isOpenrequired | boolean | — | Controlled open state, owned by the host page, sidebar, or menu. |
| onCloserequired | () => void | — | Called by the backdrop, close button, and Escape key. |
| defaultPage | "general" | "profile" | "storage" | "tools" | general | Page selected each time the modal opens. |
| planArtSrc | string | — | Optional product artwork used by the animated Current plan card. |
SettingsProfile
| Prop | Type | Default | Description |
|---|---|---|---|
| onSaved | () => void | — | — |
SettingsRow
One label + control row. Rows separate themselves; the last has no border.
| Prop | Type | Default | Description |
|---|---|---|---|
| labelrequired | string | — | — |
| description | string | — | — |
SettingsSectionLabel
Muted 13px section heading above a card ("Pull Requests", "Notifications").
| Prop | Type | Default | Description |
|---|---|---|---|
| className | string | — | — |
SettingsValueField
| Prop | Type | Default | Description |
|---|---|---|---|
| className | string | — | — |
| icon | IconComponent | — | — |
| muted | boolean | false | Secondary text color (e.g. the truncated Device ID). |