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`}>