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:
+76
-5
@@ -61,11 +61,16 @@ import {
|
|||||||
import { BUILT_IN_FLAVOURS, DEFAULT_FLAVOUR, accentForCustomFlavour, flavourMeta, mergedFlavours } from "./data/flavours";
|
import { BUILT_IN_FLAVOURS, DEFAULT_FLAVOUR, accentForCustomFlavour, flavourMeta, mergedFlavours } from "./data/flavours";
|
||||||
import {
|
import {
|
||||||
APP_THEMES,
|
APP_THEMES,
|
||||||
|
THEME_MODE_STORAGE_KEY,
|
||||||
THEME_STORAGE_KEY,
|
THEME_STORAGE_KEY,
|
||||||
getThemeById,
|
getThemeById,
|
||||||
|
getThemeTokens,
|
||||||
normaliseThemeId,
|
normaliseThemeId,
|
||||||
|
normaliseThemeMode,
|
||||||
readStoredThemeId,
|
readStoredThemeId,
|
||||||
|
readStoredThemeMode,
|
||||||
type AppTheme,
|
type AppTheme,
|
||||||
|
type ThemeMode,
|
||||||
} from "./data/themes";
|
} from "./data/themes";
|
||||||
import { themeTokensToStyle } from "./lib/themeTokens";
|
import { themeTokensToStyle } from "./lib/themeTokens";
|
||||||
import { account, appwriteConfig, Channel, client, pingAppwrite } from "./lib/appwrite";
|
import { account, appwriteConfig, Channel, client, pingAppwrite } from "./lib/appwrite";
|
||||||
@@ -178,10 +183,21 @@ const NAV_ITEMS: Array<{ id: AppView; label: string; icon: LucideIcon }> = [
|
|||||||
{ id: "settings", label: "Settings", icon: Settings2 },
|
{ id: "settings", label: "Settings", icon: Settings2 },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const THEME_MODES: Array<{ id: ThemeMode; label: string }> = [
|
||||||
|
{ id: "light", label: "Light" },
|
||||||
|
{ id: "dark", label: "Dark" },
|
||||||
|
{ id: "system", label: "System" },
|
||||||
|
];
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const [themeId, setThemeId] = useState(() => readStoredThemeId());
|
const [themeId, setThemeId] = useState(() => readStoredThemeId());
|
||||||
|
const [themeMode, setThemeMode] = useState<ThemeMode>(() => readStoredThemeMode());
|
||||||
|
const [systemDark, setSystemDark] = useState(
|
||||||
|
() => typeof window !== "undefined" && window.matchMedia("(prefers-color-scheme: dark)").matches,
|
||||||
|
);
|
||||||
const activeTheme = useMemo(() => getThemeById(themeId), [themeId]);
|
const activeTheme = useMemo(() => getThemeById(themeId), [themeId]);
|
||||||
const shellStyle = useMemo(() => themeTokensToStyle(activeTheme.tokens), [activeTheme]);
|
const resolvedMode: "light" | "dark" = themeMode === "system" ? (systemDark ? "dark" : "light") : themeMode;
|
||||||
|
const shellStyle = useMemo(() => themeTokensToStyle(getThemeTokens(themeId, resolvedMode)), [themeId, resolvedMode]);
|
||||||
const [user, setUser] = useState<AuthUser | null>(null);
|
const [user, setUser] = useState<AuthUser | null>(null);
|
||||||
const [authLoading, setAuthLoading] = useState(true);
|
const [authLoading, setAuthLoading] = useState(true);
|
||||||
const [authError, setAuthError] = useState("");
|
const [authError, setAuthError] = useState("");
|
||||||
@@ -214,6 +230,19 @@ function App() {
|
|||||||
localStorage.setItem(THEME_STORAGE_KEY, normaliseThemeId(themeId));
|
localStorage.setItem(THEME_STORAGE_KEY, normaliseThemeId(themeId));
|
||||||
}, [themeId]);
|
}, [themeId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
localStorage.setItem(THEME_MODE_STORAGE_KEY, themeMode);
|
||||||
|
}, [themeMode]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof window === "undefined") return undefined;
|
||||||
|
const media = window.matchMedia("(prefers-color-scheme: dark)");
|
||||||
|
const handleChange = (event: MediaQueryListEvent) => setSystemDark(event.matches);
|
||||||
|
setSystemDark(media.matches);
|
||||||
|
media.addEventListener("change", handleChange);
|
||||||
|
return () => media.removeEventListener("change", handleChange);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const refreshEntries = useCallback(async (userId: string, showLoader = true) => {
|
const refreshEntries = useCallback(async (userId: string, showLoader = true) => {
|
||||||
if (showLoader) setDataLoading(true);
|
if (showLoader) setDataLoading(true);
|
||||||
setSyncError("");
|
setSyncError("");
|
||||||
@@ -256,6 +285,9 @@ function App() {
|
|||||||
if (typeof currentUser.prefs.themeId === "string" && currentUser.prefs.themeId) {
|
if (typeof currentUser.prefs.themeId === "string" && currentUser.prefs.themeId) {
|
||||||
setThemeId(normaliseThemeId(currentUser.prefs.themeId));
|
setThemeId(normaliseThemeId(currentUser.prefs.themeId));
|
||||||
}
|
}
|
||||||
|
if (typeof currentUser.prefs.themeMode === "string" && currentUser.prefs.themeMode) {
|
||||||
|
setThemeMode(normaliseThemeMode(currentUser.prefs.themeMode));
|
||||||
|
}
|
||||||
setNotice(`Signed in as ${currentUser.email || currentUser.name || "Appwrite user"}.`);
|
setNotice(`Signed in as ${currentUser.email || currentUser.name || "Appwrite user"}.`);
|
||||||
if (!currentUser.prefs.onboarded) {
|
if (!currentUser.prefs.onboarded) {
|
||||||
setSetupOpen(true);
|
setSetupOpen(true);
|
||||||
@@ -325,6 +357,9 @@ function App() {
|
|||||||
if (typeof currentUser.prefs.themeId === "string" && currentUser.prefs.themeId) {
|
if (typeof currentUser.prefs.themeId === "string" && currentUser.prefs.themeId) {
|
||||||
setThemeId(normaliseThemeId(currentUser.prefs.themeId));
|
setThemeId(normaliseThemeId(currentUser.prefs.themeId));
|
||||||
}
|
}
|
||||||
|
if (typeof currentUser.prefs.themeMode === "string" && currentUser.prefs.themeMode) {
|
||||||
|
setThemeMode(normaliseThemeMode(currentUser.prefs.themeMode));
|
||||||
|
}
|
||||||
setNotice(`Signed in as ${currentUser.email}.`);
|
setNotice(`Signed in as ${currentUser.email}.`);
|
||||||
if (!currentUser.prefs.onboarded) {
|
if (!currentUser.prefs.onboarded) {
|
||||||
setSetupOpen(true);
|
setSetupOpen(true);
|
||||||
@@ -422,6 +457,7 @@ function App() {
|
|||||||
const nextPrefs = {
|
const nextPrefs = {
|
||||||
...limitsPrefs,
|
...limitsPrefs,
|
||||||
themeId: onboardingThemeId,
|
themeId: onboardingThemeId,
|
||||||
|
themeMode,
|
||||||
onboarded: true,
|
onboarded: true,
|
||||||
};
|
};
|
||||||
await account.updatePrefs(nextPrefs);
|
await account.updatePrefs(nextPrefs);
|
||||||
@@ -627,7 +663,7 @@ function App() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (authLoading) {
|
if (authLoading) {
|
||||||
return <LoadingScreen setupStatus={setupStatus} shellStyle={shellStyle} themeId={themeId} />;
|
return <LoadingScreen setupStatus={setupStatus} shellStyle={shellStyle} themeId={themeId} resolvedMode={resolvedMode} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
@@ -638,6 +674,7 @@ function App() {
|
|||||||
setupStatus={setupStatus}
|
setupStatus={setupStatus}
|
||||||
shellStyle={shellStyle}
|
shellStyle={shellStyle}
|
||||||
themeId={themeId}
|
themeId={themeId}
|
||||||
|
resolvedMode={resolvedMode}
|
||||||
onLogin={login}
|
onLogin={login}
|
||||||
onSignup={signup}
|
onSignup={signup}
|
||||||
/>
|
/>
|
||||||
@@ -648,6 +685,7 @@ function App() {
|
|||||||
<div
|
<div
|
||||||
className="app-shell min-h-screen overflow-x-hidden"
|
className="app-shell min-h-screen overflow-x-hidden"
|
||||||
data-theme={themeId}
|
data-theme={themeId}
|
||||||
|
data-mode={resolvedMode}
|
||||||
style={shellStyle}
|
style={shellStyle}
|
||||||
>
|
>
|
||||||
{setupOpen && user && (
|
{setupOpen && user && (
|
||||||
@@ -770,6 +808,7 @@ function App() {
|
|||||||
notice={notice}
|
notice={notice}
|
||||||
setupStatus={setupStatus}
|
setupStatus={setupStatus}
|
||||||
themeId={themeId}
|
themeId={themeId}
|
||||||
|
themeMode={themeMode}
|
||||||
user={user}
|
user={user}
|
||||||
userLimits={userLimits}
|
userLimits={userLimits}
|
||||||
limitCheck={limitCheck}
|
limitCheck={limitCheck}
|
||||||
@@ -781,6 +820,7 @@ function App() {
|
|||||||
onLogout={() => void logout()}
|
onLogout={() => void logout()}
|
||||||
onReset={() => setIsResetOpen(true)}
|
onReset={() => setIsResetOpen(true)}
|
||||||
onThemeChange={setThemeId}
|
onThemeChange={setThemeId}
|
||||||
|
onThemeModeChange={setThemeMode}
|
||||||
onSaveLimits={(next) => void saveUserLimits(next)}
|
onSaveLimits={(next) => void saveUserLimits(next)}
|
||||||
onRerunOnboarding={() => setSetupOpen(true)}
|
onRerunOnboarding={() => setSetupOpen(true)}
|
||||||
/>
|
/>
|
||||||
@@ -864,13 +904,15 @@ function LoadingScreen({
|
|||||||
setupStatus,
|
setupStatus,
|
||||||
shellStyle,
|
shellStyle,
|
||||||
themeId,
|
themeId,
|
||||||
|
resolvedMode,
|
||||||
}: {
|
}: {
|
||||||
setupStatus: SetupStatus;
|
setupStatus: SetupStatus;
|
||||||
shellStyle: CSSProperties;
|
shellStyle: CSSProperties;
|
||||||
themeId: string;
|
themeId: string;
|
||||||
|
resolvedMode: "light" | "dark";
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="app-shell min-h-screen" data-theme={themeId} style={shellStyle}>
|
<div className="app-shell min-h-screen" data-theme={themeId} data-mode={resolvedMode} style={shellStyle}>
|
||||||
<ShellBackdrop />
|
<ShellBackdrop />
|
||||||
<div className="flex min-h-screen items-center justify-center p-6">
|
<div className="flex min-h-screen items-center justify-center p-6">
|
||||||
<div className="glass-panel w-full max-w-md p-6 text-center">
|
<div className="glass-panel w-full max-w-md p-6 text-center">
|
||||||
@@ -891,6 +933,7 @@ function AuthView({
|
|||||||
setupStatus,
|
setupStatus,
|
||||||
shellStyle,
|
shellStyle,
|
||||||
themeId,
|
themeId,
|
||||||
|
resolvedMode,
|
||||||
onLogin,
|
onLogin,
|
||||||
onSignup,
|
onSignup,
|
||||||
}: {
|
}: {
|
||||||
@@ -899,6 +942,7 @@ function AuthView({
|
|||||||
setupStatus: SetupStatus;
|
setupStatus: SetupStatus;
|
||||||
shellStyle: CSSProperties;
|
shellStyle: CSSProperties;
|
||||||
themeId: string;
|
themeId: string;
|
||||||
|
resolvedMode: "light" | "dark";
|
||||||
onLogin: (email: string, password: string) => Promise<void>;
|
onLogin: (email: string, password: string) => Promise<void>;
|
||||||
onSignup: (name: string, email: string, password: string) => Promise<void>;
|
onSignup: (name: string, email: string, password: string) => Promise<void>;
|
||||||
}) {
|
}) {
|
||||||
@@ -917,7 +961,7 @@ function AuthView({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="app-shell min-h-screen" data-theme={themeId} style={shellStyle}>
|
<div className="app-shell min-h-screen" data-theme={themeId} data-mode={resolvedMode} style={shellStyle}>
|
||||||
<ShellBackdrop />
|
<ShellBackdrop />
|
||||||
<main className="flex min-h-screen items-center justify-center p-6">
|
<main className="flex min-h-screen items-center justify-center p-6">
|
||||||
<div className="auth-panel-shell">
|
<div className="auth-panel-shell">
|
||||||
@@ -980,15 +1024,38 @@ function AuthView({
|
|||||||
|
|
||||||
function ThemePicker({
|
function ThemePicker({
|
||||||
themeId,
|
themeId,
|
||||||
|
themeMode,
|
||||||
onChange,
|
onChange,
|
||||||
|
onThemeModeChange,
|
||||||
}: {
|
}: {
|
||||||
themeId: string;
|
themeId: string;
|
||||||
|
themeMode: ThemeMode;
|
||||||
onChange: (id: string) => void;
|
onChange: (id: string) => void;
|
||||||
|
onThemeModeChange: (mode: ThemeMode) => void;
|
||||||
}) {
|
}) {
|
||||||
const activeTheme = getThemeById(themeId);
|
const activeTheme = getThemeById(themeId);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="settings-section">
|
<div className="settings-section">
|
||||||
|
<div
|
||||||
|
className="segmented-control max-w-xs"
|
||||||
|
role="group"
|
||||||
|
aria-label="Colour mode"
|
||||||
|
style={{ gridTemplateColumns: "repeat(3, minmax(0, 1fr))" }}
|
||||||
|
>
|
||||||
|
{THEME_MODES.map((mode) => (
|
||||||
|
<button
|
||||||
|
key={mode.id}
|
||||||
|
type="button"
|
||||||
|
aria-pressed={themeMode === mode.id}
|
||||||
|
className={themeMode === mode.id ? "segmented-control-active" : ""}
|
||||||
|
onClick={() => onThemeModeChange(mode.id)}
|
||||||
|
>
|
||||||
|
{mode.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="theme-preview-strip">
|
<div className="theme-preview-strip">
|
||||||
<div className="theme-preview-chip primary-button px-4 py-2 text-sm">Button</div>
|
<div className="theme-preview-chip primary-button px-4 py-2 text-sm">Button</div>
|
||||||
<div className="theme-preview-chip glass-panel px-4 py-2 text-sm">Panel</div>
|
<div className="theme-preview-chip glass-panel px-4 py-2 text-sm">Panel</div>
|
||||||
@@ -1884,6 +1951,7 @@ function SettingsView({
|
|||||||
notice,
|
notice,
|
||||||
setupStatus,
|
setupStatus,
|
||||||
themeId,
|
themeId,
|
||||||
|
themeMode,
|
||||||
user,
|
user,
|
||||||
userLimits,
|
userLimits,
|
||||||
limitCheck,
|
limitCheck,
|
||||||
@@ -1895,6 +1963,7 @@ function SettingsView({
|
|||||||
onLogout,
|
onLogout,
|
||||||
onReset,
|
onReset,
|
||||||
onThemeChange,
|
onThemeChange,
|
||||||
|
onThemeModeChange,
|
||||||
onSaveLimits,
|
onSaveLimits,
|
||||||
onRerunOnboarding,
|
onRerunOnboarding,
|
||||||
}: {
|
}: {
|
||||||
@@ -1905,6 +1974,7 @@ function SettingsView({
|
|||||||
notice: string;
|
notice: string;
|
||||||
setupStatus: SetupStatus;
|
setupStatus: SetupStatus;
|
||||||
themeId: string;
|
themeId: string;
|
||||||
|
themeMode: ThemeMode;
|
||||||
user: AuthUser | null;
|
user: AuthUser | null;
|
||||||
userLimits: UserLimits;
|
userLimits: UserLimits;
|
||||||
limitCheck: LimitCheckResult;
|
limitCheck: LimitCheckResult;
|
||||||
@@ -1916,6 +1986,7 @@ function SettingsView({
|
|||||||
onLogout: () => void;
|
onLogout: () => void;
|
||||||
onReset: () => void;
|
onReset: () => void;
|
||||||
onThemeChange: (id: string) => void;
|
onThemeChange: (id: string) => void;
|
||||||
|
onThemeModeChange: (mode: ThemeMode) => void;
|
||||||
onSaveLimits: (limits: UserLimits) => void;
|
onSaveLimits: (limits: UserLimits) => void;
|
||||||
onRerunOnboarding: () => void;
|
onRerunOnboarding: () => void;
|
||||||
}) {
|
}) {
|
||||||
@@ -1942,7 +2013,7 @@ function SettingsView({
|
|||||||
</AppCard>
|
</AppCard>
|
||||||
|
|
||||||
<AppCard title="Appearance" subtitle={`${activeTheme.label} theme active`}>
|
<AppCard title="Appearance" subtitle={`${activeTheme.label} theme active`}>
|
||||||
<ThemePicker themeId={themeId} onChange={onThemeChange} />
|
<ThemePicker themeId={themeId} themeMode={themeMode} onChange={onThemeChange} onThemeModeChange={onThemeModeChange} />
|
||||||
</AppCard>
|
</AppCard>
|
||||||
|
|
||||||
<AppCard title="Data & sync" subtitle={`${entries.length} entries synced for this user`}>
|
<AppCard title="Data & sync" subtitle={`${entries.length} entries synced for this user`}>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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";
|
||||||
+29
-1
@@ -4,12 +4,16 @@ export type AppTheme = {
|
|||||||
id: string;
|
id: string;
|
||||||
label: string;
|
label: string;
|
||||||
swatch: string;
|
swatch: string;
|
||||||
|
seed: ThemeSeed;
|
||||||
tokens: ThemeTokens;
|
tokens: ThemeTokens;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type ThemeMode = "light" | "dark" | "system";
|
||||||
|
|
||||||
export const THEME_STORAGE_KEY = "red-bull-intake-tracker.theme.v2";
|
export const THEME_STORAGE_KEY = "red-bull-intake-tracker.theme.v2";
|
||||||
export const OLD_THEME_STORAGE_KEY = "red-bull-intake-tracker.theme.v1";
|
export const OLD_THEME_STORAGE_KEY = "red-bull-intake-tracker.theme.v1";
|
||||||
export const LEGACY_ACCENT_STORAGE_KEY = "red-bull-intake-tracker.accent.v1";
|
export const LEGACY_ACCENT_STORAGE_KEY = "red-bull-intake-tracker.accent.v1";
|
||||||
|
export const THEME_MODE_STORAGE_KEY = "red-bull-intake-tracker.theme-mode.v1";
|
||||||
export const DEFAULT_THEME_ID = "mist";
|
export const DEFAULT_THEME_ID = "mist";
|
||||||
|
|
||||||
const OLD_THEME_MAP: Record<string, string> = {
|
const OLD_THEME_MAP: Record<string, string> = {
|
||||||
@@ -50,7 +54,7 @@ const OLD_THEME_MAP: Record<string, string> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
function theme(id: string, label: string, swatch: string, seed: ThemeSeed): AppTheme {
|
function theme(id: string, label: string, swatch: string, seed: ThemeSeed): AppTheme {
|
||||||
return { id, label, swatch, tokens: buildThemeTokens(seed) };
|
return { id, label, swatch, seed, tokens: buildThemeTokens(seed) };
|
||||||
}
|
}
|
||||||
|
|
||||||
export const APP_THEMES: AppTheme[] = [
|
export const APP_THEMES: AppTheme[] = [
|
||||||
@@ -97,6 +101,30 @@ export function getThemeById(id: string): AppTheme {
|
|||||||
return APP_THEMES.find((entry) => entry.id === id) ?? APP_THEMES[0];
|
return APP_THEMES.find((entry) => entry.id === id) ?? APP_THEMES[0];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getThemeTokens(id: string, mode: "light" | "dark"): ThemeTokens {
|
||||||
|
const entry = getThemeById(id);
|
||||||
|
if (mode === "light") return entry.tokens;
|
||||||
|
|
||||||
|
// Dark mode drops the light-only explicit overrides; chart colours that
|
||||||
|
// still read well on dark surfaces are carried over.
|
||||||
|
const overrides = entry.seed.tokens;
|
||||||
|
const chartOverrides: Partial<ThemeTokens> = {};
|
||||||
|
if (overrides?.chartPrimary) chartOverrides.chartPrimary = overrides.chartPrimary;
|
||||||
|
if (overrides?.chartSecondary) chartOverrides.chartSecondary = overrides.chartSecondary;
|
||||||
|
if (overrides?.chartTertiary) chartOverrides.chartTertiary = overrides.chartTertiary;
|
||||||
|
return buildThemeTokens({ ...entry.seed, dark: true, tokens: chartOverrides });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normaliseThemeMode(value: string | null | undefined): ThemeMode {
|
||||||
|
if (value === "light" || value === "dark" || value === "system") return value;
|
||||||
|
return "system";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readStoredThemeMode(): ThemeMode {
|
||||||
|
if (typeof window === "undefined") return "system";
|
||||||
|
return normaliseThemeMode(localStorage.getItem(THEME_MODE_STORAGE_KEY));
|
||||||
|
}
|
||||||
|
|
||||||
export function normaliseThemeId(id: string | null | undefined): string {
|
export function normaliseThemeId(id: string | null | undefined): string {
|
||||||
if (!id) return DEFAULT_THEME_ID;
|
if (!id) return DEFAULT_THEME_ID;
|
||||||
if (APP_THEMES.some((entry) => entry.id === id)) return id;
|
if (APP_THEMES.some((entry) => entry.id === id)) return id;
|
||||||
|
|||||||
+64
-3
@@ -3,7 +3,9 @@
|
|||||||
@tailwind utilities;
|
@tailwind utilities;
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
color-scheme: light;
|
--radius-sm: 10px;
|
||||||
|
--radius-md: 14px;
|
||||||
|
--radius-lg: 20px;
|
||||||
font-family: "Google Sans", "SF Pro Display", Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
font-family: "Google Sans", "SF Pro Display", Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||||
background: #eef3fb;
|
background: #eef3fb;
|
||||||
}
|
}
|
||||||
@@ -76,10 +78,15 @@ textarea:focus-visible {
|
|||||||
|
|
||||||
.app-shell {
|
.app-shell {
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
|
color: var(--text, #202124);
|
||||||
background:
|
background:
|
||||||
radial-gradient(circle at 78% 12%, color-mix(in srgb, var(--primary-container, #dbe9ff) 42%, transparent), transparent 32rem),
|
radial-gradient(circle at 78% 12%, color-mix(in srgb, var(--primary-container, #dbe9ff) 42%, transparent), transparent 32rem),
|
||||||
#eef3fb !important;
|
var(--bg, #eef3fb);
|
||||||
color: #202124 !important;
|
color-scheme: light;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-shell[data-mode="dark"] {
|
||||||
|
color-scheme: dark;
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-layout {
|
.app-layout {
|
||||||
@@ -996,6 +1003,21 @@ textarea:focus-visible {
|
|||||||
color: #80868b;
|
color: #80868b;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.field-help-text,
|
||||||
|
.field-error-text {
|
||||||
|
margin-top: 6px;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-help-text {
|
||||||
|
color: var(--muted, #5f6670);
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-error-text {
|
||||||
|
color: var(--error, #ba1a1a);
|
||||||
|
}
|
||||||
|
|
||||||
.entry-row {
|
.entry-row {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
@@ -1085,6 +1107,45 @@ textarea:focus-visible {
|
|||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
display: inline-flex;
|
||||||
|
min-height: 26px;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 0 10px;
|
||||||
|
color: var(--muted, #5f6670);
|
||||||
|
background: var(--surface-container-high, #f1f4f9);
|
||||||
|
border: 1px solid var(--outline-variant, #dce5f1);
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-accent {
|
||||||
|
color: var(--on-primary-container, #10243f);
|
||||||
|
background: var(--primary-container, #dbe9ff);
|
||||||
|
border-color: color-mix(in srgb, var(--primary, #2563c7) 24%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-success {
|
||||||
|
color: var(--on-success-container, #03210f);
|
||||||
|
background: var(--success-container, #ceead6);
|
||||||
|
border-color: color-mix(in srgb, var(--success, #0d652d) 24%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-warning {
|
||||||
|
color: var(--on-warning-container, #291800);
|
||||||
|
background: var(--warning-container, #ffddb0);
|
||||||
|
border-color: color-mix(in srgb, var(--warning, #8d5700) 24%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-danger {
|
||||||
|
color: var(--on-error-container, #410002);
|
||||||
|
background: var(--error-container, #ffdad6);
|
||||||
|
border-color: color-mix(in srgb, var(--error, #ba1a1a) 24%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
.segmented-control {
|
.segmented-control {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
|
|||||||
@@ -17,6 +17,14 @@ export type ThemeTokens = {
|
|||||||
onError: string;
|
onError: string;
|
||||||
errorContainer: string;
|
errorContainer: string;
|
||||||
onErrorContainer: string;
|
onErrorContainer: string;
|
||||||
|
warning: string;
|
||||||
|
onWarning: string;
|
||||||
|
warningContainer: string;
|
||||||
|
onWarningContainer: string;
|
||||||
|
success: string;
|
||||||
|
onSuccess: string;
|
||||||
|
successContainer: string;
|
||||||
|
onSuccessContainer: string;
|
||||||
bg: string;
|
bg: string;
|
||||||
surface: string;
|
surface: string;
|
||||||
surfaceContainerLowest: string;
|
surfaceContainerLowest: string;
|
||||||
@@ -160,6 +168,10 @@ export function buildThemeTokens(seed: ThemeSeed): ThemeTokens {
|
|||||||
const tertiaryContainer = containerColor(tertiary);
|
const tertiaryContainer = containerColor(tertiary);
|
||||||
const error = "#ba1a1a";
|
const error = "#ba1a1a";
|
||||||
const errorContainer = "#ffdad6";
|
const errorContainer = "#ffdad6";
|
||||||
|
const warning = dark ? "#fbbc04" : "#8d5700";
|
||||||
|
const warningContainer = dark ? "#594400" : "#ffddb0";
|
||||||
|
const success = dark ? "#81c995" : "#0d652d";
|
||||||
|
const successContainer = dark ? "#0f5223" : "#ceead6";
|
||||||
|
|
||||||
const tokens: ThemeTokens = {
|
const tokens: ThemeTokens = {
|
||||||
primary,
|
primary,
|
||||||
@@ -178,6 +190,14 @@ export function buildThemeTokens(seed: ThemeSeed): ThemeTokens {
|
|||||||
onError: "#ffffff",
|
onError: "#ffffff",
|
||||||
errorContainer,
|
errorContainer,
|
||||||
onErrorContainer: "#410002",
|
onErrorContainer: "#410002",
|
||||||
|
warning,
|
||||||
|
onWarning: dark ? "#3a2a00" : "#ffffff",
|
||||||
|
warningContainer,
|
||||||
|
onWarningContainer: dark ? "#ffe1a6" : "#291800",
|
||||||
|
success,
|
||||||
|
onSuccess: dark ? "#07391c" : "#ffffff",
|
||||||
|
successContainer,
|
||||||
|
onSuccessContainer: dark ? "#ceead6" : "#03210f",
|
||||||
bg: surfaces.bg,
|
bg: surfaces.bg,
|
||||||
surface: surfaces.surface,
|
surface: surfaces.surface,
|
||||||
surfaceContainerLowest: surfaces.surfaceContainerLowest,
|
surfaceContainerLowest: surfaces.surfaceContainerLowest,
|
||||||
@@ -220,6 +240,14 @@ export function themeTokensToStyle(tokens: ThemeTokens): CSSProperties {
|
|||||||
"--on-error": tokens.onError,
|
"--on-error": tokens.onError,
|
||||||
"--error-container": tokens.errorContainer,
|
"--error-container": tokens.errorContainer,
|
||||||
"--on-error-container": tokens.onErrorContainer,
|
"--on-error-container": tokens.onErrorContainer,
|
||||||
|
"--warning": tokens.warning,
|
||||||
|
"--on-warning": tokens.onWarning,
|
||||||
|
"--warning-container": tokens.warningContainer,
|
||||||
|
"--on-warning-container": tokens.onWarningContainer,
|
||||||
|
"--success": tokens.success,
|
||||||
|
"--on-success": tokens.onSuccess,
|
||||||
|
"--success-container": tokens.successContainer,
|
||||||
|
"--on-success-container": tokens.onSuccessContainer,
|
||||||
"--bg": tokens.bg,
|
"--bg": tokens.bg,
|
||||||
"--surface": tokens.surface,
|
"--surface": tokens.surface,
|
||||||
"--surface-container-lowest": tokens.surfaceContainerLowest,
|
"--surface-container-lowest": tokens.surfaceContainerLowest,
|
||||||
|
|||||||
Reference in New Issue
Block a user