Merge pull request #64 from LINUXexpert-org/theme-ihasmail
Add the ihasmail theme, and make it the default
This commit is contained in:
@@ -62,7 +62,7 @@ seconds of downtime with nothing lost.
|
||||
## Features
|
||||
|
||||
**Mail**
|
||||
- Gmail-style three-pane layout (reading pane right/bottom/off, **drag-to-resize splitter** in both orientations, quick layout switch in the list menu), conversation view with collapsed messages and "show quoted text", dense/cozy/comfortable density, light/dark/system theme with accent colours
|
||||
- Gmail-style three-pane layout (reading pane right/bottom/off, **drag-to-resize splitter** in both orientations, quick layout switch in the list menu), conversation view with collapsed messages and "show quoted text", dense/cozy/comfortable density, light/dark/system themes plus **ihasmail** — the palette from ihasmail.org, and what a new account starts on — each with accent colours over the top
|
||||
- Virtualised, infinitely-scrolling message list; multi-select (click, ⇧-click, ⌃-click), drag & drop to folders, right-click context menus, hover actions, Gmail keyboard shortcuts (`j/k`, `e`, `#`, `r/a/f`, `g i`, `/`, `?` …)
|
||||
- Archive / delete / spam / star / mark read / move / labels (IMAP keywords with colours) with **Undo**
|
||||
- **"Filter messages like this…"** from the message context menu: creates a Sieve rule pre-filled from the sender/list (target folders can be created on the fly), and can **apply it immediately to the existing messages in the folder** (evaluated client-side, actions applied via JMAP)
|
||||
|
||||
+11
-2
@@ -4,8 +4,17 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<meta name="color-scheme" content="light dark" />
|
||||
<meta name="theme-color" content="#0f766e" media="(prefers-color-scheme: light)" />
|
||||
<meta name="theme-color" content="#0b1220" media="(prefers-color-scheme: dark)" />
|
||||
<!--
|
||||
One tag, no media query: applyTheme() keeps it in step with the chosen
|
||||
theme, which a media query cannot do — it only knows what the OS prefers,
|
||||
not what the user picked here. There used to be two, both with media
|
||||
attributes, which meant the selector in applyTheme (:not([media])) matched
|
||||
neither and the colour never moved off whatever the OS implied.
|
||||
|
||||
The initial value is the default theme's background, so the browser chrome
|
||||
is right from the first paint rather than only once JS has run.
|
||||
-->
|
||||
<meta name="theme-color" content="#0d2430" />
|
||||
<meta name="description" content="ihasmail - fast, friendly JMAP webmail for Stalwart" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { DEFAULT_SETTINGS, DEVICE_KEYS, acceptRemote, isDarkTheme, syncedPart, toggleTarget, useSettings, type Theme } from "@/store/settings";
|
||||
import { loadJson, saveJson } from "@/lib/storage";
|
||||
|
||||
/**
|
||||
* "ihasmail" is a dark theme wearing ihasmail.org's palette. Everything that
|
||||
* asks "is this dark?" has to say yes for it — the top-bar toggle picks its
|
||||
* icon from the answer, and the message frame decides whether mail sits on a
|
||||
* light card or follows the app. A theme that painted dark while reporting
|
||||
* light would show a sun icon on a dark screen and light-card mail on it.
|
||||
*/
|
||||
|
||||
describe("which themes paint dark", () => {
|
||||
it("counts ihasmail as dark, regardless of the OS", () => {
|
||||
expect(isDarkTheme("ihasmail", false)).toBe(true);
|
||||
expect(isDarkTheme("ihasmail", true)).toBe(true);
|
||||
});
|
||||
|
||||
it("still resolves the ordinary three the way it always did", () => {
|
||||
expect(isDarkTheme("dark", false)).toBe(true);
|
||||
expect(isDarkTheme("light", true)).toBe(false);
|
||||
expect(isDarkTheme("system", true)).toBe(true);
|
||||
expect(isDarkTheme("system", false)).toBe(false);
|
||||
});
|
||||
|
||||
it("treats a missing OS preference as light, not as unknown", () => {
|
||||
// matchMedia is absent in some embeddings; the default must not read dark.
|
||||
expect(isDarkTheme("system")).toBe(false);
|
||||
});
|
||||
|
||||
it("has an answer for every theme there is", () => {
|
||||
// A theme added later without a branch here would silently paint light.
|
||||
const all: Theme[] = ["system", "light", "dark", "ihasmail"];
|
||||
for (const t of all) expect(typeof isDarkTheme(t, false), t).toBe("boolean");
|
||||
});
|
||||
});
|
||||
|
||||
describe("the default theme", () => {
|
||||
it("is ihasmail, so a new account looks like ihasmail before anyone chooses", () => {
|
||||
expect(DEFAULT_SETTINGS.theme).toBe("ihasmail");
|
||||
});
|
||||
|
||||
/**
|
||||
* The guarantee that matters when a default changes: it moves nobody who
|
||||
* already has a theme stored — which is everyone using ihasmail today, since
|
||||
* the setting is saved whether or not they deliberately picked it.
|
||||
*
|
||||
* `localStorage` is not available in this environment, and `saveJson`
|
||||
* swallows that, so a plain round-trip here would pass for the wrong reason:
|
||||
* both sides would be the fallback. Stub it, so what is under test is
|
||||
* `loadJson`'s merge rather than the environment.
|
||||
*/
|
||||
const withStorage = (fn: () => void) => {
|
||||
const store = new Map<string, string>();
|
||||
Object.defineProperty(globalThis, "localStorage", {
|
||||
configurable: true,
|
||||
value: {
|
||||
getItem: (k: string) => store.get(k) ?? null,
|
||||
setItem: (k: string, v: string) => void store.set(k, v),
|
||||
removeItem: (k: string) => void store.delete(k),
|
||||
},
|
||||
});
|
||||
try {
|
||||
fn();
|
||||
} finally {
|
||||
Reflect.deleteProperty(globalThis, "localStorage");
|
||||
}
|
||||
};
|
||||
|
||||
it("is only a default — a stored theme wins", () => {
|
||||
withStorage(() => {
|
||||
saveJson("theme-test", { ...DEFAULT_SETTINGS, theme: "light" });
|
||||
expect(loadJson("theme-test", DEFAULT_SETTINGS).theme).toBe("light");
|
||||
});
|
||||
});
|
||||
|
||||
it("fills in from the default only for keys the stored settings lack", () => {
|
||||
withStorage(() => {
|
||||
// An older settings blob that predates a key must not lose the new one.
|
||||
saveJson("theme-test-partial", { theme: "dark" });
|
||||
const loaded = loadJson("theme-test-partial", DEFAULT_SETTINGS);
|
||||
expect(loaded.theme).toBe("dark");
|
||||
expect(loaded.accent).toBe(DEFAULT_SETTINGS.accent);
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to the default when nothing is stored", () => {
|
||||
withStorage(() => {
|
||||
expect(loadJson("theme-test-absent", DEFAULT_SETTINGS).theme).toBe("ihasmail");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("the top-bar toggle", () => {
|
||||
it("goes to light from anything dark", () => {
|
||||
expect(toggleTarget("dark", "ihasmail")).toBe("light");
|
||||
expect(toggleTarget("dark", "dark")).toBe("light");
|
||||
expect(toggleTarget("dark", "system")).toBe("light");
|
||||
});
|
||||
|
||||
it("comes back to the theme you were actually on", () => {
|
||||
// The whole point: two clicks from ihasmail must return to ihasmail, not
|
||||
// deposit you on plain dark.
|
||||
expect(toggleTarget("light", "ihasmail")).toBe("ihasmail");
|
||||
expect(toggleTarget("light", "dark")).toBe("dark");
|
||||
});
|
||||
|
||||
it("can bring back \"match system\", which the toggle used to strand", () => {
|
||||
expect(toggleTarget("light", "system")).toBe("system");
|
||||
});
|
||||
|
||||
it("round-trips every dark theme there is", () => {
|
||||
for (const t of ["dark", "ihasmail", "system"] as const) {
|
||||
expect(toggleTarget(toggleTarget("light", t) === "light" ? "light" : "dark", t), t).toBe("light");
|
||||
expect(toggleTarget("light", t), t).toBe(t);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("remembering which dark theme you were on", () => {
|
||||
const setTheme = (t: Theme) => {
|
||||
useSettings.getState().update({ theme: t });
|
||||
return useSettings.getState().settings;
|
||||
};
|
||||
|
||||
it("records a dark theme chosen from Settings, not just from the toggle", () => {
|
||||
// update() is the single path every way of choosing a theme goes through,
|
||||
// which is why the remembering lives there rather than at the call sites.
|
||||
expect(setTheme("dark").lastDarkTheme).toBe("dark");
|
||||
expect(setTheme("ihasmail").lastDarkTheme).toBe("ihasmail");
|
||||
expect(setTheme("system").lastDarkTheme).toBe("system");
|
||||
});
|
||||
|
||||
it("does not let light overwrite it — that is the theme being toggled away from", () => {
|
||||
setTheme("ihasmail");
|
||||
expect(setTheme("light").lastDarkTheme).toBe("ihasmail");
|
||||
});
|
||||
|
||||
it("survives a there-and-back through the toggle", () => {
|
||||
setTheme("ihasmail");
|
||||
const away = setTheme(toggleTarget("dark", useSettings.getState().settings.lastDarkTheme));
|
||||
expect(away.theme).toBe("light");
|
||||
const back = setTheme(toggleTarget("light", away.lastDarkTheme));
|
||||
expect(back.theme).toBe("ihasmail");
|
||||
});
|
||||
});
|
||||
|
||||
describe("where the theme settings live", () => {
|
||||
it("follows the account, not the browser", () => {
|
||||
// Both of these ride in the account's settings.json, so a theme chosen on
|
||||
// one machine — and the toggle's way back to it — are the same everywhere.
|
||||
// Named explicitly rather than derived from DEVICE_KEYS: the test that
|
||||
// does derive it would still pass if one of these were moved there, since
|
||||
// its expectation would move too.
|
||||
const synced = syncedPart(DEFAULT_SETTINGS);
|
||||
expect(synced).toHaveProperty("theme");
|
||||
expect(synced).toHaveProperty("lastDarkTheme");
|
||||
expect(DEVICE_KEYS.has("theme")).toBe(false);
|
||||
expect(DEVICE_KEYS.has("lastDarkTheme")).toBe(false);
|
||||
});
|
||||
|
||||
it("is applied from a settings file another device wrote", () => {
|
||||
expect(acceptRemote({ theme: "dark", lastDarkTheme: "dark" })).toEqual({ theme: "dark", lastDarkTheme: "dark" });
|
||||
});
|
||||
});
|
||||
@@ -4,7 +4,12 @@ import { loadJson, saveJson } from "@/lib/storage";
|
||||
import { queueSettingsPush } from "@/lib/settingsSync";
|
||||
import { setDateTimePrefs, type DateFormat, type TimeFormat } from "@/lib/datetime";
|
||||
|
||||
export type Theme = "system" | "light" | "dark";
|
||||
/**
|
||||
* "ihasmail" is a dark theme carrying the palette from ihasmail.org. It is a
|
||||
* theme rather than an accent because it changes the backgrounds, borders and
|
||||
* text as well as the highlight colour — an accent could not.
|
||||
*/
|
||||
export type Theme = "system" | "light" | "dark" | "ihasmail";
|
||||
export type Density = "comfortable" | "cozy" | "compact";
|
||||
export type ReadingPane = "right" | "bottom" | "off";
|
||||
export type ImagePolicy = "ask" | "always" | "contacts";
|
||||
@@ -83,10 +88,25 @@ export interface Settings {
|
||||
eventCategories: Array<{ name: string; color: string }>;
|
||||
/** Default sending identity per account (JMAP has no such flag). */
|
||||
defaultIdentityByAccount: Record<string, string>;
|
||||
/**
|
||||
* The theme the top-bar toggle goes back to from light. Remembered rather
|
||||
* than assumed, so flipping to light and back returns you to the theme you
|
||||
* were on — "ihasmail", "system" or plain "dark" — instead of dropping
|
||||
* everyone onto the same one. Never "light": that is the side being
|
||||
* toggled away from.
|
||||
*/
|
||||
lastDarkTheme: Exclude<Theme, "light">;
|
||||
}
|
||||
|
||||
export const DEFAULT_SETTINGS: Settings = {
|
||||
theme: "system",
|
||||
/**
|
||||
* ihasmail's own palette is what a new account gets, so the app looks like
|
||||
* itself before anyone has chosen anything. It is only a default: a stored
|
||||
* theme always wins, so nobody who has picked one — including everyone
|
||||
* already using ihasmail, whose choice is saved even if they never changed
|
||||
* it — is moved off it.
|
||||
*/
|
||||
theme: "ihasmail",
|
||||
accent: "teal",
|
||||
density: "cozy",
|
||||
readingPane: "right",
|
||||
@@ -140,6 +160,7 @@ export const DEFAULT_SETTINGS: Settings = {
|
||||
{ name: "Family", color: "#9333ea" },
|
||||
],
|
||||
defaultIdentityByAccount: {},
|
||||
lastDarkTheme: "ihasmail",
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -203,14 +224,18 @@ applyDateTimePrefs(initialSettings);
|
||||
export const useSettings = create<SettingsState>((set, get) => ({
|
||||
settings: initialSettings,
|
||||
update(patch) {
|
||||
const settings = { ...get().settings, ...patch };
|
||||
// Picking a theme anywhere — the toggle, Appearance, an imported file —
|
||||
// is what teaches the toggle where to come back to. Doing it here rather
|
||||
// than at the call sites means a fourth way to set a theme cannot forget.
|
||||
const next = patch.theme && patch.theme !== "light" ? { ...patch, lastDarkTheme: patch.theme } : patch;
|
||||
const settings = { ...get().settings, ...next };
|
||||
saveJson("settings", settings);
|
||||
set({ settings });
|
||||
applyTheme(settings);
|
||||
applyDateTimePrefs(settings);
|
||||
// Dragging a splitter changes a device key on every frame and must not put
|
||||
// a request in the air; anything else is queued and coalesced.
|
||||
if (Object.keys(patch).some((k) => !DEVICE_KEYS.has(k as keyof Settings))) {
|
||||
if (Object.keys(next).some((k) => !DEVICE_KEYS.has(k as keyof Settings))) {
|
||||
queueSettingsPush(syncedPart(settings));
|
||||
}
|
||||
},
|
||||
@@ -247,16 +272,37 @@ function applyDateTimePrefs(s: Settings): void {
|
||||
setDateTimePrefs({ locale: s.locale, dateFormat: s.dateFormat, timeFormat: s.timeFormat });
|
||||
}
|
||||
|
||||
/** Background of each theme, for the browser chrome (`theme-color`). */
|
||||
const THEME_COLOR = { light: "#ffffff", dark: "#0b1220", ihasmail: "#0d2430" } as const;
|
||||
|
||||
export function applyTheme(s: Settings = useSettings.getState().settings): void {
|
||||
const root = document.documentElement;
|
||||
const prefersDark = window.matchMedia?.("(prefers-color-scheme: dark)").matches;
|
||||
const dark = s.theme === "dark" || (s.theme === "system" && prefersDark);
|
||||
const dark = isDarkTheme(s.theme, prefersDark);
|
||||
// ihasmail keeps data-theme="dark" and adds a palette on top, so every
|
||||
// dark-only rule in the stylesheet applies to it without being repeated.
|
||||
root.dataset.theme = dark ? "dark" : "light";
|
||||
if (s.theme === "ihasmail") root.dataset.palette = "ihasmail";
|
||||
else delete root.dataset.palette;
|
||||
root.dataset.density = s.density;
|
||||
root.dataset.accent = s.accent;
|
||||
root.dataset.fontsize = s.fontSize;
|
||||
const meta = document.querySelector<HTMLMetaElement>('meta[name="theme-color"]:not([media])');
|
||||
if (meta) meta.content = dark ? "#0b1220" : "#ffffff";
|
||||
if (meta) meta.content = s.theme === "ihasmail" ? THEME_COLOR.ihasmail : dark ? THEME_COLOR.dark : THEME_COLOR.light;
|
||||
}
|
||||
|
||||
/**
|
||||
* Where the top-bar toggle goes next. Away from dark is always light; back
|
||||
* from light is wherever you last were, which is the whole point of
|
||||
* remembering it.
|
||||
*/
|
||||
export function toggleTarget(effective: "light" | "dark", lastDarkTheme: Settings["lastDarkTheme"]): Theme {
|
||||
return effective === "dark" ? "light" : lastDarkTheme;
|
||||
}
|
||||
|
||||
/** Whether a theme paints dark, resolving "system" against the OS. */
|
||||
export function isDarkTheme(theme: Theme, prefersDark = false): boolean {
|
||||
return theme === "dark" || theme === "ihasmail" || (theme === "system" && prefersDark);
|
||||
}
|
||||
|
||||
if (typeof window !== "undefined") {
|
||||
@@ -278,7 +324,7 @@ export function useEffectiveTheme(): "light" | "dark" {
|
||||
mq.addEventListener("change", onChange);
|
||||
return () => mq.removeEventListener("change", onChange);
|
||||
}, []);
|
||||
return theme === "dark" || (theme === "system" && systemDark) ? "dark" : "light";
|
||||
return isDarkTheme(theme, systemDark) ? "dark" : "light";
|
||||
}
|
||||
|
||||
export const settings = () => useSettings.getState().settings;
|
||||
|
||||
@@ -90,6 +90,59 @@
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
/*
|
||||
* The "ihasmail" theme: the palette from ihasmail.org, which is a teal-navy
|
||||
* rather than the blue-slate of the plain dark theme, warmed by the orange the
|
||||
* logo's cat is drawn in.
|
||||
*
|
||||
* It rides on data-theme="dark" rather than replacing it, so every dark-only
|
||||
* rule further down this file -- tooltips, toasts, the message frame -- keeps
|
||||
* applying without being duplicated. Only the palette is overridden.
|
||||
*
|
||||
* Specificity is doing deliberate work here. This block is [data-palette] plus
|
||||
* :root, so 0,2,0; the accent variants below are :root[data-theme][data-accent],
|
||||
* so 0,3,0 and they win. That is what makes the accent swatches keep working on
|
||||
* top of this theme -- and because the default accent ("teal") has no rule of
|
||||
* its own, ihasmail.org's own accent is what shows until someone picks another.
|
||||
*/
|
||||
:root[data-palette="ihasmail"] {
|
||||
--bg: #0d2430;
|
||||
--bg-elev: #12303e;
|
||||
--bg-sunken: #0a1c26;
|
||||
--bg-hover: rgba(70, 202, 195, 0.10);
|
||||
--bg-active: rgba(70, 202, 195, 0.16);
|
||||
--fg: #eaf6f6;
|
||||
--fg-muted: #a3c3cb;
|
||||
--fg-faint: #86aab4;
|
||||
--border: #21505f;
|
||||
--border-strong: #2e6a7a;
|
||||
--accent: #46cac3;
|
||||
--accent-fg: #062028;
|
||||
--accent-soft: rgba(70, 202, 195, 0.16);
|
||||
--accent-soft-fg: #9fe6e2;
|
||||
--danger: #f87171;
|
||||
--danger-soft: rgba(248, 113, 113, 0.15);
|
||||
--warn: #f9a34b;
|
||||
--warn-soft: rgba(249, 163, 75, 0.14);
|
||||
--success: #4ade80;
|
||||
--success-soft: rgba(74, 222, 128, 0.15);
|
||||
--link: #6fdcd6;
|
||||
--unread-bg: #163a4a;
|
||||
--read-bg: #12303e;
|
||||
--selected-bg: rgba(70, 202, 195, 0.18);
|
||||
--focus-ring: 0 0 0 3px rgba(70, 202, 195, 0.4);
|
||||
/* The cat is orange; so is the star. */
|
||||
--star: #f9a34b;
|
||||
--q1: #6fdcd6;
|
||||
--q2: #4ade80;
|
||||
--q3: #c084fc;
|
||||
--scrollbar: rgba(163, 195, 203, 0.3);
|
||||
--shadow-1: 0 1px 2px rgba(0, 0, 0, 0.45);
|
||||
--shadow-2: 0 8px 24px rgba(0, 0, 0, 0.55);
|
||||
--shadow-3: 0 22px 60px -28px rgba(0, 0, 0, 0.75);
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
/* Accent variants */
|
||||
:root[data-accent="blue"] { --accent: #2563eb; --accent-soft: #dbeafe; --accent-soft-fg: #1e3a8a; --selected-bg: #dbeafe; --focus-ring: 0 0 0 3px rgba(37,99,235,.35); --link:#1d4ed8; }
|
||||
:root[data-accent="purple"] { --accent: #7c3aed; --accent-soft: #ede9fe; --accent-soft-fg: #4c1d95; --selected-bg: #ede9fe; --focus-ring: 0 0 0 3px rgba(124,58,237,.35); --link:#6d28d9; }
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useEffect, useState, type ReactNode } from "react";
|
||||
import { Link, useLocation } from "wouter";
|
||||
import { Calendar, ChevronsUpDown, FolderOpen, HelpCircle, Mail, Menu as MenuIcon, Moon, PenSquare, Settings, Sun, Users, LogOut, Plus, RefreshCw } from "lucide-react";
|
||||
import { useSession } from "@/store/session";
|
||||
import { useEffectiveTheme, useSettings } from "@/store/settings";
|
||||
import { toggleTarget, useEffectiveTheme, useSettings } from "@/store/settings";
|
||||
import { useMail } from "@/store/mail";
|
||||
import { draftFromMailto, useCompose } from "@/store/compose";
|
||||
import { Avatar, useIsMobile } from "@/ui/misc";
|
||||
@@ -205,22 +205,31 @@ function QuotaBar() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Flip between light and dark from the top bar.
|
||||
* Flip to light and back from the top bar.
|
||||
*
|
||||
* The stored setting has a third value, "system", so the button acts on what
|
||||
* is actually on screen rather than on the setting: whichever theme you can
|
||||
* see, one click gives you the other one. Choosing "match system" again lives
|
||||
* in Settings › Appearance, where the three-way choice belongs.
|
||||
* The setting has four values and only two of them are "light", so the button
|
||||
* acts on what is actually on screen rather than on the setting: if you can
|
||||
* see a dark theme, one click gives you light.
|
||||
*
|
||||
* Coming back is the part that needs remembering. There is more than one way
|
||||
* to be dark — "dark", "ihasmail", or "system" while the OS is — so the way
|
||||
* back is whichever you were on, kept in `lastDarkTheme`, rather than plain
|
||||
* "dark" for everyone. Without that, two clicks would quietly move an
|
||||
* ihasmail user onto a theme they never chose.
|
||||
*/
|
||||
function ThemeToggle() {
|
||||
const effective = useEffectiveTheme();
|
||||
const lastDarkTheme = useSettings((s) => s.settings.lastDarkTheme);
|
||||
const update = useSettings((s) => s.update);
|
||||
const next = effective === "dark" ? "light" : "dark";
|
||||
const next = toggleTarget(effective, lastDarkTheme);
|
||||
// The label names where you are going, and going back is not always "dark"
|
||||
// any more -- it is whichever theme you were on before flipping to light.
|
||||
const label = next === "light" ? "light mode" : next === "system" ? "your system theme" : next === "ihasmail" ? "the ihasmail theme" : "dark mode";
|
||||
return (
|
||||
<button
|
||||
className="icon-btn"
|
||||
aria-label={`Switch to ${next} mode`}
|
||||
title={`Switch to ${next} mode`}
|
||||
aria-label={`Switch to ${label}`}
|
||||
title={`Switch to ${label}`}
|
||||
onClick={() => update({ theme: next })}
|
||||
>
|
||||
{effective === "dark" ? <Sun size={21} /> : <Moon size={21} />}
|
||||
|
||||
@@ -1,6 +1,20 @@
|
||||
import { useSettings } from "@/store/settings";
|
||||
import { Switch } from "@/ui/misc";
|
||||
|
||||
/**
|
||||
* The theme cards, each previewing the background it actually paints. Kept as
|
||||
* data rather than three inline ternaries so a fourth does not mean editing a
|
||||
* conditional in three places.
|
||||
*/
|
||||
const THEMES = [
|
||||
{ id: "system", label: "Match system", preview: "linear-gradient(90deg,#f6f8fa 50%,#0b1220 50%)" },
|
||||
{ id: "light", label: "Light", preview: "#f6f8fa" },
|
||||
{ id: "dark", label: "Dark", preview: "#0b1220" },
|
||||
// The ihasmail.org palette: its background, with its teal and the logo's
|
||||
// orange showing, so the card looks like what picking it does.
|
||||
{ id: "ihasmail", label: "ihasmail", preview: "linear-gradient(135deg,#0d2430 0%,#12303e 55%,#46cac3 55%,#46cac3 78%,#f9a34b 78%)" },
|
||||
] as const;
|
||||
|
||||
const ACCENTS = [
|
||||
{ id: "teal", color: "#0f766e" },
|
||||
{ id: "blue", color: "#2563eb" },
|
||||
@@ -19,13 +33,16 @@ export function AppearanceSettings() {
|
||||
<p className="lead">Make ihasmail yours.</p>
|
||||
<h2>Theme</h2>
|
||||
<div className="theme-grid">
|
||||
{(["system", "light", "dark"] as const).map((t) => (
|
||||
<button key={t} className={`theme-card ${s.theme === t ? "active" : ""}`} onClick={() => update({ theme: t })}>
|
||||
<div className="preview" style={{ background: t === "dark" ? "#0b1220" : t === "light" ? "#f6f8fa" : "linear-gradient(90deg,#f6f8fa 50%,#0b1220 50%)" }} />
|
||||
{t === "system" ? "Match system" : t === "light" ? "Light" : "Dark"}
|
||||
{THEMES.map((t) => (
|
||||
<button key={t.id} className={`theme-card ${s.theme === t.id ? "active" : ""}`} onClick={() => update({ theme: t.id })}>
|
||||
<div className="preview" style={{ background: t.preview }} />
|
||||
{t.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<p className="hint" style={{ marginTop: 10 }}>
|
||||
<strong>ihasmail</strong> is the palette from <a href="https://ihasmail.org" target="_blank" rel="noopener noreferrer">ihasmail.org</a>, and what a new account starts on. It is a dark theme, so it counts as dark wherever that matters, and the accent colour below still applies on top of it.
|
||||
</p>
|
||||
<Switch
|
||||
checked={s.themeMessageBody}
|
||||
onChange={(v) => update({ themeMessageBody: v })}
|
||||
|
||||
Reference in New Issue
Block a user