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 {
|
||||
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`}>
|
||||
|
||||
Reference in New Issue
Block a user