Files
ihasmail-inbuxa/web/src/views/settings/AppearanceSettings.tsx
T
jcoffey-dev 3f4b33cb51 Finish extraction: 100%, and a coverage number worth believing
The 143 the codemod refused turned out to be two different things, and only
one of them needed a person.

A third were phrases sitting next to an icon -- `<Plus /> New rule`. The
refusal rule was "has siblings", which is broader than the danger: what breaks
a translation is a sibling that renders *text*, splitting a sentence into
fragments no one can reorder. An element beside a phrase does not. Narrowing
the rule to text-producing siblings let the codemod take 73 more.

The rest were real sentences with values in the middle, rebuilt by hand as
named placeholders -- "Your active script “{name}” was written by hand",
"Waiting on the server — goes out {when}." Named rather than positional
because a translator moves the parts around; counted things go through
plural() so Russian and Ukrainian get their three forms rather than English's
two.

Sentences with an element inside them needed something new. `Open <code>mailto:
</code> links in ihasmail` has two obvious treatments and both are wrong:
splitting it into two t() calls hands over fragments that cannot be reordered,
and dropping the <code> keeps the sentence whole but loses the monospace that
said "this is a literal". tNode() keeps the sentence whole and makes the
element a named hole in it, so a translator sees one sentence and can put the
hole where their language wants it. The German test asserts exactly that: the
same call renders the code first when the catalogue says so.

The coverage number was also lying, and it is worth saying how. It counted
text inside <code> and inside translate="no" as untranslated work, and
placeholders like "123456" and "+1 555 0100" -- a one-time code and a phone
format. None of those will ever be translated, so the report sat at 21 with 6
real items left. A number with an unreachable floor is something to argue with
rather than act on, so the tool now applies the same rules the codemod does.

596 wrapped, nothing remaining. Verified in the browser across 15 views, which
is where the last bulk pass hid a bug the tests could not see: no entities, no
unfilled placeholders, no raw t( in rendered text, and the toggle switches that
looked like emptied labels are text-free by design.
2026-08-31 10:41:24 -07:00

152 lines
7.6 KiB
TypeScript

import { useSettings } from "@/store/settings";
import { Switch, useIsTouch } from "@/ui/misc";
import { SWIPE_CHOICES, type SwipeAction } from "@/lib/swipe";
import { UI_LANGUAGES } from "@/lib/languages";
import { t as translate } from "@/lib/i18n";
/**
* 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" },
{ id: "purple", color: "#7c3aed" },
{ id: "rose", color: "#e11d48" },
{ id: "orange", color: "#ea580c" },
{ id: "green", color: "#16a34a" },
];
export function AppearanceSettings() {
const s = useSettings((st) => st.settings);
const update = useSettings((st) => st.update);
const isTouch = useIsTouch();
return (
<div>
<h1>{translate("Appearance")}</h1>
<p className="lead">{translate("Make ihasmail yours.")}</p>
<h2>{translate("Theme")}</h2>
<div className="theme-grid">
{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>{translate("ihasmail")}</strong> {translate("is the palette from")} <a href="https://ihasmail.org" target="_blank" rel="noopener noreferrer">{translate("ihasmail.org")}</a>{translate(", 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 })}
label={translate("Apply the theme to messages too")}
hint={translate("Plain-text mail already follows the theme. With this on, HTML mail that brings no colours of its own does as well, instead of sitting on a white card. Messages that style themselves are left exactly as the sender designed them.")}
/>
<h2>{translate("Accent color")}</h2>
<div className="swatches">
{ACCENTS.map((a) => (
<button key={a.id} className={`swatch ${s.accent === a.id ? "active" : ""}`} style={{ background: a.color }} onClick={() => update({ accent: a.id })} aria-label={a.id} title={a.id} />
))}
</div>
<h2>{translate("Density & text")}</h2>
<div className="field-row">
<div className="field">
<label>{translate("Display density")}</label>
<select className="select" value={s.density} onChange={(e) => update({ density: e.target.value as typeof s.density })}>
<option value="comfortable">{translate("Comfortable")}</option>
<option value="cozy">{translate("Cozy (default)")}</option>
<option value="compact">{translate("Compact")}</option>
</select>
</div>
<div className="field">
<label>{translate("Text size")}</label>
<select className="select" value={s.fontSize} onChange={(e) => update({ fontSize: e.target.value as typeof s.fontSize })}>
<option value="small">{translate("Small")}</option>
<option value="medium">{translate("Medium")}</option>
<option value="large">{translate("Large")}</option>
</select>
</div>
</div>
<h2>{translate("Language")}</h2>
<div className="field" style={{ maxWidth: 320 }}>
<label htmlFor="ui-language">{translate("Interface language")}</label>
<select id="ui-language" className="select" value={s.uiLanguage} onChange={(e) => update({ uiLanguage: e.target.value })}>
{UI_LANGUAGES.map((l) => (
<option key={l.tag} value={l.tag}>{l.name}</option>
))}
</select>
</div>
{/*
Said plainly rather than left to be discovered. A picker with one entry
looks broken; a picker with one entry and a sentence explaining that
more are coming is a roadmap.
*/}
<p className="hint">
{translate("Only languages ihasmail has been translated into appear here, so this list grows as translations land rather than ahead of them — a language offered without strings behind it would leave the page claiming to be in a language it is not.")}
</p>
<p className="hint">
{translate("This is separate from")} <strong>{translate("Language & region")}</strong> {translate("in General, which decides how dates, times and numbers are written. You can read an English interface with German dates, or the other way round.")}
</p>
<h2>{translate("Swiping")}</h2>
<p className="hint">
{translate("On a touchscreen, drag a message sideways to act on it. Each direction can do one thing, or nothing. These follow your account, so a phone and a tablet agree; a mouse ignores them and keeps dragging messages into folders instead.")}
</p>
<div className="field-row">
<div className="field">
<label htmlFor="swipe-right">{translate("Swipe right")}</label>
<select id="swipe-right" className="select" value={s.swipeRight} onChange={(e) => update({ swipeRight: e.target.value as SwipeAction })}>
{SWIPE_CHOICES.map((c) => (
<option key={c.value} value={c.value}>{c.label}</option>
))}
</select>
</div>
<div className="field">
<label htmlFor="swipe-left">{translate("Swipe left")}</label>
<select id="swipe-left" className="select" value={s.swipeLeft} onChange={(e) => update({ swipeLeft: e.target.value as SwipeAction })}>
{SWIPE_CHOICES.map((c) => (
<option key={c.value} value={c.value}>{c.label}</option>
))}
</select>
</div>
</div>
{/*
Said once, where it is relevant, rather than greying the pickers out on
a desktop: the settings are real and worth setting here for the phone
that will read them, and a disabled control invites a hunt for whatever
would enable it.
*/}
{!isTouch && (
<p className="hint">
{translate("This screen has no touchscreen, so nothing here changes what it does. Your phone or tablet will pick these up.")}
</p>
)}
<p className="hint">
{translate("Holding a message selects it, and holding a folder opens its menu. Pull the top of the message list down to check for new mail.")}
</p>
<h2>{translate("Sidebar")}</h2>
<Switch checked={s.labelsSidebar} onChange={(v) => update({ labelsSidebar: v })} label={translate("Show labels in the sidebar")} />
<Switch checked={s.showHiddenFolders} onChange={(v) => update({ showHiddenFolders: v })} label={translate("Show unsubscribed (hidden) folders")} />
<Switch checked={s.sidebarCollapsed} onChange={(v) => update({ sidebarCollapsed: v })} label={translate("Collapse sidebar to icons")} />
</div>
);
}