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
+76 -5
View File
@@ -61,11 +61,16 @@ import {
import { BUILT_IN_FLAVOURS, DEFAULT_FLAVOUR, accentForCustomFlavour, flavourMeta, mergedFlavours } from "./data/flavours";
import {
APP_THEMES,
THEME_MODE_STORAGE_KEY,
THEME_STORAGE_KEY,
getThemeById,
getThemeTokens,
normaliseThemeId,
normaliseThemeMode,
readStoredThemeId,
readStoredThemeMode,
type AppTheme,
type ThemeMode,
} from "./data/themes";
import { themeTokensToStyle } from "./lib/themeTokens";
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 },
];
const THEME_MODES: Array<{ id: ThemeMode; label: string }> = [
{ id: "light", label: "Light" },
{ id: "dark", label: "Dark" },
{ id: "system", label: "System" },
];
function App() {
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 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 [authLoading, setAuthLoading] = useState(true);
const [authError, setAuthError] = useState("");
@@ -214,6 +230,19 @@ function App() {
localStorage.setItem(THEME_STORAGE_KEY, normaliseThemeId(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) => {
if (showLoader) setDataLoading(true);
setSyncError("");
@@ -256,6 +285,9 @@ function App() {
if (typeof currentUser.prefs.themeId === "string" && 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"}.`);
if (!currentUser.prefs.onboarded) {
setSetupOpen(true);
@@ -325,6 +357,9 @@ function App() {
if (typeof currentUser.prefs.themeId === "string" && 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}.`);
if (!currentUser.prefs.onboarded) {
setSetupOpen(true);
@@ -422,6 +457,7 @@ function App() {
const nextPrefs = {
...limitsPrefs,
themeId: onboardingThemeId,
themeMode,
onboarded: true,
};
await account.updatePrefs(nextPrefs);
@@ -627,7 +663,7 @@ function App() {
}
if (authLoading) {
return <LoadingScreen setupStatus={setupStatus} shellStyle={shellStyle} themeId={themeId} />;
return <LoadingScreen setupStatus={setupStatus} shellStyle={shellStyle} themeId={themeId} resolvedMode={resolvedMode} />;
}
if (!user) {
@@ -638,6 +674,7 @@ function App() {
setupStatus={setupStatus}
shellStyle={shellStyle}
themeId={themeId}
resolvedMode={resolvedMode}
onLogin={login}
onSignup={signup}
/>
@@ -648,6 +685,7 @@ function App() {
<div
className="app-shell min-h-screen overflow-x-hidden"
data-theme={themeId}
data-mode={resolvedMode}
style={shellStyle}
>
{setupOpen && user && (
@@ -770,6 +808,7 @@ function App() {
notice={notice}
setupStatus={setupStatus}
themeId={themeId}
themeMode={themeMode}
user={user}
userLimits={userLimits}
limitCheck={limitCheck}
@@ -781,6 +820,7 @@ function App() {
onLogout={() => void logout()}
onReset={() => setIsResetOpen(true)}
onThemeChange={setThemeId}
onThemeModeChange={setThemeMode}
onSaveLimits={(next) => void saveUserLimits(next)}
onRerunOnboarding={() => setSetupOpen(true)}
/>
@@ -864,13 +904,15 @@ function LoadingScreen({
setupStatus,
shellStyle,
themeId,
resolvedMode,
}: {
setupStatus: SetupStatus;
shellStyle: CSSProperties;
themeId: string;
resolvedMode: "light" | "dark";
}) {
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 />
<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">
@@ -891,6 +933,7 @@ function AuthView({
setupStatus,
shellStyle,
themeId,
resolvedMode,
onLogin,
onSignup,
}: {
@@ -899,6 +942,7 @@ function AuthView({
setupStatus: SetupStatus;
shellStyle: CSSProperties;
themeId: string;
resolvedMode: "light" | "dark";
onLogin: (email: string, password: string) => Promise<void>;
onSignup: (name: string, email: string, password: string) => Promise<void>;
}) {
@@ -917,7 +961,7 @@ function AuthView({
}
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 />
<main className="flex min-h-screen items-center justify-center p-6">
<div className="auth-panel-shell">
@@ -980,15 +1024,38 @@ function AuthView({
function ThemePicker({
themeId,
themeMode,
onChange,
onThemeModeChange,
}: {
themeId: string;
themeMode: ThemeMode;
onChange: (id: string) => void;
onThemeModeChange: (mode: ThemeMode) => void;
}) {
const activeTheme = getThemeById(themeId);
return (
<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-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>
@@ -1884,6 +1951,7 @@ function SettingsView({
notice,
setupStatus,
themeId,
themeMode,
user,
userLimits,
limitCheck,
@@ -1895,6 +1963,7 @@ function SettingsView({
onLogout,
onReset,
onThemeChange,
onThemeModeChange,
onSaveLimits,
onRerunOnboarding,
}: {
@@ -1905,6 +1974,7 @@ function SettingsView({
notice: string;
setupStatus: SetupStatus;
themeId: string;
themeMode: ThemeMode;
user: AuthUser | null;
userLimits: UserLimits;
limitCheck: LimitCheckResult;
@@ -1916,6 +1986,7 @@ function SettingsView({
onLogout: () => void;
onReset: () => void;
onThemeChange: (id: string) => void;
onThemeModeChange: (mode: ThemeMode) => void;
onSaveLimits: (limits: UserLimits) => void;
onRerunOnboarding: () => void;
}) {
@@ -1942,7 +2013,7 @@ function SettingsView({
</AppCard>
<AppCard title="Appearance" subtitle={`${activeTheme.label} theme active`}>
<ThemePicker themeId={themeId} onChange={onThemeChange} />
<ThemePicker themeId={themeId} themeMode={themeMode} onChange={onThemeChange} onThemeModeChange={onThemeModeChange} />
</AppCard>
<AppCard title="Data & sync" subtitle={`${entries.length} entries synced for this user`}>
+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";
+29 -1
View File
@@ -4,12 +4,16 @@ export type AppTheme = {
id: string;
label: string;
swatch: string;
seed: ThemeSeed;
tokens: ThemeTokens;
};
export type ThemeMode = "light" | "dark" | "system";
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 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";
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 {
return { id, label, swatch, tokens: buildThemeTokens(seed) };
return { id, label, swatch, seed, tokens: buildThemeTokens(seed) };
}
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];
}
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 {
if (!id) return DEFAULT_THEME_ID;
if (APP_THEMES.some((entry) => entry.id === id)) return id;
+64 -3
View File
@@ -3,7 +3,9 @@
@tailwind utilities;
: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;
background: #eef3fb;
}
@@ -76,10 +78,15 @@ textarea:focus-visible {
.app-shell {
min-height: 100vh;
color: var(--text, #202124);
background:
radial-gradient(circle at 78% 12%, color-mix(in srgb, var(--primary-container, #dbe9ff) 42%, transparent), transparent 32rem),
#eef3fb !important;
color: #202124 !important;
var(--bg, #eef3fb);
color-scheme: light;
}
.app-shell[data-mode="dark"] {
color-scheme: dark;
}
.app-layout {
@@ -996,6 +1003,21 @@ textarea:focus-visible {
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 {
display: grid;
gap: 12px;
@@ -1085,6 +1107,45 @@ textarea:focus-visible {
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 {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
+28
View File
@@ -17,6 +17,14 @@ export type ThemeTokens = {
onError: string;
errorContainer: string;
onErrorContainer: string;
warning: string;
onWarning: string;
warningContainer: string;
onWarningContainer: string;
success: string;
onSuccess: string;
successContainer: string;
onSuccessContainer: string;
bg: string;
surface: string;
surfaceContainerLowest: string;
@@ -160,6 +168,10 @@ export function buildThemeTokens(seed: ThemeSeed): ThemeTokens {
const tertiaryContainer = containerColor(tertiary);
const error = "#ba1a1a";
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 = {
primary,
@@ -178,6 +190,14 @@ export function buildThemeTokens(seed: ThemeSeed): ThemeTokens {
onError: "#ffffff",
errorContainer,
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,
surface: surfaces.surface,
surfaceContainerLowest: surfaces.surfaceContainerLowest,
@@ -220,6 +240,14 @@ export function themeTokensToStyle(tokens: ThemeTokens): CSSProperties {
"--on-error": tokens.onError,
"--error-container": tokens.errorContainer,
"--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,
"--surface": tokens.surface,
"--surface-container-lowest": tokens.surfaceContainerLowest,