@@ -1,10 +1,12 @@
|
||||
import * as React from "react";
|
||||
import { useInView, useMotionValue, useSpring, useReducedMotion } from "motion/react";
|
||||
|
||||
const defaultFormat = (value: number) => Math.round(value).toLocaleString();
|
||||
|
||||
/** Count-up number for dashboards/stat cards. Springs to `value` when scrolled into view. */
|
||||
export function AnimatedNumber({
|
||||
value,
|
||||
format = (n) => Math.round(n).toLocaleString(),
|
||||
format = defaultFormat,
|
||||
className,
|
||||
}: {
|
||||
value: number;
|
||||
@@ -25,5 +27,10 @@ export function AnimatedNumber({
|
||||
|
||||
React.useEffect(() => spring.on("change", (v) => setDisplay(format(v))), [spring, format]);
|
||||
|
||||
return <span ref={ref} className={className}>{display}</span>;
|
||||
return (
|
||||
<span ref={ref} className={className}>
|
||||
<span aria-hidden>{display}</span>
|
||||
<span className="sr-only">{format(value)}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -10,26 +10,37 @@ export function SpotlightCard({
|
||||
className?: string;
|
||||
}) {
|
||||
const ref = React.useRef<HTMLDivElement>(null);
|
||||
const [pos, setPos] = React.useState({ x: 50, y: 50, active: false });
|
||||
const frame = React.useRef<number>();
|
||||
|
||||
const onMove = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
const onMove = (e: React.PointerEvent<HTMLDivElement>) => {
|
||||
if (e.pointerType !== "mouse") return;
|
||||
const r = ref.current?.getBoundingClientRect();
|
||||
if (!r) return;
|
||||
setPos({ x: ((e.clientX - r.left) / r.width) * 100, y: ((e.clientY - r.top) / r.height) * 100, active: true });
|
||||
const x = ((e.clientX - r.left) / r.width) * 100;
|
||||
const y = ((e.clientY - r.top) / r.height) * 100;
|
||||
if (frame.current) cancelAnimationFrame(frame.current);
|
||||
frame.current = requestAnimationFrame(() => {
|
||||
ref.current?.style.setProperty("--spotlight-x", `${x}%`);
|
||||
ref.current?.style.setProperty("--spotlight-y", `${y}%`);
|
||||
});
|
||||
};
|
||||
|
||||
React.useEffect(() => () => {
|
||||
if (frame.current) cancelAnimationFrame(frame.current);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
onMouseMove={onMove}
|
||||
onMouseLeave={() => setPos((p) => ({ ...p, active: false }))}
|
||||
onPointerMove={onMove}
|
||||
className={cn("group relative overflow-hidden rounded-lg border border-border bg-surface p-6 shadow-sm transition-shadow duration-slow hover:shadow-md", className)}
|
||||
style={{ "--spotlight-x": "50%", "--spotlight-y": "50%" } as React.CSSProperties}
|
||||
>
|
||||
<div
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute inset-0 opacity-0 transition-opacity duration-slow group-hover:opacity-100"
|
||||
style={{
|
||||
background: `radial-gradient(400px circle at ${pos.x}% ${pos.y}%, hsl(var(--brand) / 0.10), transparent 60%)`,
|
||||
background: "radial-gradient(400px circle at var(--spotlight-x) var(--spotlight-y), hsl(var(--brand) / 0.10), transparent 60%)",
|
||||
}}
|
||||
/>
|
||||
<div className="relative">{children}</div>
|
||||
|
||||
Executable → Regular
+109
-26
@@ -1,4 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { Menu } from "lucide-react";
|
||||
import { Button } from "../components/button";
|
||||
import { Dialog, DialogContent, DialogTitle } from "../components/dialog";
|
||||
import { cn } from "../lib/cn";
|
||||
|
||||
export interface NavItem {
|
||||
@@ -9,50 +14,128 @@ export interface NavItem {
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
/** Dashboard chrome: fixed sidebar + sticky topbar + scrolling content. */
|
||||
function ShellNav({
|
||||
items,
|
||||
label,
|
||||
onNavigate,
|
||||
}: {
|
||||
items: NavItem[];
|
||||
label: string;
|
||||
onNavigate?: () => void;
|
||||
}) {
|
||||
return (
|
||||
<nav aria-label={label} className="flex-1 space-y-0.5 px-3 py-3">
|
||||
{items.map((item) => {
|
||||
const classes = cn(
|
||||
"flex w-full items-center gap-3 rounded px-3 py-2 text-sm font-medium transition-colors duration-fast",
|
||||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/60 [&_svg]:size-4 [&_svg]:shrink-0",
|
||||
item.active ? "bg-brand/15 text-brand" : "text-muted hover:bg-surface-2/60 hover:text-text"
|
||||
);
|
||||
const content = <>{item.icon}<span className="truncate">{item.label}</span></>;
|
||||
if (item.href) {
|
||||
return (
|
||||
<a
|
||||
key={item.label}
|
||||
href={item.href}
|
||||
aria-current={item.active ? "page" : undefined}
|
||||
onClick={() => {
|
||||
item.onClick?.();
|
||||
onNavigate?.();
|
||||
}}
|
||||
className={classes}
|
||||
>
|
||||
{content}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<button
|
||||
key={item.label}
|
||||
type="button"
|
||||
aria-current={item.active ? "page" : undefined}
|
||||
onClick={() => {
|
||||
item.onClick?.();
|
||||
onNavigate?.();
|
||||
}}
|
||||
className={classes}
|
||||
>
|
||||
{content}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
/** Responsive dashboard chrome with desktop navigation and a mobile drawer. */
|
||||
export function AppShell({
|
||||
brand,
|
||||
nav,
|
||||
topbar,
|
||||
children,
|
||||
footer,
|
||||
navLabel = "Primary navigation",
|
||||
className,
|
||||
mainClassName,
|
||||
}: {
|
||||
brand: React.ReactNode;
|
||||
nav: NavItem[];
|
||||
topbar?: React.ReactNode;
|
||||
children: React.ReactNode;
|
||||
footer?: React.ReactNode;
|
||||
navLabel?: string;
|
||||
className?: string;
|
||||
mainClassName?: string;
|
||||
}) {
|
||||
const [mobileOpen, setMobileOpen] = React.useState(false);
|
||||
|
||||
const sidebar = (mobile = false) => (
|
||||
<>
|
||||
<div className={cn("flex h-14 items-center px-5 font-display text-lg font-bold", mobile && "pr-12")}>
|
||||
{brand}
|
||||
</div>
|
||||
<ShellNav items={nav} label={navLabel} onNavigate={mobile ? () => setMobileOpen(false) : undefined} />
|
||||
{footer && <div className="border-t border-border p-3 text-sm text-muted">{footer}</div>}
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen bg-bg text-text">
|
||||
<div className={cn("flex min-h-dvh bg-bg text-text", className)}>
|
||||
<a
|
||||
href="#main-content"
|
||||
className="fixed left-3 top-3 z-[70] -translate-y-20 rounded bg-brand px-3 py-2 text-sm font-semibold text-brand-foreground shadow-lg focus:translate-y-0"
|
||||
>
|
||||
Skip to content
|
||||
</a>
|
||||
|
||||
<aside className="hidden w-60 shrink-0 flex-col border-r border-border bg-surface md:flex">
|
||||
<div className="flex h-14 items-center px-5 font-display text-lg font-bold">{brand}</div>
|
||||
<nav className="flex-1 space-y-0.5 px-3 py-3">
|
||||
{nav.map((item) => {
|
||||
const Comp = item.href ? "a" : "button";
|
||||
return (
|
||||
<Comp
|
||||
key={item.label}
|
||||
href={item.href}
|
||||
onClick={item.onClick}
|
||||
className={cn(
|
||||
"flex w-full items-center gap-3 rounded px-3 py-2 text-sm font-medium transition-colors duration-fast [&_svg]:size-4",
|
||||
item.active ? "bg-brand/15 text-brand" : "text-muted hover:bg-surface-2/60 hover:text-text"
|
||||
)}
|
||||
>
|
||||
{item.icon}
|
||||
{item.label}
|
||||
</Comp>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
{footer && <div className="border-t border-border p-3 text-sm text-muted">{footer}</div>}
|
||||
{sidebar()}
|
||||
</aside>
|
||||
|
||||
<Dialog open={mobileOpen} onOpenChange={setMobileOpen}>
|
||||
<DialogContent className="inset-y-0 left-0 top-0 flex h-dvh w-[min(20rem,88vw)] max-w-none translate-x-0 translate-y-0 flex-col rounded-none border-y-0 border-l-0 p-0 md:hidden">
|
||||
<DialogTitle className="sr-only">{navLabel}</DialogTitle>
|
||||
{sidebar(true)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<header className="sticky top-0 z-30 flex h-14 items-center justify-between gap-4 border-b border-border bg-bg/80 px-5 backdrop-blur">
|
||||
{topbar}
|
||||
<header className="sticky top-0 z-30 flex min-h-14 items-center gap-3 border-b border-border bg-bg/85 px-4 py-2 backdrop-blur md:px-5">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="shrink-0 md:hidden"
|
||||
aria-label="Open navigation"
|
||||
onClick={() => setMobileOpen(true)}
|
||||
>
|
||||
<Menu />
|
||||
</Button>
|
||||
<div className="min-w-0 flex-1">{topbar ?? <div className="font-display font-semibold md:hidden">{brand}</div>}</div>
|
||||
</header>
|
||||
<main className="flex-1 p-6">{children}</main>
|
||||
<main id="main-content" tabIndex={-1} className={cn("flex-1 p-4 outline-none sm:p-6", mainClassName)}>
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -45,9 +45,11 @@ export function CommandPalette({
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
label="Command palette"
|
||||
overlayClassName="fixed inset-0 z-50 bg-black/60 backdrop-blur-sm"
|
||||
contentClassName="fixed left-1/2 top-[18vh] z-50 w-[calc(100%-2rem)] max-w-xl -translate-x-1/2"
|
||||
className={cn(
|
||||
"fixed left-1/2 top-1/4 z-50 w-full max-w-xl -translate-x-1/2 overflow-hidden",
|
||||
"rounded-lg border border-border bg-surface shadow-lg data-[state=open]:animate-scale-in"
|
||||
"overflow-hidden rounded-lg border border-border bg-surface shadow-lg",
|
||||
"data-[state=open]:animate-scale-in"
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2 border-b border-border px-4">
|
||||
|
||||
+1
-1
@@ -44,7 +44,7 @@ export function Hero({
|
||||
)}
|
||||
{(primaryCta || secondaryCta) && (
|
||||
<FadeIn delay={0.15}>
|
||||
<div className="mt-10 flex items-center justify-center gap-3">
|
||||
<div className="mt-10 flex flex-col items-stretch justify-center gap-3 sm:flex-row sm:items-center">
|
||||
{primaryCta && (
|
||||
<Button size="lg" asChild={!!primaryCta.href} onClick={primaryCta.onClick}>
|
||||
{primaryCta.href ? <a href={primaryCta.href}>{primaryCta.label}</a> : primaryCta.label}
|
||||
|
||||
@@ -11,11 +11,11 @@ const buttonVariants = cva(
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
primary: "bg-brand text-bg hover:bg-brand-bright shadow-sm hover:shadow-glow",
|
||||
primary: "bg-brand text-brand-foreground hover:bg-brand-bright shadow-sm hover:shadow-glow",
|
||||
secondary: "bg-surface-2 text-text hover:bg-surface-2/70 shadow-sm",
|
||||
outline: "border border-border bg-transparent text-text hover:bg-surface-2/60",
|
||||
ghost: "bg-transparent text-text hover:bg-surface-2/60",
|
||||
danger: "bg-danger text-bg hover:bg-danger/90 shadow-sm",
|
||||
danger: "bg-danger text-danger-foreground hover:bg-danger/90 shadow-sm",
|
||||
link: "text-brand underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as CheckboxPrimitive from "@radix-ui/react-checkbox";
|
||||
import { Check } from "lucide-react";
|
||||
import { cn } from "../lib/cn";
|
||||
|
||||
export const Checkbox = React.forwardRef<
|
||||
React.ElementRef<typeof CheckboxPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CheckboxPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"peer size-4 shrink-0 rounded-sm border border-border bg-bg/40 shadow-sm",
|
||||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/60",
|
||||
"disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:border-brand data-[state=checked]:bg-brand data-[state=checked]:text-brand-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<CheckboxPrimitive.Indicator className="flex items-center justify-center">
|
||||
<Check className="size-3.5" />
|
||||
</CheckboxPrimitive.Indicator>
|
||||
</CheckboxPrimitive.Root>
|
||||
));
|
||||
Checkbox.displayName = "Checkbox";
|
||||
@@ -32,7 +32,7 @@ export const DialogContent = React.forwardRef<
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed left-1/2 top-1/2 z-50 w-full max-w-lg -translate-x-1/2 -translate-y-1/2",
|
||||
"fixed left-1/2 top-1/2 z-50 w-[calc(100%-2rem)] max-w-lg -translate-x-1/2 -translate-y-1/2",
|
||||
"rounded-lg border border-border bg-surface p-6 shadow-lg",
|
||||
"data-[state=open]:animate-scale-in",
|
||||
className
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import * as React from "react";
|
||||
import { cn } from "../lib/cn";
|
||||
import { Label } from "./label";
|
||||
|
||||
export interface FormFieldProps {
|
||||
label: React.ReactNode;
|
||||
children: React.ReactElement<{
|
||||
id?: string;
|
||||
"aria-describedby"?: string;
|
||||
"aria-invalid"?: boolean;
|
||||
}>;
|
||||
id?: string;
|
||||
description?: React.ReactNode;
|
||||
error?: React.ReactNode;
|
||||
required?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/** Associates a control with its label, supporting text, and validation error. */
|
||||
export function FormField({
|
||||
label,
|
||||
children,
|
||||
id,
|
||||
description,
|
||||
error,
|
||||
required,
|
||||
className,
|
||||
}: FormFieldProps) {
|
||||
const generatedId = React.useId();
|
||||
const controlId = id ?? children.props.id ?? `field-${generatedId}`;
|
||||
const descriptionId = description ? `${controlId}-description` : undefined;
|
||||
const errorId = error ? `${controlId}-error` : undefined;
|
||||
const describedBy = [children.props["aria-describedby"], descriptionId, errorId].filter(Boolean).join(" ") || undefined;
|
||||
|
||||
return (
|
||||
<div className={cn("space-y-1.5", className)}>
|
||||
<Label htmlFor={controlId} className="mb-0 text-sm text-text">
|
||||
{label}
|
||||
{required && <span className="ml-1 text-danger" aria-hidden>*</span>}
|
||||
</Label>
|
||||
{React.cloneElement(children, {
|
||||
id: controlId,
|
||||
"aria-describedby": describedBy,
|
||||
"aria-invalid": Boolean(error) || children.props["aria-invalid"],
|
||||
})}
|
||||
{description && !error && <p id={descriptionId} className="text-xs text-muted">{description}</p>}
|
||||
{error && <p id={errorId} className="text-xs font-medium text-danger">{error}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
"use client";
|
||||
|
||||
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import { Button } from "./button";
|
||||
import { cn } from "../lib/cn";
|
||||
|
||||
export interface PaginationProps {
|
||||
page: number;
|
||||
pageCount: number;
|
||||
onPageChange: (page: number) => void;
|
||||
className?: string;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
export function Pagination({
|
||||
page,
|
||||
pageCount,
|
||||
onPageChange,
|
||||
className,
|
||||
label = "Pagination",
|
||||
}: PaginationProps) {
|
||||
const current = Math.min(Math.max(1, page), Math.max(1, pageCount));
|
||||
return (
|
||||
<nav aria-label={label} className={cn("flex items-center justify-between gap-3", className)}>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={current <= 1}
|
||||
onClick={() => onPageChange(current - 1)}
|
||||
>
|
||||
<ChevronLeft />
|
||||
Previous
|
||||
</Button>
|
||||
<span className="text-sm text-muted" aria-live="polite">
|
||||
Page <strong className="font-semibold text-text">{current}</strong> of {Math.max(1, pageCount)}
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={current >= pageCount}
|
||||
onClick={() => onPageChange(current + 1)}
|
||||
>
|
||||
Next
|
||||
<ChevronRight />
|
||||
</Button>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
Executable → Regular
+102
-63
@@ -1,23 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as RS from "@radix-ui/react-select";
|
||||
import { Check, ChevronDown } from "lucide-react";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import { cn } from "../lib/cn";
|
||||
|
||||
/**
|
||||
* Themed Select — a drop-in for a native <select>, but the open menu is a fully
|
||||
* styleable popover (dark/gold), unlike the OS-drawn native dropdown.
|
||||
*
|
||||
* Keeps the native API on purpose so it swaps 1:1 with existing code:
|
||||
* <Select value={x} onChange={(e) => setX(e.target.value)}>
|
||||
* <option value="">None</option>
|
||||
* <option value="a">A</option>
|
||||
* </Select>
|
||||
*
|
||||
* Radix forbids empty-string item values, but apps use <option value=""> for
|
||||
* real, selectable choices (e.g. "No procedure"). We map "" to a sentinel
|
||||
* internally and translate back on change, so empty options stay selectable.
|
||||
*/
|
||||
const selectVariants = cva(
|
||||
"inline-flex w-full items-center justify-between gap-2 rounded border border-border bg-bg/40 text-text " +
|
||||
"transition-shadow duration-base ease-out focus:outline-none focus-visible:ring-2 focus-visible:ring-ring/60 " +
|
||||
@@ -29,61 +17,101 @@ const selectVariants = cva(
|
||||
);
|
||||
|
||||
const EMPTY = "__ui_select_empty__";
|
||||
const toRadix = (v?: string) => (v === "" ? EMPTY : v);
|
||||
const fromRadix = (v: string) => (v === EMPTY ? "" : v);
|
||||
|
||||
type OptionData = { value: string; label: React.ReactNode; disabled?: boolean };
|
||||
type OptionGroup = { label?: React.ReactNode; options: OptionData[] };
|
||||
|
||||
function collectOptions(children: React.ReactNode): OptionData[] {
|
||||
const out: OptionData[] = [];
|
||||
const walk = (nodes: React.ReactNode) =>
|
||||
React.Children.forEach(nodes, (child) => {
|
||||
if (!React.isValidElement(child)) return;
|
||||
if (child.type === "option") {
|
||||
const p = child.props as { value?: string | number; children?: React.ReactNode; disabled?: boolean };
|
||||
out.push({ value: String(p.value ?? ""), label: p.children, disabled: p.disabled });
|
||||
} else if (child.type === "optgroup") {
|
||||
walk((child.props as { children?: React.ReactNode }).children);
|
||||
}
|
||||
});
|
||||
walk(children);
|
||||
return out;
|
||||
function collectOptions(children: React.ReactNode): OptionGroup[] {
|
||||
const groups: OptionGroup[] = [];
|
||||
const ungrouped: OptionData[] = [];
|
||||
|
||||
React.Children.forEach(children, (child) => {
|
||||
if (!React.isValidElement(child)) return;
|
||||
if (child.type === "option") {
|
||||
const props = child.props as { value?: string | number; children?: React.ReactNode; disabled?: boolean };
|
||||
ungrouped.push({ value: String(props.value ?? ""), label: props.children, disabled: props.disabled });
|
||||
return;
|
||||
}
|
||||
if (child.type === "optgroup") {
|
||||
const props = child.props as { label?: React.ReactNode; children?: React.ReactNode };
|
||||
const options: OptionData[] = [];
|
||||
React.Children.forEach(props.children, (option) => {
|
||||
if (!React.isValidElement(option) || option.type !== "option") return;
|
||||
const optionProps = option.props as { value?: string | number; children?: React.ReactNode; disabled?: boolean };
|
||||
options.push({
|
||||
value: String(optionProps.value ?? ""),
|
||||
label: optionProps.children,
|
||||
disabled: optionProps.disabled,
|
||||
});
|
||||
});
|
||||
groups.push({ label: props.label, options });
|
||||
}
|
||||
});
|
||||
|
||||
return ungrouped.length ? [{ options: ungrouped }, ...groups] : groups;
|
||||
}
|
||||
|
||||
export interface SelectProps extends VariantProps<typeof selectVariants> {
|
||||
type RootProps = React.ComponentPropsWithoutRef<typeof RS.Root>;
|
||||
type TriggerProps = React.ComponentPropsWithoutRef<typeof RS.Trigger>;
|
||||
|
||||
export interface SelectProps
|
||||
extends VariantProps<typeof selectVariants>,
|
||||
Omit<RootProps, "children" | "value" | "defaultValue" | "onValueChange"> {
|
||||
value?: string;
|
||||
defaultValue?: string;
|
||||
onChange?: (e: { target: { value: string } }) => void;
|
||||
onValueChange?: (value: string) => void;
|
||||
disabled?: boolean;
|
||||
name?: string;
|
||||
placeholder?: string;
|
||||
/** Compatibility callback for native-select migrations. Prefer onValueChange. */
|
||||
onChange?: (event: { target: { value: string } }) => void;
|
||||
placeholder?: React.ReactNode;
|
||||
className?: string;
|
||||
contentClassName?: string;
|
||||
triggerProps?: Omit<TriggerProps, "children" | "className">;
|
||||
children?: React.ReactNode;
|
||||
"aria-label"?: string;
|
||||
id?: string;
|
||||
}
|
||||
|
||||
export const Select = React.forwardRef<HTMLButtonElement, SelectProps>(
|
||||
(
|
||||
{ value, defaultValue, onChange, onValueChange, disabled, name, placeholder, size, className, children, id, ...rest },
|
||||
{
|
||||
value,
|
||||
defaultValue,
|
||||
onChange,
|
||||
onValueChange,
|
||||
placeholder,
|
||||
size,
|
||||
className,
|
||||
contentClassName,
|
||||
triggerProps,
|
||||
children,
|
||||
id,
|
||||
...rootProps
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
const options = collectOptions(children);
|
||||
const handle = (v: string) => {
|
||||
const real = fromRadix(v);
|
||||
onValueChange?.(real);
|
||||
onChange?.({ target: { value: real } });
|
||||
const groups = collectOptions(children);
|
||||
const values = groups.flatMap((group) => group.options.map((option) => option.value));
|
||||
const emptySentinel = values.includes(EMPTY) ? `${EMPTY}_fallback` : EMPTY;
|
||||
const toRadix = (next?: string) => (next === "" ? emptySentinel : next);
|
||||
const fromRadix = (next: string) => (next === emptySentinel ? "" : next);
|
||||
|
||||
const handleValueChange = (next: string) => {
|
||||
const realValue = fromRadix(next);
|
||||
onValueChange?.(realValue);
|
||||
onChange?.({ target: { value: realValue } });
|
||||
};
|
||||
|
||||
return (
|
||||
<RS.Root
|
||||
{...rootProps}
|
||||
value={value === undefined ? undefined : toRadix(value)}
|
||||
defaultValue={defaultValue === undefined ? undefined : toRadix(defaultValue)}
|
||||
onValueChange={handle}
|
||||
disabled={disabled}
|
||||
name={name}
|
||||
onValueChange={handleValueChange}
|
||||
>
|
||||
<RS.Trigger ref={ref} id={id} aria-label={rest["aria-label"]} className={cn(selectVariants({ size }), className)}>
|
||||
<RS.Trigger
|
||||
{...triggerProps}
|
||||
ref={ref}
|
||||
id={id}
|
||||
className={cn(selectVariants({ size }), className)}
|
||||
>
|
||||
<span className="min-w-0 truncate text-left">
|
||||
<RS.Value placeholder={placeholder} />
|
||||
</span>
|
||||
@@ -97,26 +125,36 @@ export const Select = React.forwardRef<HTMLButtonElement, SelectProps>(
|
||||
sideOffset={6}
|
||||
className={cn(
|
||||
"z-50 max-h-[min(24rem,var(--radix-select-content-available-height))] min-w-[var(--radix-select-trigger-width)]",
|
||||
"overflow-hidden rounded-md border border-border bg-surface shadow-lg data-[state=open]:animate-scale-in"
|
||||
"overflow-hidden rounded-md border border-border bg-surface shadow-lg data-[state=open]:animate-scale-in",
|
||||
contentClassName
|
||||
)}
|
||||
>
|
||||
<RS.Viewport className="p-1">
|
||||
{options.map((o, i) => (
|
||||
<RS.Item
|
||||
key={`${o.value}-${i}`}
|
||||
value={toRadix(o.value)!}
|
||||
disabled={o.disabled}
|
||||
className={cn(
|
||||
"relative flex cursor-pointer select-none items-center rounded px-2 py-1.5 pr-8 text-sm text-text outline-none",
|
||||
"data-[highlighted]:bg-brand/15 data-[highlighted]:text-brand",
|
||||
"data-[disabled]:pointer-events-none data-[disabled]:opacity-50"
|
||||
{groups.map((group, groupIndex) => (
|
||||
<RS.Group key={groupIndex}>
|
||||
{group.label && (
|
||||
<RS.Label className="px-2 py-1.5 text-xs font-semibold uppercase tracking-wide text-muted">
|
||||
{group.label}
|
||||
</RS.Label>
|
||||
)}
|
||||
>
|
||||
<RS.ItemText>{o.label}</RS.ItemText>
|
||||
<RS.ItemIndicator className="absolute right-2 inline-flex">
|
||||
<Check className="size-4" />
|
||||
</RS.ItemIndicator>
|
||||
</RS.Item>
|
||||
{group.options.map((option, optionIndex) => (
|
||||
<RS.Item
|
||||
key={`${option.value}-${optionIndex}`}
|
||||
value={toRadix(option.value)!}
|
||||
disabled={option.disabled}
|
||||
className={cn(
|
||||
"relative flex cursor-pointer select-none items-center rounded px-2 py-1.5 pr-8 text-sm text-text outline-none",
|
||||
"data-[highlighted]:bg-brand/15 data-[highlighted]:text-brand",
|
||||
"data-[disabled]:pointer-events-none data-[disabled]:opacity-50"
|
||||
)}
|
||||
>
|
||||
<RS.ItemText>{option.label}</RS.ItemText>
|
||||
<RS.ItemIndicator className="absolute right-2 inline-flex">
|
||||
<Check className="size-4" />
|
||||
</RS.ItemIndicator>
|
||||
</RS.Item>
|
||||
))}
|
||||
</RS.Group>
|
||||
))}
|
||||
</RS.Viewport>
|
||||
</RS.Content>
|
||||
@@ -126,4 +164,5 @@ export const Select = React.forwardRef<HTMLButtonElement, SelectProps>(
|
||||
}
|
||||
);
|
||||
Select.displayName = "Select";
|
||||
|
||||
export { selectVariants };
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import * as React from "react";
|
||||
import { cn } from "../lib/cn";
|
||||
|
||||
export function Skeleton({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
|
||||
return (
|
||||
<div
|
||||
aria-hidden
|
||||
className={cn(
|
||||
"animate-pulse rounded bg-surface-2",
|
||||
"motion-reduce:animate-none",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as SwitchPrimitive from "@radix-ui/react-switch";
|
||||
import { cn } from "../lib/cn";
|
||||
|
||||
export const Switch = React.forwardRef<
|
||||
React.ElementRef<typeof SwitchPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof SwitchPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SwitchPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border border-border bg-surface-2 p-0.5 transition-colors",
|
||||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/60 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
"data-[state=checked]:border-brand data-[state=checked]:bg-brand",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<SwitchPrimitive.Thumb className="block size-5 rounded-full bg-text shadow-sm transition-transform data-[state=checked]:translate-x-5 data-[state=checked]:bg-brand-foreground" />
|
||||
</SwitchPrimitive.Root>
|
||||
));
|
||||
Switch.displayName = "Switch";
|
||||
@@ -0,0 +1,40 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as TabsPrimitive from "@radix-ui/react-tabs";
|
||||
import { cn } from "../lib/cn";
|
||||
|
||||
export const Tabs = TabsPrimitive.Root;
|
||||
|
||||
export const TabsList = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.List>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.List ref={ref} className={cn("inline-flex min-h-10 items-center rounded bg-surface-2 p-1", className)} {...props} />
|
||||
));
|
||||
TabsList.displayName = "TabsList";
|
||||
|
||||
export const TabsTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"inline-flex min-h-8 items-center justify-center rounded-sm px-3 text-sm font-medium text-muted transition-colors",
|
||||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/60 disabled:pointer-events-none disabled:opacity-50",
|
||||
"data-[state=active]:bg-surface data-[state=active]:text-text data-[state=active]:shadow-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TabsTrigger.displayName = "TabsTrigger";
|
||||
|
||||
export const TabsContent = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.Content ref={ref} className={cn("mt-4 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/60", className)} {...props} />
|
||||
));
|
||||
TabsContent.displayName = "TabsContent";
|
||||
+21
-15
@@ -1,20 +1,26 @@
|
||||
import { Toaster as Sonner, toast } from "sonner";
|
||||
import { useOptionalTheme } from "../lib/theme";
|
||||
|
||||
/** Pre-themed sonner Toaster. Drop <Toaster /> once near your app root. */
|
||||
export const Toaster = (props: React.ComponentProps<typeof Sonner>) => (
|
||||
<Sonner
|
||||
theme="dark"
|
||||
position="bottom-right"
|
||||
toastOptions={{
|
||||
classNames: {
|
||||
toast: "!bg-surface !border-border !text-text !rounded-lg !shadow-lg",
|
||||
description: "!text-muted",
|
||||
actionButton: "!bg-brand !text-bg",
|
||||
cancelButton: "!bg-surface-2 !text-muted",
|
||||
},
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
export const Toaster = ({ toastOptions, ...props }: React.ComponentProps<typeof Sonner>) => {
|
||||
const theme = useOptionalTheme();
|
||||
return (
|
||||
<Sonner
|
||||
theme={theme?.resolvedTheme ?? "system"}
|
||||
position="bottom-right"
|
||||
toastOptions={{
|
||||
...toastOptions,
|
||||
classNames: {
|
||||
toast: "!bg-surface !border-border !text-text !rounded-lg !shadow-lg",
|
||||
description: "!text-muted",
|
||||
actionButton: "!bg-brand !text-brand-foreground",
|
||||
cancelButton: "!bg-surface-2 !text-muted",
|
||||
...toastOptions?.classNames,
|
||||
},
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export { toast };
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
|
||||
import { cn } from "../lib/cn";
|
||||
|
||||
export const TooltipProvider = TooltipPrimitive.Provider;
|
||||
export const Tooltip = TooltipPrimitive.Root;
|
||||
export const TooltipTrigger = TooltipPrimitive.Trigger;
|
||||
|
||||
export const TooltipContent = React.forwardRef<
|
||||
React.ElementRef<typeof TooltipPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
|
||||
>(({ className, sideOffset = 6, ...props }, ref) => (
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipPrimitive.Content
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 max-w-xs rounded bg-text px-2.5 py-1.5 text-xs font-medium text-bg shadow-md",
|
||||
"data-[state=delayed-open]:animate-fade-in",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</TooltipPrimitive.Portal>
|
||||
));
|
||||
TooltipContent.displayName = "TooltipContent";
|
||||
+16
-1
@@ -1,7 +1,15 @@
|
||||
// Utilities
|
||||
export { cn } from "./lib/cn";
|
||||
export { tokens, type Tokens } from "./lib/tokens";
|
||||
export { setTheme, toggleTheme } from "./lib/theme";
|
||||
export {
|
||||
ThemeProvider,
|
||||
ThemeScript,
|
||||
setTheme,
|
||||
toggleTheme,
|
||||
useTheme,
|
||||
type Theme,
|
||||
type ResolvedTheme,
|
||||
} from "./lib/theme";
|
||||
|
||||
// Primitives
|
||||
export { Button, buttonVariants, type ButtonProps } from "./components/button";
|
||||
@@ -10,6 +18,13 @@ export { Input, inputVariants, type InputProps } from "./components/input";
|
||||
export { Select, selectVariants, type SelectProps } from "./components/select";
|
||||
export { Textarea, type TextareaProps } from "./components/textarea";
|
||||
export { Label } from "./components/label";
|
||||
export { FormField, type FormFieldProps } from "./components/form-field";
|
||||
export { Checkbox } from "./components/checkbox";
|
||||
export { Switch } from "./components/switch";
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent } from "./components/tabs";
|
||||
export { Tooltip, TooltipProvider, TooltipTrigger, TooltipContent } from "./components/tooltip";
|
||||
export { Skeleton } from "./components/skeleton";
|
||||
export { Pagination, type PaginationProps } from "./components/pagination";
|
||||
export { Badge, badgeVariants, type BadgeProps } from "./components/badge";
|
||||
export {
|
||||
Dialog, DialogTrigger, DialogClose, DialogContent,
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
/** Toggle the .light class on <html>. Dark is the default (:root). */
|
||||
export function setTheme(mode: "dark" | "light") {
|
||||
const el = document.documentElement;
|
||||
el.classList.toggle("light", mode === "light");
|
||||
}
|
||||
export function toggleTheme() {
|
||||
const isLight = document.documentElement.classList.toggle("light");
|
||||
return isLight ? "light" : "dark";
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
|
||||
export type Theme = "dark" | "light" | "system";
|
||||
export type ResolvedTheme = Exclude<Theme, "system">;
|
||||
|
||||
const DEFAULT_STORAGE_KEY = "jason-ui-theme";
|
||||
const mediaQuery = "(prefers-color-scheme: light)";
|
||||
|
||||
function systemTheme(): ResolvedTheme {
|
||||
return typeof window !== "undefined" && window.matchMedia(mediaQuery).matches ? "light" : "dark";
|
||||
}
|
||||
|
||||
function resolveTheme(theme: Theme): ResolvedTheme {
|
||||
return theme === "system" ? systemTheme() : theme;
|
||||
}
|
||||
|
||||
/** Apply a theme immediately. For persisted React state, use ThemeProvider. */
|
||||
export function setTheme(theme: Theme): ResolvedTheme {
|
||||
const resolved = resolveTheme(theme);
|
||||
if (typeof document === "undefined") return resolved;
|
||||
const root = document.documentElement;
|
||||
root.classList.toggle("light", resolved === "light");
|
||||
root.classList.toggle("dark", resolved === "dark");
|
||||
root.style.colorScheme = resolved;
|
||||
return resolved;
|
||||
}
|
||||
|
||||
/** Toggle the currently rendered mode. */
|
||||
export function toggleTheme(): ResolvedTheme {
|
||||
const current = typeof document !== "undefined" && document.documentElement.classList.contains("light");
|
||||
return setTheme(current ? "dark" : "light");
|
||||
}
|
||||
|
||||
export interface ThemeContextValue {
|
||||
theme: Theme;
|
||||
resolvedTheme: ResolvedTheme;
|
||||
setTheme: (theme: Theme) => void;
|
||||
toggleTheme: () => void;
|
||||
}
|
||||
|
||||
const ThemeContext = React.createContext<ThemeContextValue | null>(null);
|
||||
|
||||
export function useOptionalTheme() {
|
||||
return React.useContext(ThemeContext);
|
||||
}
|
||||
|
||||
export function ThemeProvider({
|
||||
children,
|
||||
defaultTheme = "system",
|
||||
storageKey = DEFAULT_STORAGE_KEY,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
defaultTheme?: Theme;
|
||||
storageKey?: string;
|
||||
}) {
|
||||
const [theme, updateTheme] = React.useState<Theme>(defaultTheme);
|
||||
const [resolvedTheme, updateResolvedTheme] = React.useState<ResolvedTheme>(() => resolveTheme(defaultTheme));
|
||||
|
||||
React.useEffect(() => {
|
||||
const stored = window.localStorage.getItem(storageKey);
|
||||
if (stored === "dark" || stored === "light" || stored === "system") updateTheme(stored);
|
||||
}, [storageKey]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const query = window.matchMedia(mediaQuery);
|
||||
const apply = () => updateResolvedTheme(setTheme(theme));
|
||||
apply();
|
||||
if (theme !== "system") return;
|
||||
query.addEventListener("change", apply);
|
||||
return () => query.removeEventListener("change", apply);
|
||||
}, [theme]);
|
||||
|
||||
const changeTheme = React.useCallback((next: Theme) => {
|
||||
window.localStorage.setItem(storageKey, next);
|
||||
updateTheme(next);
|
||||
}, [storageKey]);
|
||||
|
||||
const toggle = React.useCallback(() => {
|
||||
changeTheme(resolvedTheme === "light" ? "dark" : "light");
|
||||
}, [changeTheme, resolvedTheme]);
|
||||
|
||||
const value = React.useMemo(
|
||||
() => ({ theme, resolvedTheme, setTheme: changeTheme, toggleTheme: toggle }),
|
||||
[changeTheme, resolvedTheme, theme, toggle]
|
||||
);
|
||||
|
||||
return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
|
||||
}
|
||||
|
||||
export function useTheme(): ThemeContextValue {
|
||||
const context = useOptionalTheme();
|
||||
if (!context) throw new Error("useTheme must be used inside ThemeProvider");
|
||||
return context;
|
||||
}
|
||||
|
||||
/** Inline this before app CSS to avoid a light/dark flash during hydration. */
|
||||
export function ThemeScript({
|
||||
defaultTheme = "system",
|
||||
storageKey = DEFAULT_STORAGE_KEY,
|
||||
}: {
|
||||
defaultTheme?: Theme;
|
||||
storageKey?: string;
|
||||
}) {
|
||||
const script = `(()=>{try{const k=${JSON.stringify(storageKey)},d=${JSON.stringify(defaultTheme)},s=localStorage.getItem(k)||d,m=s==="system"?(matchMedia(${JSON.stringify(mediaQuery)}).matches?"light":"dark"):s,e=document.documentElement;e.classList.remove("light","dark");e.classList.add(m);e.style.colorScheme=m}catch{}})();`;
|
||||
return <script dangerouslySetInnerHTML={{ __html: script }} />;
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
{
|
||||
"color": {
|
||||
"dark": {
|
||||
"bg": "#232022",
|
||||
"surface": "#2B282A",
|
||||
"surface2": "#333031",
|
||||
"border": "#3A3638",
|
||||
"text": "#F5F1EC",
|
||||
"muted": "#A99F97",
|
||||
"brand": "#DCBB4F",
|
||||
"brandBright": "#F5CD15",
|
||||
"brandDark": "#998643",
|
||||
"brandForeground": "#232022",
|
||||
"success": "#62C890",
|
||||
"successForeground": "#232022",
|
||||
"warning": "#E4C85B",
|
||||
"warningForeground": "#232022",
|
||||
"danger": "#D76F4A",
|
||||
"dangerForeground": "#232022",
|
||||
"info": "#76A9D6",
|
||||
"infoForeground": "#232022"
|
||||
},
|
||||
"light": {
|
||||
"bg": "#FAF8F4",
|
||||
"surface": "#FFFFFF",
|
||||
"surface2": "#F4F0EA",
|
||||
"border": "#DED8D1",
|
||||
"text": "#272326",
|
||||
"muted": "#665F62",
|
||||
"brand": "#79640E",
|
||||
"brandBright": "#665308",
|
||||
"brandDark": "#5C4B07",
|
||||
"brandForeground": "#FFFFFF",
|
||||
"success": "#217A4A",
|
||||
"successForeground": "#FFFFFF",
|
||||
"warning": "#7A5B00",
|
||||
"warningForeground": "#FFFFFF",
|
||||
"danger": "#9B3D22",
|
||||
"dangerForeground": "#FFFFFF",
|
||||
"info": "#366B99",
|
||||
"infoForeground": "#FFFFFF"
|
||||
}
|
||||
},
|
||||
"radius": {
|
||||
"sm": "6px",
|
||||
"md": "10px",
|
||||
"lg": "14px",
|
||||
"xl": "20px"
|
||||
},
|
||||
"font": {
|
||||
"display": "\"Montserrat Variable\", \"Montserrat\", system-ui, sans-serif",
|
||||
"sans": "\"Open Sans Variable\", \"Open Sans\", system-ui, sans-serif",
|
||||
"mono": "\"JetBrains Mono Variable\", \"JetBrains Mono\", ui-monospace, monospace"
|
||||
},
|
||||
"shadow": {
|
||||
"sm": "0 1px 2px 0 rgb(20 16 18 / 0.20)",
|
||||
"md": "0 4px 12px -2px rgb(20 16 18 / 0.28), 0 2px 4px -2px rgb(20 16 18 / 0.20)",
|
||||
"lg": "0 12px 32px -8px rgb(20 16 18 / 0.40), 0 4px 8px -4px rgb(20 16 18 / 0.24)",
|
||||
"glow": "0 0 0 1px rgb(220 187 79 / 0.20), 0 8px 28px -6px rgb(220 187 79 / 0.28)"
|
||||
},
|
||||
"ease": {
|
||||
"out": "cubic-bezier(0.22, 1, 0.36, 1)",
|
||||
"inOut": "cubic-bezier(0.65, 0, 0.35, 1)"
|
||||
},
|
||||
"duration": {
|
||||
"fast": "120ms",
|
||||
"base": "180ms",
|
||||
"slow": "320ms"
|
||||
}
|
||||
}
|
||||
+7
-42
@@ -1,46 +1,11 @@
|
||||
/**
|
||||
* Design tokens — the single source of truth for the brand.
|
||||
* These mirror the CSS variables in styles/globals.css and feed the Tailwind preset.
|
||||
* Change the brand once, here, and every app that consumes the preset updates.
|
||||
*/
|
||||
import source from "./tokens.json";
|
||||
|
||||
/** Canonical brand values are maintained in tokens.json. */
|
||||
export const tokens = {
|
||||
color: {
|
||||
// Neutrals are tinted (warm), never pure black/gray — an Impeccable anti-slop rule.
|
||||
bg: "#232022",
|
||||
surface: "#2b282a",
|
||||
surface2: "#333031",
|
||||
border: "#3a3638",
|
||||
text: "#F5F1EC",
|
||||
muted: "#A99F97",
|
||||
// Brand — warm gold on warm dark.
|
||||
brand: "#DCBB4F",
|
||||
brandBright: "#F5CD15",
|
||||
brandDark: "#998643",
|
||||
// Semantic
|
||||
success: "#3F9E6A",
|
||||
warning: "#DCBB4F",
|
||||
danger: "#C0562F",
|
||||
info: "#5B8DB8",
|
||||
},
|
||||
radius: { sm: "6px", md: "10px", lg: "14px", xl: "20px" },
|
||||
font: {
|
||||
display: '"Montserrat", system-ui, sans-serif',
|
||||
sans: '"Open Sans", system-ui, sans-serif',
|
||||
mono: '"JetBrains Mono", ui-monospace, monospace',
|
||||
},
|
||||
// Layered, tinted shadows — depth instead of a flat plane.
|
||||
shadow: {
|
||||
sm: "0 1px 2px 0 rgb(20 16 18 / 0.20)",
|
||||
md: "0 4px 12px -2px rgb(20 16 18 / 0.28), 0 2px 4px -2px rgb(20 16 18 / 0.20)",
|
||||
lg: "0 12px 32px -8px rgb(20 16 18 / 0.40), 0 4px 8px -4px rgb(20 16 18 / 0.24)",
|
||||
glow: "0 0 0 1px rgb(220 187 79 / 0.20), 0 8px 28px -6px rgb(220 187 79 / 0.28)",
|
||||
},
|
||||
// Motion — smooth, never bouncy/elastic (Impeccable anti-slop rule).
|
||||
ease: {
|
||||
out: "cubic-bezier(0.22, 1, 0.36, 1)",
|
||||
inOut: "cubic-bezier(0.65, 0, 0.35, 1)",
|
||||
},
|
||||
duration: { fast: "120ms", base: "180ms", slow: "320ms" },
|
||||
...source,
|
||||
/** Backward-compatible default palette. Prefer themes for mode-aware work. */
|
||||
color: source.color.dark,
|
||||
themes: source.color,
|
||||
} as const;
|
||||
|
||||
export type Tokens = typeof tokens;
|
||||
|
||||
@@ -22,7 +22,7 @@ export function FadeIn({
|
||||
initial={reduce ? false : { opacity: 0, y }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true, margin: "-10% 0px" }}
|
||||
transition={{ duration: 0.4, ease: easeOut, delay }}
|
||||
transition={reduce ? { duration: 0 } : { duration: 0.4, ease: easeOut, delay }}
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
@@ -40,11 +40,12 @@ export const staggerItem: Variants = {
|
||||
};
|
||||
|
||||
export function Stagger({ children, className }: { children: React.ReactNode; className?: string }) {
|
||||
const reduce = useReducedMotion();
|
||||
return (
|
||||
<motion.div
|
||||
className={className}
|
||||
variants={staggerContainer}
|
||||
initial="hidden"
|
||||
variants={reduce ? undefined : staggerContainer}
|
||||
initial={reduce ? false : "hidden"}
|
||||
whileInView="show"
|
||||
viewport={{ once: true, margin: "-10% 0px" }}
|
||||
>
|
||||
@@ -53,7 +54,8 @@ export function Stagger({ children, className }: { children: React.ReactNode; cl
|
||||
);
|
||||
}
|
||||
export function StaggerItem({ children, className }: { children: React.ReactNode; className?: string }) {
|
||||
return <motion.div className={className} variants={staggerItem}>{children}</motion.div>;
|
||||
const reduce = useReducedMotion();
|
||||
return <motion.div className={className} variants={reduce ? undefined : staggerItem}>{children}</motion.div>;
|
||||
}
|
||||
|
||||
export { motion };
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
/* Optional, self-hosted Latin variable fonts. Import before styles.css. */
|
||||
@font-face {
|
||||
font-family: "Montserrat Variable";
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
font-weight: 100 900;
|
||||
src: url("@fontsource-variable/montserrat/files/montserrat-latin-wght-normal.woff2") format("woff2-variations");
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "Open Sans Variable";
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
font-weight: 300 800;
|
||||
src: url("@fontsource-variable/open-sans/files/open-sans-latin-wght-normal.woff2") format("woff2-variations");
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "JetBrains Mono Variable";
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
font-weight: 100 800;
|
||||
src: url("@fontsource-variable/jetbrains-mono/files/jetbrains-mono-latin-wght-normal.woff2") format("woff2-variations");
|
||||
}
|
||||
+16
-32
@@ -1,52 +1,36 @@
|
||||
/* Import once in your app entry: import "@jason/ui-kit/styles.css"; */
|
||||
/* Import once in your app entry: import "@jason/ui-kit/styles.css"; */
|
||||
@import "./theme.generated.css";
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
/*
|
||||
* Brand tokens as HSL channels so Tailwind's <alpha-value> works.
|
||||
* :root = dark (default brand). .light overrides for light surfaces.
|
||||
*/
|
||||
@layer base {
|
||||
:root {
|
||||
--bg: 330 4% 13%;
|
||||
--surface: 330 4% 16%;
|
||||
--surface-2: 330 3% 20%;
|
||||
--border: 330 3% 22%;
|
||||
--text: 33 33% 94%;
|
||||
--muted: 28 9% 63%;
|
||||
--brand: 46 68% 59%;
|
||||
--brand-bright: 51 91% 52%;
|
||||
--brand-dark: 46 40% 43%;
|
||||
--success: 146 43% 43%;
|
||||
--warning: 46 68% 59%;
|
||||
--danger: 17 61% 47%;
|
||||
--info: 205 37% 54%;
|
||||
}
|
||||
|
||||
.light {
|
||||
--bg: 36 33% 97%;
|
||||
--surface: 0 0% 100%;
|
||||
--surface-2: 36 20% 95%;
|
||||
--border: 33 12% 86%;
|
||||
--text: 330 6% 15%;
|
||||
--muted: 330 4% 40%;
|
||||
}
|
||||
|
||||
* { border-color: hsl(var(--border)); }
|
||||
html {
|
||||
color-scheme: dark;
|
||||
}
|
||||
html.light {
|
||||
color-scheme: light;
|
||||
}
|
||||
body {
|
||||
background-color: hsl(var(--bg));
|
||||
color: hsl(var(--text));
|
||||
font-family: "Open Sans", system-ui, sans-serif;
|
||||
font-family: "Open Sans Variable", "Open Sans", system-ui, sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
text-rendering: optimizeLegibility;
|
||||
}
|
||||
h1, h2, h3, h4, h5 { font-family: "Montserrat", system-ui, sans-serif; }
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
font-family: "Montserrat Variable", "Montserrat", system-ui, sans-serif;
|
||||
text-wrap: balance;
|
||||
}
|
||||
|
||||
/* Respect reduced-motion everywhere. */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*, *::before, *::after {
|
||||
animation-delay: 0s !important;
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
scroll-behavior: auto !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
/* Generated by scripts/generate-theme.mjs from src/lib/tokens.json. */
|
||||
:root, .dark {
|
||||
--bg: 320 4% 13%;
|
||||
--surface: 320 4% 16%;
|
||||
--surface-2: 340 3% 19%;
|
||||
--border: 330 4% 22%;
|
||||
--text: 33 31% 94%;
|
||||
--muted: 27 9% 63%;
|
||||
--brand: 46 67% 59%;
|
||||
--brand-bright: 49 92% 52%;
|
||||
--brand-dark: 47 39% 43%;
|
||||
--brand-foreground: 320 4% 13%;
|
||||
--success: 147 48% 58%;
|
||||
--success-foreground: 320 4% 13%;
|
||||
--warning: 48 72% 63%;
|
||||
--warning-foreground: 320 4% 13%;
|
||||
--danger: 16 64% 57%;
|
||||
--danger-foreground: 320 4% 13%;
|
||||
--info: 208 54% 65%;
|
||||
--info-foreground: 320 4% 13%;
|
||||
}
|
||||
|
||||
.light {
|
||||
--bg: 40 37% 97%;
|
||||
--surface: 0 0% 100%;
|
||||
--surface-2: 36 31% 94%;
|
||||
--border: 32 16% 85%;
|
||||
--text: 315 5% 15%;
|
||||
--muted: 334 4% 39%;
|
||||
--brand: 48 79% 26%;
|
||||
--brand-bright: 48 85% 22%;
|
||||
--brand-dark: 48 86% 19%;
|
||||
--brand-foreground: 0 0% 100%;
|
||||
--success: 148 57% 30%;
|
||||
--success-foreground: 0 0% 100%;
|
||||
--warning: 45 100% 24%;
|
||||
--warning-foreground: 0 0% 100%;
|
||||
--danger: 13 64% 37%;
|
||||
--danger-foreground: 0 0% 100%;
|
||||
--info: 208 48% 41%;
|
||||
--info-foreground: 0 0% 100%;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { tokens, type Tokens } from "./lib/tokens";
|
||||
Reference in New Issue
Block a user