"use client"; import { createContext, useContext, useState, useEffect, useRef, useCallback, useMemo, type ReactNode, } from "react"; type ShapeVariant = "pill" | "rounded"; const shapeOrder: ShapeVariant[] = ["rounded", "pill"]; interface ShapeClasses { item: string; bg: string; focusRing: string; mergedBg: string; container: string; button: string; input: string; // Numeric counterparts of `bg` / `mergedBg`, in px. Needed where individual // corners are animated (e.g. the selected-background merge/split animation), // which requires per-corner numeric border-radii rather than a class. bgRadius: number; mergedRadius: number; } const shapeMap: Record = { pill: { item: "rounded-[20px]", bg: "rounded-[20px]", // +2px over `item` because the focus ring sits 2px outside the element // (top/left -2, width/height +4); this keeps the corners concentric so a // pill element gets a pill ring (matches the rounded-mode 8px→10px bump). focusRing: "rounded-[22px]", mergedBg: "rounded-2xl", container: "rounded-3xl", button: "rounded-[20px]", input: "rounded-[20px]", bgRadius: 20, mergedRadius: 16, }, rounded: { item: "rounded-lg", bg: "rounded-lg", focusRing: "rounded-[10px]", mergedBg: "rounded-lg", container: "rounded-xl", button: "rounded-lg", input: "rounded-lg", bgRadius: 8, mergedRadius: 8, }, }; interface ShapeContextValue { shape: ShapeVariant; setShape: (shape: ShapeVariant) => void; classes: ShapeClasses; } const ShapeContext = createContext(null); function useShape(): ShapeClasses { const ctx = useContext(ShapeContext); if (!ctx) return shapeMap.pill; return ctx.classes; } function useShapeContext() { const ctx = useContext(ShapeContext); if (!ctx) throw new Error("useShapeContext must be used within a ShapeProvider"); return ctx; } function ShapeProvider({ children, defaultShape = "pill", }: { children: ReactNode; defaultShape?: ShapeVariant; }) { const [shape, setShapeState] = useState(defaultShape); const transitionTimeoutRef = useRef | null>(null); // Run a state change under the `.transitioning` guard (added + reflow-flushed // first so the 180ms border-radius cross-fade applies). Clearing the previous // timeout first keeps a double-press from removing the class mid-fade. const transitionShape = useCallback((callback: () => void) => { const root = document.documentElement; root.classList.add("transitioning"); void root.offsetHeight; callback(); if (transitionTimeoutRef.current) clearTimeout(transitionTimeoutRef.current); transitionTimeoutRef.current = setTimeout( () => root.classList.remove("transitioning"), 200 ); }, []); const setShape = useCallback( (next: ShapeVariant) => { transitionShape(() => setShapeState(next)); }, [transitionShape] ); // Global keyboard shortcut: R to cycle radius useEffect(() => { const onKeyDown = (e: KeyboardEvent) => { if (e.key !== "r" && e.key !== "R") return; if (e.metaKey || e.ctrlKey || e.altKey) return; const tag = (e.target as HTMLElement)?.tagName; if (tag === "INPUT" || tag === "TEXTAREA" || (e.target as HTMLElement)?.isContentEditable) return; e.preventDefault(); transitionShape(() => { setShapeState((prev) => { const idx = shapeOrder.indexOf(prev); return shapeOrder[(idx + 1) % shapeOrder.length]; }); }); }; document.addEventListener("keydown", onKeyDown); return () => document.removeEventListener("keydown", onKeyDown); }, [transitionShape]); const value = useMemo( () => ({ shape, setShape, classes: shapeMap[shape] }), [shape, setShape] ); return ( {children} ); } export { ShapeProvider, useShape, useShapeContext, shapeMap }; export type { ShapeVariant, ShapeClasses };