feat(ui): extend design tokens, add dark mode, add UI primitives

- Add warning/success token roles across light and dark themes
- Add Light/Dark/System theme mode (localStorage + Appwrite prefs)
- Derive dark token stacks from existing theme seeds
- Remove forced light app-shell background; dynamic color-scheme
- Add Button/Card/Field/Badge/EmptyState primitives under src/components/ui
This commit is contained in:
nh9961
2026-07-19 18:07:42 +00:00
parent 50783ccd45
commit 2cd8dd5130
10 changed files with 348 additions and 9 deletions
+17
View File
@@ -0,0 +1,17 @@
import type { HTMLAttributes, ReactNode } from "react";
export type BadgeTone = "neutral" | "accent" | "success" | "warning" | "danger";
export type BadgeProps = {
tone?: BadgeTone;
children: ReactNode;
} & HTMLAttributes<HTMLSpanElement>;
export function Badge({ tone = "neutral", className, children, ...rest }: BadgeProps) {
const classes = ["badge", tone !== "neutral" ? `badge-${tone}` : "", className].filter(Boolean).join(" ");
return (
<span className={classes} {...rest}>
{children}
</span>
);
}
+35
View File
@@ -0,0 +1,35 @@
import { Loader2 } from "lucide-react";
import type { ButtonHTMLAttributes, ReactNode } from "react";
export type ButtonVariant = "primary" | "secondary" | "danger";
export type ButtonProps = {
variant?: ButtonVariant;
loading?: boolean;
icon?: ReactNode;
} & ButtonHTMLAttributes<HTMLButtonElement>;
const BUTTON_VARIANT_CLASSES: Record<ButtonVariant, string> = {
primary: "primary-button",
secondary: "secondary-button",
danger: "danger-button",
};
export function Button({
variant = "primary",
loading = false,
icon,
className,
children,
disabled,
type = "button",
...rest
}: ButtonProps) {
const classes = [BUTTON_VARIANT_CLASSES[variant], className].filter(Boolean).join(" ");
return (
<button className={classes} type={type} disabled={disabled || loading} aria-busy={loading || undefined} {...rest}>
{loading ? <Loader2 className="animate-spin" size={17} aria-hidden="true" /> : icon}
{children}
</button>
);
}
+27
View File
@@ -0,0 +1,27 @@
import type { ReactNode } from "react";
export type CardProps = {
title?: string;
subtitle?: string;
actions?: ReactNode;
className?: string;
children: ReactNode;
};
export function Card({ title, subtitle, actions, className, children }: CardProps) {
const classes = ["app-card p-4 sm:p-5", className].filter(Boolean).join(" ");
return (
<section className={classes}>
{(title || subtitle || actions) && (
<div className="mb-4 flex items-start justify-between gap-3">
<div className="min-w-0">
{title && <h2 className="app-card-title text-xl">{title}</h2>}
{subtitle && <p className="app-card-subtitle mt-1">{subtitle}</p>}
</div>
{actions && <div className="flex shrink-0 items-center gap-2">{actions}</div>}
</div>
)}
{children}
</section>
);
}
+22
View File
@@ -0,0 +1,22 @@
import { Zap } from "lucide-react";
import type { ReactNode } from "react";
export type EmptyStateProps = {
icon?: ReactNode;
title: string;
body: string;
action?: ReactNode;
className?: string;
};
export function EmptyState({ icon, title, body, action, className }: EmptyStateProps) {
const classes = ["empty-state", className].filter(Boolean).join(" ");
return (
<div className={classes}>
<div className="empty-state-icon">{icon ?? <Zap size={22} aria-hidden="true" />}</div>
<h3 className="empty-state-title">{title}</h3>
<p className="empty-state-copy mt-2 max-w-sm">{body}</p>
{action && <div className="mt-4">{action}</div>}
</div>
);
}
+40
View File
@@ -0,0 +1,40 @@
import type { ReactNode } from "react";
export type FieldControlProps = {
id: string;
"aria-invalid": true | undefined;
"aria-describedby": string | undefined;
};
export type FieldProps = {
id: string;
label: string;
error?: string;
help?: string;
className?: string;
children: (controlProps: FieldControlProps) => ReactNode;
};
export function Field({ id, label, error, help, className, children }: FieldProps) {
const message = error ?? help;
const messageId = message ? `${id}-message` : undefined;
return (
<div className={className}>
<label className="field-label" htmlFor={id}>
{label}
</label>
<div className="mt-2">
{children({
id,
"aria-invalid": error ? true : undefined,
"aria-describedby": messageId,
})}
</div>
{message && (
<p id={messageId} className={error ? "field-error-text" : "field-help-text"}>
{message}
</p>
)}
</div>
);
}
+10
View File
@@ -0,0 +1,10 @@
export { Badge } from "./Badge";
export type { BadgeProps, BadgeTone } from "./Badge";
export { Button } from "./Button";
export type { ButtonProps, ButtonVariant } from "./Button";
export { Card } from "./Card";
export type { CardProps } from "./Card";
export { EmptyState } from "./EmptyState";
export type { EmptyStateProps } from "./EmptyState";
export { Field } from "./Field";
export type { FieldControlProps, FieldProps } from "./Field";