diff --git a/src/App.tsx b/src/App.tsx index 8194f92..74abae3 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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(() => 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(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 ; + return ; } 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() {
{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 ( -
+
@@ -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; onSignup: (name: string, email: string, password: string) => Promise; }) { @@ -917,7 +961,7 @@ function AuthView({ } return ( -
+
@@ -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 (
+
+ {THEME_MODES.map((mode) => ( + + ))} +
+
Button
Panel
@@ -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({ - + diff --git a/src/components/ui/Badge.tsx b/src/components/ui/Badge.tsx new file mode 100644 index 0000000..8e54220 --- /dev/null +++ b/src/components/ui/Badge.tsx @@ -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; + +export function Badge({ tone = "neutral", className, children, ...rest }: BadgeProps) { + const classes = ["badge", tone !== "neutral" ? `badge-${tone}` : "", className].filter(Boolean).join(" "); + return ( + + {children} + + ); +} diff --git a/src/components/ui/Button.tsx b/src/components/ui/Button.tsx new file mode 100644 index 0000000..2dc4025 --- /dev/null +++ b/src/components/ui/Button.tsx @@ -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; + +const BUTTON_VARIANT_CLASSES: Record = { + 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 ( + + ); +} diff --git a/src/components/ui/Card.tsx b/src/components/ui/Card.tsx new file mode 100644 index 0000000..da10175 --- /dev/null +++ b/src/components/ui/Card.tsx @@ -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 ( +
+ {(title || subtitle || actions) && ( +
+
+ {title &&

{title}

} + {subtitle &&

{subtitle}

} +
+ {actions &&
{actions}
} +
+ )} + {children} +
+ ); +} diff --git a/src/components/ui/EmptyState.tsx b/src/components/ui/EmptyState.tsx new file mode 100644 index 0000000..8bb8b58 --- /dev/null +++ b/src/components/ui/EmptyState.tsx @@ -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 ( +
+
{icon ??
+

{title}

+

{body}

+ {action &&
{action}
} +
+ ); +} diff --git a/src/components/ui/Field.tsx b/src/components/ui/Field.tsx new file mode 100644 index 0000000..acd7b85 --- /dev/null +++ b/src/components/ui/Field.tsx @@ -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 ( +
+ +
+ {children({ + id, + "aria-invalid": error ? true : undefined, + "aria-describedby": messageId, + })} +
+ {message && ( +

+ {message} +

+ )} +
+ ); +} diff --git a/src/components/ui/index.ts b/src/components/ui/index.ts new file mode 100644 index 0000000..4933bc5 --- /dev/null +++ b/src/components/ui/index.ts @@ -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"; diff --git a/src/data/themes.ts b/src/data/themes.ts index dca7aac..96d71ce 100644 --- a/src/data/themes.ts +++ b/src/data/themes.ts @@ -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 = { @@ -50,7 +54,7 @@ const OLD_THEME_MAP: Record = { }; 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 = {}; + 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; diff --git a/src/index.css b/src/index.css index c9f18d9..f1b0cb3 100644 --- a/src/index.css +++ b/src/index.css @@ -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)); diff --git a/src/lib/themeTokens.ts b/src/lib/themeTokens.ts index 6c69468..f07ec85 100644 --- a/src/lib/themeTokens.ts +++ b/src/lib/themeTokens.ts @@ -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,