Use American English spelling throughout

This commit is contained in:
2026-09-15 11:45:58 -07:00
parent 6ba89696ee
commit d1731efdb9
169 changed files with 786 additions and 795 deletions
+1 -1
View File
@@ -94,7 +94,7 @@ describe("generated passwords", () => {
expect(p).not.toMatch(/[01lIO]/);
});
it("skip bytes that would favour the start of the alphabet", () => {
it("skip bytes that would favor the start of the alphabet", () => {
// 256 % 55 leaves 36 byte values over; a plain modulo would hand those to
// the first 36 characters twice as often. Bytes of 220 and up are dropped
// and more are drawn, so a batch of nothing but those costs a draw.
+5 -5
View File
@@ -1,12 +1,12 @@
import { describe, expect, it, vi } from "vitest";
import { client, JmapMethodError } from "@/jmap/client";
import { balancedColumns, countObjects, isRefused, loadMetrics, summariseMetrics, type MetricRecord } from "@/lib/adminDashboard";
import { balancedColumns, countObjects, isRefused, loadMetrics, summarizeMetrics, type MetricRecord } from "@/lib/adminDashboard";
const counter = (metric: string, count: number, timestamp = "2026-09-15T14:00:00Z"): MetricRecord => ({ "@type": "Counter", metric, count, timestamp });
describe("the dashboard's message numbers", () => {
it("adds received and sent up over the metric names Stalwart's own dashboard uses", () => {
const stats = summariseMetrics([
const stats = summarizeMetrics([
counter("queue.message-queued", 6),
counter("queue.message-queued", 4, "2026-09-15T13:00:00Z"),
counter("queue.authenticated-message-queued", 2),
@@ -20,7 +20,7 @@ describe("the dashboard's message numbers", () => {
});
it("reads memory from the newest gauge, not the first one listed", () => {
const stats = summariseMetrics([
const stats = summarizeMetrics([
{ "@type": "Gauge", metric: "server.memory", count: 100, timestamp: "2026-09-15T12:00:00Z" },
{ "@type": "Gauge", metric: "server.memory", count: 300, timestamp: "2026-09-15T14:00:00Z" },
{ "@type": "Gauge", metric: "queue.count", count: 7, timestamp: "2026-09-15T15:00:00Z" },
@@ -29,8 +29,8 @@ describe("the dashboard's message numbers", () => {
});
it("tells a history that records nothing from a quiet day", () => {
expect(summariseMetrics([]).recorded).toBe(false);
const quiet = summariseMetrics([{ "@type": "Gauge", metric: "server.memory", count: 1, timestamp: "2026-09-15T14:00:00Z" }]);
expect(summarizeMetrics([]).recorded).toBe(false);
const quiet = summarizeMetrics([{ "@type": "Gauge", metric: "server.memory", count: 1, timestamp: "2026-09-15T14:00:00Z" }]);
expect(quiet).toMatchObject({ recorded: true, received: 0, sent: 0 });
});
});
+1 -1
View File
@@ -62,7 +62,7 @@ describe("the account query", () => {
* live server gave, or one its source says it gives.
*/
describe("refusals in the reader's language", () => {
it("recognises the registry's validators and says it again, without the server's words", () => {
it("recognizes the registry's validators and says it again, without the server's words", () => {
// Live, 2026-09-13: a reserved TLD, and a catch-all without a domain.
const domain = describeDirectoryError(new DirectoryError("invalidPatch", "Invalid domain name", ["name"]), "domain");
expect(domain).toMatch(/isn't a valid domain name/);
+4 -4
View File
@@ -1,10 +1,10 @@
import { describe, expect, it } from "vitest";
import { describeLinked, dkimAlgorithm, looksLikeDomain, normaliseDomain, parseZoneFile } from "@/lib/adminDomains";
import { describeLinked, dkimAlgorithm, looksLikeDomain, normalizeDomain, parseZoneFile } from "@/lib/adminDomains";
/**
* Written the way Stalwart's BIND serialiser writes it (dns-update's
* Written the way Stalwart's BIND serializer writes it (dns-update's
* `BindSerializer`): `name IN TYPE value`, and a TXT over 255 bytes as a
* parenthesised run of quoted chunks.
* parenthesized run of quoted chunks.
*/
const long = "v=DKIM1; k=rsa; h=sha256; p=" + "A".repeat(400);
const zone = [
@@ -46,7 +46,7 @@ describe("reading the zone file", () => {
describe("domain names", () => {
it("are written back lower-case without the root dot", () => {
expect(normaliseDomain(" Example.COM. ")).toBe("example.com");
expect(normalizeDomain(" Example.COM. ")).toBe("example.com");
});
it("are checked loosely before the server decides", () => {
@@ -34,7 +34,7 @@ describe("the span an availability bar covers", () => {
expect(w.span).toBeGreaterThan(0);
});
it("marks a single day every three hours, labelling every six", () => {
it("marks a single day every three hours, labeling every six", () => {
const w = availabilityWindow(at("2026-09-02T09:00:00"), at("2026-09-02T10:00:00"));
expect(w.scale).toBe("hours");
expect(hours(w)).toEqual(["2@0", "2@3", "2@6", "2@9", "2@12", "2@15", "2@18", "2@21"]);
+2 -2
View File
@@ -73,7 +73,7 @@ describe("birthdaysInRange", () => {
expect(birthdaysInRange([card("c2", "", { month: 6, day: 15 })], s, e)).toEqual([]);
});
it("falls back to a name built from components, then to the organisation", () => {
it("falls back to a name built from components, then to the organization", () => {
const [s, e] = range("2026-01-01", "2027-01-01");
const parts = {
id: "c1",
@@ -115,7 +115,7 @@ describe("birthdaysInRange", () => {
expect(out.map((b) => b.name)).toEqual(["Amy", "Zoe"]);
});
it("gives each occurrence a stable, unique id that marks it as synthesised", () => {
it("gives each occurrence a stable, unique id that marks it as synthesized", () => {
const [s, e] = range("2025-01-01", "2027-01-01");
const out = birthdaysInRange([card("c1", "Ada", { month: 6, day: 15 })], s, e);
expect(new Set(out.map((b) => b.id)).size).toBe(out.length);
+1 -1
View File
@@ -6,7 +6,7 @@ import { DEFAULT_APP_NAME } from "@/lib/brand";
*
* `APP_NAME` is a runtime variable, so every place showing the name has to ask
* the server rather than have it written in. The sign-in page did not (#236's
* neighbour): it fetched `/api/config`, received the name and used only
* neighbor): it fetched `/api/config`, received the name and used only
* `sourceUrl`, so a rebranded instance still said "ihasmail" on the page a new
* user meets first. These pin the shape of the answer rather than the name.
*/
+2 -2
View File
@@ -97,14 +97,14 @@ describe("explicit date formats", () => {
});
describe("clock preference", () => {
it("honours 24-hour regardless of locale", () => {
it("honors 24-hour regardless of locale", () => {
setDateTimePrefs({ locale: "en-US", timeFormat: "24" });
expect(formatClock(SAMPLE)).toBe("18:23");
expect(uses24Hour()).toBe(true);
expect(formatHourLabel(13)).toBe("13");
expect(formatHourLabel(9)).toBe("09");
});
it("honours 12-hour regardless of locale", () => {
it("honors 12-hour regardless of locale", () => {
setDateTimePrefs({ locale: "de-DE", timeFormat: "12" });
expect(formatClock(SAMPLE)).toBe("6:23 PM");
expect(uses24Hour()).toBe(false);
+5 -5
View File
@@ -2,7 +2,7 @@
* The two sentence builders, which had no tests while they were building
* English by concatenation -- and no test would have caught the thing wrong
* with them, since the English output was correct. These pin the two
* properties that matter now: every fragment goes through the catalogue, and
* properties that matter now: every fragment goes through the catalog, and
* the joining is Intl's rather than a hardcoded " and ".
*/
import { describe, expect, it } from "vitest";
@@ -12,7 +12,7 @@ import { setUiLanguageForFormatting } from "../datetime";
import { setCatalog } from "../i18n";
describe("sieve describeRule", () => {
it("names the header and operator through the catalogue", () => {
it("names the header and operator through the catalog", () => {
const s = describeSieve({
id: "1", name: "r", join: "allof", enabled: true,
tests: [{ type: "header", header: "subject", op: "contains", value: "invoice" }],
@@ -50,7 +50,7 @@ describe("recurrence describeRule", () => {
expect(describeRecurrence({ "@type": "RecurrenceRule", frequency: "daily", interval: 3 } as never)).toBe("Every 3 days");
});
it("recognises Monday to Friday as every weekday", () => {
it("recognizes Monday to Friday as every weekday", () => {
const rule = {
"@type": "RecurrenceRule", frequency: "weekly",
byDay: ["mo", "tu", "we", "th", "fr"].map((day) => ({ "@type": "NDay", day })),
@@ -80,12 +80,12 @@ describe("recurrence describeRule", () => {
expect(names[0]).toBe("Montag");
expect(names).toHaveLength(7);
// The narrow forms collide in English ("T" for both Tuesday and Thursday),
// which is why they cannot be catalogue keys and come from Intl instead.
// which is why they cannot be catalog keys and come from Intl instead.
expect(weekdayOptions().map((w) => w.short)).toHaveLength(7);
setUiLanguageForFormatting(null);
});
it("renders a translated rule through the catalogue", () => {
it("renders a translated rule through the catalog", () => {
setCatalog("de", { strings: { Daily: "Täglich" }, plurals: {} });
expect(describeRecurrence({ "@type": "RecurrenceRule", frequency: "daily" } as never)).toBe("Täglich");
setCatalog("en", { strings: {}, plurals: {} });
+2 -2
View File
@@ -93,14 +93,14 @@ describe("canMoveFolderTo", () => {
});
describe("folderColor", () => {
it("returns the colour chosen for that folder, and null for the rest", () => {
it("returns the color chosen for that folder, and null for the rest", () => {
const colors = { work: "#7c3aed" };
expect(folderColor(colors, "work")).toBe("#7c3aed");
expect(folderColor(colors, "news")).toBeNull();
expect(folderColor({}, "work")).toBeNull();
});
it("is keyed by id, so a renamed folder keeps its colour", () => {
it("is keyed by id, so a renamed folder keeps its color", () => {
// The id is stable across a rename; the name and path are not.
expect(folderColor({ mb1: "#0f766e" }, "mb1")).toBe("#0f766e");
});
+22 -22
View File
@@ -34,7 +34,7 @@ describe("sanitizeEmailHtml", () => {
});
describe("htmlDeclaresColors", () => {
it("is false for mail that brings no colours", () => {
it("is false for mail that brings no colors", () => {
expect(htmlDeclaresColors("<p>Hi there</p>")).toBe(false);
expect(htmlDeclaresColors("<div><b>bold</b> and <i>italic</i></div>", "font-family:Arial")).toBe(false);
expect(htmlDeclaresColors('<a href="https://x.io/?color=red">link</a>')).toBe(false);
@@ -54,9 +54,9 @@ describe("htmlDeclaresColors", () => {
/**
* Forcing the theme onto mail that styles itself — issue #290.
*
* The switch above it leaves nearly all HTML mail alone, because one colour
* The switch above it leaves nearly all HTML mail alone, because one color
* anywhere opts a message out. What this half has to get right is telling a
* sheet the design sits on from a surface painted on top of it: neutralise the
* sheet the design sits on from a surface painted on top of it: neutralize the
* first and the white card goes away, keep the second and a button keeps a
* label you can still read.
*/
@@ -70,15 +70,15 @@ describe("relativeLuminance", () => {
expect(relativeLuminance("rgba(255,255,255,0.5)")).toBeCloseTo(1, 5);
});
it("has nothing to say about a colour it cannot read", () => {
it("has nothing to say about a color it cannot read", () => {
// Not a failure: the caller treats null as "no deliberate surface", which
// is the safe way round — an unreadable colour must not keep a white sheet.
// is the safe way round — an unreadable color must not keep a white sheet.
expect(relativeLuminance("color-mix(in srgb, red, blue)")).toBeNull();
expect(relativeLuminance("var(--brand)")).toBeNull();
expect(relativeLuminance("")).toBeNull();
});
it("treats a fully transparent colour as painting nothing", () => {
it("treats a fully transparent color as painting nothing", () => {
expect(relativeLuminance("rgba(0,0,0,0)")).toBeNull();
expect(relativeLuminance("transparent")).toBeNull();
});
@@ -100,21 +100,21 @@ describe("markKeptSurfaces", () => {
* Marking is only half of it — the other half is the rule in EMAIL_BASE_CSS
* that reads the marks, and #310 was a bug in that half rather than in the
* marking. So these assert what the reader actually sees: does the
* neutraliser hit this element? The selector is lifted out of the stylesheet
* neutralizer hit this element? The selector is lifted out of the stylesheet
* rather than copied, so a test cannot quietly drift from the rule it checks.
*/
const NEUTRALISER = (() => {
const NEUTRALIZER = (() => {
const m = EMAIL_BASE_CSS.match(
/\.ihm-email-root\.forced\s+(\*:not\([^{]*?)\s*\{\s*color: inherit/,
);
if (!m) throw new Error("could not find the neutraliser rule in EMAIL_BASE_CSS");
if (!m) throw new Error("could not find the neutralizer rule in EMAIL_BASE_CSS");
return m[1]!.trim();
})();
/** True when the theme is forced onto this element rather than leaving it alone. */
const neutralised = (el: Element) => el.matches(NEUTRALISER);
const neutralized = (el: Element) => el.matches(NEUTRALIZER);
it("keeps a coloured button and drops the white sheet around it", () => {
it("keeps a colored button and drops the white sheet around it", () => {
// The shape reported in #290: a Shopify/Klaviyo template whose outer 600px
// wrapper carries bgcolor="#ffffff" and whose CTA carries bgcolor="#1155CC".
const d = frag('<table bgcolor="#ffffff"><tr><td bgcolor="#1155CC"><a style="color:#FFFFFF">Buy</a></td></tr></table>');
@@ -127,7 +127,7 @@ describe("markKeptSurfaces", () => {
expect(d.querySelector("a")!.hasAttribute("data-ihm-in-keep")).toBe(true);
});
it("neutralises a light panel nested inside a dark painted card", () => {
it("neutralizes a light panel nested inside a dark painted card", () => {
// The shape reported in #310: a dark Klaviyo campaign whose 600px cards
// are dark enough to be marked, with light content tables inside them.
// Those tables used to inherit the card's exemption and render as beige
@@ -153,11 +153,11 @@ describe("markKeptSurfaces", () => {
// The fix, stated the way the reader experiences it: the nested sheet is
// themed, and so is the copy inside it. Before #310 both were exempt for
// being descendants of the card.
expect(neutralised(nested)).toBe(true);
expect(neutralised(d.querySelector("td")!)).toBe(true);
expect(neutralized(nested)).toBe(true);
expect(neutralized(d.querySelector("td")!)).toBe(true);
// The card itself is still left alone, and the page surround still goes.
expect(neutralised(card)).toBe(false);
expect(neutralised(surround)).toBe(true);
expect(neutralized(card)).toBe(false);
expect(neutralized(surround)).toBe(true);
});
it("still keeps a button that sits inside a nested light panel", () => {
@@ -172,11 +172,11 @@ describe("markKeptSurfaces", () => {
'</div>',
);
expect(markKeptSurfaces(d)).toBe(2);
expect(neutralised(d.querySelector("table")!)).toBe(true);
expect(neutralised(d.querySelector("td")!)).toBe(false);
expect(neutralized(d.querySelector("table")!)).toBe(true);
expect(neutralized(d.querySelector("td")!)).toBe(false);
// The label keeps its white, which is the thing #294 bought and this must
// not spend.
expect(neutralised(d.querySelector("a")!)).toBe(false);
expect(neutralized(d.querySelector("a")!)).toBe(false);
});
it("leaves no light panel exempt across the whole reported specimen", () => {
@@ -201,7 +201,7 @@ describe("markKeptSurfaces", () => {
expect(panels.length).toBe(21);
expect(markKeptSurfaces(d)).toBe(7);
expect(panels.filter((p) => !neutralised(p))).toHaveLength(0);
expect(panels.filter((p) => !neutralized(p))).toHaveLength(0);
});
it("reads an inline background as well as the attribute", () => {
@@ -233,7 +233,7 @@ describe("markKeptSurfaces", () => {
* The control that actually stops it is layout containment on an ancestor of
* the shadow host, which mail CSS has no selector for; that lives in app.css
* and is asserted at the bottom of this file, because jsdom does no layout and
* cannot prove it here. These cover the second line of defence.
* cannot prove it here. These cover the second line of defense.
*/
describe("mail CSS cannot climb out of its card", () => {
const render = (html: string) => sanitizeEmailHtml(html).html;
@@ -271,7 +271,7 @@ describe("mail CSS cannot climb out of its card", () => {
describe("the containment that mail CSS cannot override", () => {
it("is still applied to the message body container", async () => {
// jsdom does no layout, so this asserts the control is present rather than
// that it works; the behaviour was verified in a real browser. Without it,
// that it works; the behavior was verified in a real browser. Without it,
// a message can cover the viewport regardless of what the sanitizer does.
const { readFile } = await import("node:fs/promises");
const { join } = await import("node:path");
@@ -36,7 +36,7 @@ describe("deciding whether a message has an HTML alternative", () => {
expect(hasHtmlAlternative({ type: "text/htmlish" }, "<p>Hi</p>")).toBe(false);
});
it("matches the type case-insensitively, since a header may be capitalised", () => {
it("matches the type case-insensitively, since a header may be capitalized", () => {
expect(hasHtmlAlternative({ type: "TEXT/HTML" }, "<p>Hi</p>")).toBe(true);
});
+5 -5
View File
@@ -29,12 +29,12 @@ describe("t", () => {
expect(currentLanguage()).toBe("en");
});
it("translates once a catalogue is in force", () => {
it("translates once a catalog is in force", () => {
setCatalog("de", de);
expect(t("Archive")).toBe("Archivieren");
});
it("falls back per string, not per catalogue", () => {
it("falls back per string, not per catalog", () => {
setCatalog("de", de);
expect(t("Report spam")).toBe("Report spam");
});
@@ -60,7 +60,7 @@ describe("interpolation", () => {
describe("plural", () => {
const FORMS = { one: "{n} message", other: "{n} messages" };
it("picks the English form without a catalogue", () => {
it("picks the English form without a catalog", () => {
expect(plural(1, FORMS)).toBe("1 message");
expect(plural(0, FORMS)).toBe("0 messages");
expect(plural(5, FORMS)).toBe("5 messages");
@@ -73,7 +73,7 @@ describe("plural", () => {
expect(plural(7, FORMS)).toBe("7 сообщений"); // many
});
it("falls back to `other` when the catalogue lacks the category", () => {
it("falls back to `other` when the catalog lacks the category", () => {
setCatalog("de", de);
// German has no "few"; asking for 3 must not render undefined.
expect(plural(3, FORMS)).toBe("3 Nachrichten");
@@ -95,7 +95,7 @@ describe("tNode", () => {
it("lets a translator move the element", () => {
// Splitting the sentence into two t() calls could not do this: the
// fragments would render in the English order whatever the catalogue said.
// fragments would render in the English order whatever the catalog said.
setCatalog("de", de);
expect(render(tNode("Open {scheme} links here", { scheme: <code>mailto:</code> })))
.toBe("<code>mailto:</code>-Links hier öffnen");
+1 -1
View File
@@ -111,7 +111,7 @@ describe("parseIcsDuration", () => {
});
describe("looksLikeCalendar", () => {
it("recognises a calendar and rejects an error page", () => {
it("recognizes a calendar and rejects an error page", () => {
expect(looksLikeCalendar("BEGIN:VCALENDAR\r\nEND:VCALENDAR")).toBe(true);
expect(looksLikeCalendar("<!doctype html><title>404</title>")).toBe(false);
});
+3 -3
View File
@@ -30,7 +30,7 @@ const find = (e: JSCalendarEvent[], prefix: string) => eventLines(e).filter((l)
const one = (e: JSCalendarEvent, prefix: string) => find([e], prefix)[0];
describe("the document around the events", () => {
it("is a calendar a reader will recognise", () => {
it("is a calendar a reader will recognize", () => {
const l = lines([base]);
expect(l[0]).toBe("BEGIN:VCALENDAR");
expect(l).toContain("VERSION:2.0");
@@ -105,7 +105,7 @@ describe("recurrence", () => {
expect(one(e, "RRULE")).toBe("RRULE:FREQ=MONTHLY;BYDAY=-1TH");
});
it("turns a cancelled occurrence into an EXDATE", () => {
it("turns a canceled occurrence into an EXDATE", () => {
const e = { ...weekly, recurrenceOverrides: { "2026-09-09T09:00:00": null } };
expect(one(e, "EXDATE")).toBe("EXDATE;TZID=Europe/Berlin:20260909T090000");
expect(find([e], "BEGIN:VEVENT")).toHaveLength(1);
@@ -166,7 +166,7 @@ describe("the rest of an event", () => {
expect(one(e, "TRANSP")).toBe("TRANSP:TRANSPARENT");
});
it("writes the organiser and the guests, with what each answered", () => {
it("writes the organizer and the guests, with what each answered", () => {
const e = {
...base,
organizerCalendarAddress: "mailto:[email protected]",
+4 -4
View File
@@ -19,7 +19,7 @@ describe("resolveUiLanguage", () => {
});
it("refuses a language whose strings are not shipped", () => {
// The account travels between machines and can outlive a catalogue. A
// The account travels between machines and can outlive a catalog. A
// page that says lang="fr" while rendering English is worse than one that
// admits to English: it stops the reader translating it themselves.
// Derived rather than named, so shipping another language does not turn
@@ -30,7 +30,7 @@ describe("resolveUiLanguage", () => {
});
it("carries the Beta flag until a person has signed the language off", () => {
// Not a completeness measure. A catalogue can be word-for-word finished
// Not a completeness measure. A catalog can be word-for-word finished
// and still read like a machine wrote it, which is what this marks.
// Every shipped language except English is unreviewed, and stays marked
// until a person says otherwise.
@@ -40,12 +40,12 @@ describe("resolveUiLanguage", () => {
}
});
it("honours one that is", () => {
it("honors one that is", () => {
for (const l of UI_LANGUAGES) expect(resolveUiLanguage(l.tag)).toBe(l.tag);
});
it("only offers languages that resolve to themselves", () => {
// Guards the ordering mistake: adding a picker entry before its catalogue.
// Guards the ordering mistake: adding a picker entry before its catalog.
for (const l of UI_LANGUAGES) {
expect(resolveUiLanguage(l.tag)).toBe(l.tag);
expect(l.name.trim()).not.toBe("");
+1 -1
View File
@@ -94,7 +94,7 @@ describe("comparatorsFor, custom levels", () => {
});
describe("optional sorts, which a server is allowed to refuse", () => {
it("recognises the keyword properties", () => {
it("recognizes the keyword properties", () => {
expect(isOptionalSort({ property: "hasKeyword", keyword: "$seen" })).toBe(true);
expect(isOptionalSort({ property: "someInThreadHaveKeyword", keyword: "$flagged" })).toBe(true);
expect(isOptionalSort({ property: "receivedAt" })).toBe(false);
+7 -7
View File
@@ -1,5 +1,5 @@
import { afterEach, describe, expect, it } from "vitest";
import { isLocalisedName, mailboxDisplayName, mailboxDisplayPath } from "@/lib/mailboxName";
import { isLocalizedName, mailboxDisplayName, mailboxDisplayPath } from "@/lib/mailboxName";
import { setCatalog, type Catalog } from "@/lib/i18n";
import type { Mailbox } from "@/jmap/types";
@@ -19,7 +19,7 @@ const mb = (id: string, name: string, role: string | null = null, parentId: stri
afterEach(() => setCatalog("en", { strings: {}, plurals: {} }));
describe("mailboxDisplayName", () => {
it("is the server's name until a catalogue says otherwise", () => {
it("is the server's name until a catalog says otherwise", () => {
expect(mailboxDisplayName(mb("1", "Deleted Items", "trash"))).toBe("Deleted Items");
});
@@ -43,18 +43,18 @@ describe("mailboxDisplayName", () => {
});
});
describe("isLocalisedName", () => {
describe("isLocalizedName", () => {
it("tells an editor when the name on screen is not the server's", () => {
// A rename box prefilled with "Papierkorb" would rename the folder to that
// the moment somebody pressed Save — a real change made by accident.
expect(isLocalisedName(mb("1", "Deleted Items", "trash"))).toBe(true);
expect(isLocalisedName(mb("2", "Newsletters"))).toBe(false);
expect(isLocalisedName(mb("3", "Work", "subscribed"))).toBe(false);
expect(isLocalizedName(mb("1", "Deleted Items", "trash"))).toBe(true);
expect(isLocalizedName(mb("2", "Newsletters"))).toBe(false);
expect(isLocalizedName(mb("3", "Work", "subscribed"))).toBe(false);
});
});
describe("mailboxDisplayPath", () => {
it("localises each part that has a role and leaves the rest", () => {
it("localizes each part that has a role and leaves the rest", () => {
setCatalog("de", de);
const all = { a: mb("a", "Inbox", "inbox"), b: mb("b", "Projects", null, "a") };
expect(mailboxDisplayPath(all.b!, all)).toBe("Posteingang / Projects");
+1 -1
View File
@@ -35,7 +35,7 @@ describe("renderMarkdown", () => {
/*
* Markdown passes raw HTML through by design, and the file came from
* somewhere else -- an upload, or a share from another account. Every one of
* these renders as a script tag without a sanitiser.
* these renders as a script tag without a sanitizer.
*/
it("takes out anything that would execute", () => {
const html = renderMarkdown("<script>alert(1)</script>\n\n<img src=x onerror=alert(1)>\n\n<iframe src='https://evil.example'></iframe>\n");
+1 -1
View File
@@ -85,7 +85,7 @@ describe("the rest of the schema", () => {
expect(phones).toContainEqual(expect.objectContaining({ number: "3", features: { pager: true } }));
});
it("reads the organisation, its units and the job title", () => {
it("reads the organization, its units and the job title", () => {
const c = card("dn: cn=X\ncn: X\no: Example Corp\nou: Research\nou: Optics\ntitle: Lens Grinder\n")!;
expect(values(c.organizations)[0]).toMatchObject({
name: "Example Corp",
+2 -2
View File
@@ -5,7 +5,7 @@ describe("the palettes themselves", () => {
it("has a light and a dark half for every one of them", () => {
// The reason there is no "this palette is dark only" machinery: there is
// no such palette. ihasmail's own gained a light half, and the override,
// the toggle's memory and a greyed-out control all went with it.
// the toggle's memory and a grayed-out control all went with it.
expect(PALETTES.map((p) => p.id)).toEqual([
"default", "ihasmail", "dracula", "gruvbox", "rose-pine", "tokyo-night",
"catppuccin", "solarized", "ayu", "kanagawa", "everforest", "primer",
@@ -75,7 +75,7 @@ describe("legacyTheme, read by a device still on an older build", () => {
});
describe("toggleTarget", () => {
it("flips the mode and keeps the colours, whatever the palette", () => {
it("flips the mode and keeps the colors, whatever the palette", () => {
for (const palette of ["default", "ihasmail", "gruvbox", "dracula", "rose-pine", "tokyo-night"] as const) {
expect(toggleTarget({ palette, mode: "dark" }, false)).toEqual({ palette, mode: "light" });
expect(toggleTarget({ palette, mode: "light" }, false)).toEqual({ palette, mode: "dark" });
@@ -30,7 +30,7 @@ describe("permission labels", () => {
* does not: a missing one would show English in the middle of a translated
* picker, and a stale one would never be looked up.
*/
describe("the permission catalogues", () => {
describe("the permission catalogs", () => {
const modules = import.meta.glob<{ permissionCatalog: PermissionCatalog }>("../../locales/permissions/*.ts");
const tagOf = (path: string) => path.split("/").pop()!.replace(/\.ts$/, "");
const languages = UI_LANGUAGES.map((l) => l.tag).filter((tag) => tag !== "en");
+1 -1
View File
@@ -7,7 +7,7 @@ import { settingsAlreadyLoadedFor, stopSettingsSync } from "../settingsSync";
* Settings used to live only in localStorage, so nothing followed the user
* between devices — issue #54, whose sharpest case is the default identity:
* with none set, the address that sorts first wins, so mail goes out from an
* address the recipient may not recognise.
* address the recipient may not recognize.
*
* The split is written as a list of exceptions, which means the interesting
* test is not "does this key sync" but "does a key added later sync without
+1 -1
View File
@@ -109,7 +109,7 @@ describe("sharing a file", () => {
await expect(shareFile(aFile())).resolves.toBe("unsupported");
});
it("raises anything it does not recognise, so a real fault is still reported", async () => {
it("raises anything it does not recognize, so a real fault is still reported", async () => {
stubNavigator({
share: vi.fn(async () => { throw new DOMException("boom", "DataError"); }),
canShare: (() => true) as unknown as Navigator["canShare"],
+1 -1
View File
@@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest";
import { buildMarkerSignature, byteLength, compactHtml, markerOf, signatureTooLong, SIGNATURE_LIMIT } from "../signatureHtml";
describe("signature compaction", () => {
it("strips office cruft and non-essential styles but keeps colours and links", () => {
it("strips office cruft and non-essential styles but keeps colors and links", () => {
const src = `<!--[if gte mso 9]><xml>x</xml><![endif]--><div class="WordSection1" style="mso-margin-top-alt:auto;line-height:115%;font-family:'Calibri',sans-serif;color:windowtext"><p class="MsoNormal" style="margin:0cm;font-size:11pt"><span lang="EN-US" style="font-size:12pt;color:#1F4E79;mso-fareast-language:EN-US"><b>John Coffey</b></span><o:p></o:p></p><p><span></span></p><a href="https://linuxexpert.org" target="_blank" data-x="1">linuxexpert.org</a><img src="https://x/y.png" width="100" style="mso-foo:bar"></div>`;
const out = compactHtml(src);
expect(out).not.toContain("mso-");
+3 -3
View File
@@ -6,8 +6,8 @@ import { catalog as de } from "@/locales/de";
/**
* The briefing is the only thing standing between a notification action and a
* button labelled in a language the reader does not use — the worker is plain
* JavaScript outside the bundle and cannot reach a catalogue.
* button labeled in a language the reader does not use — the worker is plain
* JavaScript outside the bundle and cannot reach a catalog.
*
* It is also the only place the archive mailbox is named, and getting that
* wrong does not fail visibly: a message would be filed somewhere, just not
@@ -45,7 +45,7 @@ describe("the worker's briefing", () => {
});
it("carries the worker's text in the language the tab is in", async () => {
// The worker has no catalogue. Everything it will say has to be said here
// The worker has no catalog. Everything it will say has to be said here
// first, or a German reader gets English buttons on their lock screen.
setCatalog("de", de);
const { store } = fakeCaches();
+1 -1
View File
@@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest";
import { SWIPE_CHOICES, describeSwipe, type SwipeAction } from "../swipe";
/**
* A swipe names what it is about to do on a coloured strip the reader sees for
* A swipe names what it is about to do on a colored strip the reader sees for
* about a third of a second before letting go. These check that the name is
* true in the folder it is being read in — which is the whole reason the
* descriptor exists rather than a fixed label per setting.
+1 -1
View File
@@ -138,7 +138,7 @@ describe("remembering the palette you were on", () => {
expect(toggleTarget(away, false)).toEqual({ palette: "ihasmail", mode: "dark" });
});
it("keeps the colours when the palette has both sides", () => {
it("keeps the colors when the palette has both sides", () => {
const away = toggleTarget({ palette: "gruvbox", mode: "dark" }, false);
expect(away.palette).toBe("gruvbox");
expect(away.mode).toBe("light");
+1 -1
View File
@@ -65,7 +65,7 @@ const file = (name: string, body: string, extra: Attr[] = []): Attr[] => [
const text = (a: Uint8Array) => new TextDecoder().decode(a);
describe("isTnef", () => {
it("recognises the types and the filename", () => {
it("recognizes the types and the filename", () => {
expect(isTnef("application/ms-tnef", null)).toBe(true);
expect(isTnef("application/vnd.ms-tnef; name=winmail.dat", null)).toBe(true);
expect(isTnef("application/octet-stream", "winmail.dat")).toBe(true);
+1 -1
View File
@@ -27,7 +27,7 @@ describe("lockAxis", () => {
expect(lockAxis(30, 25)).toBe("y");
});
it("counts distance on either axis towards committing", () => {
it("counts distance on either axis toward committing", () => {
expect(lockAxis(0, AXIS_SLOP)).toBe("y");
expect(lockAxis(AXIS_SLOP, 0)).toBe("x");
});
@@ -5,7 +5,7 @@ import { createRoot, type Root } from "react-dom/client";
/*
* The guard's answers, and which of them the dialog leans on.
*
* It shipped with "Discard changes" as the only choice carrying a colour, which
* It shipped with "Discard changes" as the only choice carrying a color, which
* made losing the work the easy thing to click on a dialog whose entire purpose
* is to stop that (#175). The emphasis belongs on the safe answer; the
* destructive one stays legible as destructive without being the loudest thing
+1 -1
View File
@@ -42,7 +42,7 @@ const advertises = (account: AccountLike | undefined, cap: string): boolean =>
Boolean(account && cap in (account.accountCapabilities ?? {}));
/**
* The account to read and write for this capability, honouring the switcher.
* The account to read and write for this capability, honoring the switcher.
*
* Use for anything the reader is looking at: their mail, a shared calendar,
* somebody's files. Not for anything of the reader's own — see below.
+1 -1
View File
@@ -107,7 +107,7 @@ export interface RoleDef {
* more rights than its own and sign in as it. ihasmail refuses to offer that,
* and treats such an account as read-only.
*
* It errs towards refusing. A role that cannot be read -- the viewer lacks
* It errs toward refusing. A role that cannot be read -- the viewer lacks
* `sysRoleGet`, or the id is not in the list -- counts as outranking, because
* an unknown grant is not a grant the viewer can be shown to hold. What it
* cannot see is tenancy: an "Administrator" account is a tenant administrator
+1 -1
View File
@@ -46,7 +46,7 @@ export interface MessageStats {
recorded: boolean;
}
export function summariseMetrics(records: readonly MetricRecord[]): MessageStats {
export function summarizeMetrics(records: readonly MetricRecord[]): MessageStats {
let received = 0;
let sent = 0;
let memory: MessageStats["memory"] = null;
+6 -6
View File
@@ -244,7 +244,7 @@ export type DirectoryObject = "account" | "domain" | "group" | "list" | "role" |
* Stalwart explains a refusal in English, and its words are never shown as
* they are: an interface in German that answers in English reads as broken
* even when the English is exact. Every type the registry returns has its
* own message, and a value a validator refused is recognised by the
* own message, and a value a validator refused is recognized by the
* validator's wording and said again here.
*
* One exception, on purpose. A password policy is the server's to set -- a
@@ -282,16 +282,16 @@ export function describeDirectoryError(err: unknown, object: DirectoryObject = "
return t("One of the chosen domain, role or group can't be used for this account.");
case "overQuota":
return object === "domain"
? t("Your organisation has reached the number of domains it is allowed.")
? t("Your organization has reached the number of domains it is allowed.")
: object === "group"
? t("Your organisation has reached the number of groups it is allowed.")
? t("Your organization has reached the number of groups it is allowed.")
: object === "list"
? t("Your organisation has reached the number of mailing lists it is allowed.")
? t("Your organization has reached the number of mailing lists it is allowed.")
: object === "role"
? t("Your organisation has reached the number of roles it is allowed.")
? t("Your organization has reached the number of roles it is allowed.")
: object === "tenant"
? t("The server allows no more tenants.")
: t("Your organisation has reached the number of accounts it is allowed.");
: t("Your organization has reached the number of accounts it is allowed.");
case "objectIsLinked":
return t("Something still depends on this, so the server kept it.");
case "notFound":
+5 -5
View File
@@ -127,18 +127,18 @@ export async function namesOf(object: "Tenant" | "DnsServer", ids: string[]): Pr
}
/** Lower-case, no surrounding space or root dot: how a domain is written back. */
export function normaliseDomain(name: string): string {
export function normalizeDomain(name: string): string {
return name.trim().toLowerCase().replace(/\.$/, "");
}
/** Enough of a check to catch a typo before the server does; the server decides. */
export function looksLikeDomain(name: string): boolean {
return /^(?=.{1,253}$)([a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z0-9-]{2,63}$/.test(normaliseDomain(name));
return /^(?=.{1,253}$)([a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z0-9-]{2,63}$/.test(normalizeDomain(name));
}
export async function createDomain(input: { name: string; description: string }): Promise<string> {
const res = await client.call<SetResponse>("x:Domain/set", {
create: { n: { name: normaliseDomain(input.name), description: input.description.trim() || null } },
create: { n: { name: normalizeDomain(input.name), description: input.description.trim() || null } },
});
refused(res, "notCreated");
const id = (res.created?.n as { id?: string } | undefined)?.id;
@@ -179,8 +179,8 @@ export interface DnsRecord {
/**
* Read the zone file Stalwart computes for a domain.
*
* Its serialiser writes one record per line as `name IN TYPE value`, and a TXT
* record longer than 255 bytes as a parenthesised run of quoted strings, one
* Its serializer writes one record per line as `name IN TYPE value`, and a TXT
* record longer than 255 bytes as a parenthesized run of quoted strings, one
* per line. A DNS provider's form wants the whole value, so the strings are
* joined and unescaped; the original lines are kept for anyone pasting into a
* zone. Anything that does not parse is kept too, as its own row, rather than
+1 -1
View File
@@ -72,7 +72,7 @@ export async function loadRoleDefaults(): Promise<RoleDefaults | null> {
}
}
/** Stalwart's labelled permission list, through ihasmail's server. */
/** Stalwart's labeled permission list, through ihasmail's server. */
export async function loadPermissionList(): Promise<PermissionInfo[]> {
const res = await apiFetch<{ permissions: PermissionInfo[] }>("/api/admin/permissions");
return res.permissions;
+2 -2
View File
@@ -53,8 +53,8 @@ function bodyText(email: Email): string {
* Everyone the message was between, as guests: the sender and the people it
* was addressed to.
*
* The reader's own addresses come out -- they are the organiser, and an
* organiser listed among their own guests is an event that invites you to your
* The reader's own addresses come out -- they are the organizer, and an
* organizer listed among their own guests is an event that invites you to your
* own appointment. Bcc stays out too, on a message the reader sent themselves:
* a blind recipient added to a guest list is visible to every other guest, and
* turning a hidden copy into a public one is not something a menu item should
+1 -1
View File
@@ -3,7 +3,7 @@
*
* `import.meta.env.BASE_URL` is Vite's own copy of the `base` it built with,
* and `vite.config.ts` sets that from `BASE_PATH` through the shared
* normaliser -- so this is the same answer the server reached, not a second
* normalizer -- so this is the same answer the server reached, not a second
* guess at it. Reading it here rather than re-deriving it from
* `window.location` matters because the app is a SPA: at `/mail/inbox/abc`
* there is nothing in the address that says how much of it is the mount.
+1 -1
View File
@@ -23,7 +23,7 @@ export interface Birthday {
age: number | null;
}
/** The prefix marking a synthesised event, so nothing tries to save one. */
/** The prefix marking a synthesized event, so nothing tries to save one. */
export const BIRTHDAY_ID_PREFIX = "ihm-birthday:";
/** The virtual calendar's id. Not a JMAP id, and deliberately unlike one. */
+5 -5
View File
@@ -64,7 +64,7 @@ export function withPrefs<T>(over: Partial<DateTimePrefs>, fn: () => T): T {
}
}
/** Locale reported by Stalwart for this account (normalised), or null. */
/** Locale reported by Stalwart for this account (normalized), or null. */
export function setServerLocale(raw: string | null | undefined): void {
serverLocale = normalizeLocale(raw);
}
@@ -193,7 +193,7 @@ function num(value: number, digits: number): string {
return f.format(value);
}
/** Time-of-day options honouring the 12h/24h preference. */
/** Time-of-day options honoring the 12h/24h preference. */
export function timeOptions(): Intl.DateTimeFormatOptions {
switch (prefs.timeFormat) {
case "24":
@@ -562,13 +562,13 @@ export function localeOptions(): LocaleOption[] {
* Weekday names in the reader's locale, indexed by JSCalendar's two-letter day.
*
* These used to be a table of English strings with a `short` of "M", "T", "W"…
* which could not become catalogue entries at all: "T" is both Tuesday and
* which could not become catalog entries at all: "T" is both Tuesday and
* Thursday and "S" is both Saturday and Sunday, so the key collides with
* itself. A catalogue cannot hold two translations under one key, and no
* itself. A catalog cannot hold two translations under one key, and no
* amount of translating fixes that — the data was wrong, not the wiring.
*
* Intl has the names already, in every locale, in three widths, and gets the
* plural and capitalisation conventions right without anybody maintaining a
* plural and capitalization conventions right without anybody maintaining a
* list. 2026-06-01 is a Monday; the rest follow from it.
*/
export type WeekdayKey = "mo" | "tu" | "we" | "th" | "fr" | "sa" | "su";
+1 -1
View File
@@ -27,7 +27,7 @@ function unsafe(ch: string): boolean {
}
/**
* Long enough to stay recognisable, short enough to survive a 255-*byte* limit
* Long enough to stay recognizable, short enough to survive a 255-*byte* limit
* once a CJK subject is three bytes a character.
*/
const MAX = 80;
+1 -1
View File
@@ -54,7 +54,7 @@ export function isShared(node: Pick<FileNode, "shareWith">): boolean {
* legal moves behind a disabled drop. The server refuses those with a message
* of its own, which is a better answer than a silent one.
*/
/** The MIME a dragged node is offered under, so a target can recognise it. */
/** The MIME a dragged node is offered under, so a target can recognize it. */
export const NODE_MIME = "application/x-ihasmail-filenode";
/**
+1 -1
View File
@@ -66,7 +66,7 @@ export function canMoveFolderTo(mailboxes: Record<Id, Mailbox>, id: Id, targetId
return targetId === null || Boolean(mailboxes[targetId]?.myRights.mayCreateChild);
}
/** The colour chosen for a folder, if any. Ids are used, so a rename keeps it. */
/** The color chosen for a folder, if any. Ids are used, so a rename keeps it. */
export function folderColor(colors: Record<string, string>, id: Id): string | null {
return colors[id] ?? null;
}
+16 -16
View File
@@ -50,7 +50,7 @@ function ensureHooks() {
* convincing fake over the whole app. The control that actually stops that is
* layout containment on an ancestor of the shadow host (see `.message-body` in
* app.css), which mail CSS has no selector for. This is the second line:
* neutralise the declarations themselves, and defang `:host`, which is how mail
* neutralize the declarations themselves, and defang `:host`, which is how mail
* CSS would otherwise reach the host element.
*/
function hardenCss(css: string): string {
@@ -183,7 +183,7 @@ export const EMAIL_BASE_CSS = `
.ihm-email-root * { max-width:100%; box-sizing:border-box; }
.ihm-email-root [style*="position:fixed"], .ihm-email-root [style*="position: fixed"] { position:static !important; }
/* "Follow the app theme" — only applied to mail that brings no colours of its
/* "Follow the app theme" — only applied to mail that brings no colors of its
own. The custom properties are inherited from the host document, so a theme
switch repaints the message without re-rendering it. */
.ihm-email-root.themed { color: var(--fg, #1f2937); background: var(--bg-elev, #fff); }
@@ -193,7 +193,7 @@ export const EMAIL_BASE_CSS = `
.ihm-email-root.themed img[data-ihm-blocked] { background: var(--bg-sunken, #f1f5f9) repeating-linear-gradient(45deg, var(--bg-hover, #e2e8f0) 0 6px, transparent 6px 12px); border-color: var(--border-strong, #cbd5e1); }
/* "Even mail that styles itself" — the second, opt-in switch, applied on top of
.themed. Everything the sender coloured is neutralised except the surfaces
.themed. Everything the sender colored is neutralized except the surfaces
marked by markKeptSurfaces() and what it marked as sitting on them, so a
white wrapper table
stops being a bright card while a blue button keeps its white label. The
@@ -205,7 +205,7 @@ export const EMAIL_BASE_CSS = `
`;
/**
* Does this message paint itself? Mail that sets a background or text colour
* Does this message paint itself? Mail that sets a background or text color
* has a design of its own, and forcing a dark palette on half of it is worse
* than leaving it alone — so those keep the light card they were built for.
*
@@ -227,7 +227,7 @@ export function htmlDeclaresColors(html: string, bodyStyle = ""): boolean {
/* ---------- forcing the theme onto mail that styles itself ---------- */
/**
* Relative luminance per WCAG 2.x, or `null` when the colour cannot be read.
* Relative luminance per WCAG 2.x, or `null` when the color cannot be read.
*
* Only what actually turns up in mail is parsed: hex in three, six or eight
* digits, `rgb()`/`rgba()`, and the handful of names senders still write out.
@@ -263,7 +263,7 @@ export function relativeLuminance(color: string): number | null {
if (m[4] !== undefined) a = m[4].endsWith("%") ? Number(m[4].slice(0, -1)) / 100 : Number(m[4]);
}
if ([r, g, b, a].some((n) => !Number.isFinite(n))) return null;
// A fully transparent colour paints nothing, whatever its channels say.
// A fully transparent color paints nothing, whatever its channels say.
if (a === 0) return null;
const lin = (c: number) => { const x = c / 255; return x <= 0.03928 ? x / 12.92 : ((x + 0.055) / 1.055) ** 2.4; };
return 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b);
@@ -272,7 +272,7 @@ export function relativeLuminance(color: string): number | null {
/**
* Above this, a background is a sheet the message is laid on rather than a
* thing drawn on top of it. White wrappers sit at 1.0; the blue of a call to
* action lands near 0.09, mid-grey near 0.22.
* action lands near 0.09, mid-gray near 0.22.
*/
export const LIGHT_SURFACE_LUMINANCE = 0.5;
@@ -288,26 +288,26 @@ function declaredLuminance(el: HTMLElement): number | null {
*
* The reader has asked for their palette on mail that brings its own, which
* cannot be done perfectly — this is the same bargain a dark-reader extension
* makes. What it can do is tell the two kinds of colour apart: a **sheet** the
* design sits on, which is what reads as a bright card and is neutralised, and
* makes. What it can do is tell the two kinds of color apart: a **sheet** the
* design sits on, which is what reads as a bright card and is neutralized, and
* a **painted surface** — a button, a banner — which is kept whole so its
* label stays legible on it.
*
* Two attributes come out of this. `data-ihm-keep` is a painted surface, which
* keeps its own colours. `data-ihm-in-keep` is an element sitting on one with
* no background of its own, whose colour is left alone so a white label on a
* blue button stays readable. One rule in EMAIL_BASE_CSS neutralises
* keeps its own colors. `data-ihm-in-keep` is an element sitting on one with
* no background of its own, whose color is left alone so a white label on a
* blue button stays readable. One rule in EMAIL_BASE_CSS neutralizes
* everything else.
*
* The distinction that matters is that being *inside* a painted surface is not
* inherited past a sheet. A light table nested in a dark 600px card is still a
* sheet and is still neutralised — that is issue #310, where a dark campaign
* sheet and is still neutralized — that is issue #310, where a dark campaign
* rendered with beige cards inside it because the exemption used to be
* `[data-ihm-keep] *` in CSS and could not see the difference. Paint resumes
* below it: a dark button inside that nested table is kept as usual.
*
* Nothing the sender wrote is removed, so turning the switch off puts the
* message back exactly as it was — and a colour that arrived from a `<style>`
* message back exactly as it was — and a color that arrived from a `<style>`
* block rather than an attribute is covered too, which is most of them in
* modern templates.
*/
@@ -336,11 +336,11 @@ export function markKeptSurfaces(root: ParentNode): number {
kept++;
childrenOnPaint = true;
} else if (lum !== null) {
// A sheet, wherever it sits. Left unmarked so it neutralises, and it
// A sheet, wherever it sits. Left unmarked so it neutralizes, and it
// ends the protection rather than passing it on.
childrenOnPaint = false;
} else if (onPaint) {
// No background of its own, sitting on paint: leave its colour alone.
// No background of its own, sitting on paint: leave its color alone.
el.setAttribute("data-ihm-in-keep", "");
}
+16 -16
View File
@@ -5,9 +5,9 @@ import { DEFAULT_UI_LANGUAGE, resolveUiLanguage } from "@/lib/languages";
* Translation, in about as little machinery as the job takes.
*
* The English text is the key. `t("Archive")` looks "Archive" up in whatever
* catalogue is loaded and returns the English if it is not there, which buys
* catalog is loaded and returns the English if it is not there, which buys
* three things worth more than tidy symbolic keys: there is no English
* catalogue to keep in step with the code, a missing translation degrades to
* catalog to keep in step with the code, a missing translation degrades to
* readable English rather than to `mail.list.archive`, and extracting a string
* is wrapping it rather than inventing a name for it. Names are where
* extraction stalls -- 55 components is a lot of small naming arguments.
@@ -71,7 +71,7 @@ export function t(source: string, vars?: Vars): string {
*
* So a context can be given, and the lookup becomes context + source while the
* fallback stays the plain English. A translator sees the context and knows
* which sense to render; a catalogue that has not got round to it still
* which sense to render; a catalog that has not got round to it still
* renders the English word, which was right in English all along.
*
* The separator is a control character rather than a punctuation mark, which
@@ -91,7 +91,7 @@ export function tc(context: string, source: string, vars?: Vars): string {
* Two forms is an English assumption and does not survive the second phase of
* this: Russian and Ukrainian use three, and picking between them is not
* `n === 1`. `Intl.PluralRules` knows the rule for every language the browser
* knows, so the catalogue supplies the forms and the runtime picks.
* knows, so the catalog supplies the forms and the runtime picks.
*
* The English `other` form is the key, so a call site reads as the sentence it
* produces and needs no invented name.
@@ -143,7 +143,7 @@ export function tNode(source: string, parts: Record<string, ReactNode>, vars?: V
return out;
}
/** Subscribe to catalogue changes without React. Used by the tests. */
/** Subscribe to catalog changes without React. Used by the tests. */
export function subscribeForTest(fn: () => void): () => void {
listeners.add(fn);
return () => void listeners.delete(fn);
@@ -155,27 +155,27 @@ export function currentLanguage(): string {
}
/**
* Put a catalogue in force.
* Put a catalog in force.
*
* Exported for tests and for the loader; nothing else should call it, because
* the tag and the catalogue have to move together or `plural` selects with one
* the tag and the catalog have to move together or `plural` selects with one
* language's rules against another's forms.
*/
export function setCatalog(tag: string, catalog: Catalog): void {
/*
* Publishing only when something actually changed is not an optimisation
* Publishing only when something actually changed is not an optimization
* here, it is the thing that stops an infinite loop.
*
* The root keys its tree on the language version, so a publish remounts
* everything. Remounting re-runs the effect that fetches the account's
* settings file, which calls `hydrate`, which calls `applyLang`, which lands
* back here -- with the identical tag and the identical catalogue. Publishing
* back here -- with the identical tag and the identical catalog. Publishing
* that non-change bumped the version again and went round for ever: the
* message list refetched on every pass, which is what it looked like from
* the outside.
*
* Reference equality is enough. `EMPTY` is a module constant and a
* dynamically imported catalogue is cached, so the same language really does
* dynamically imported catalog is cached, so the same language really does
* hand back the same object.
*/
if (currentTag === tag && current === catalog) return;
@@ -188,16 +188,16 @@ export function setCatalog(tag: string, catalog: Catalog): void {
* Load and apply a language.
*
* English is the built-in: it is the source text, so there is nothing to fetch
* and no chance of a missing catalogue leaving the app blank. Everything else
* and no chance of a missing catalog leaving the app blank. Everything else
* is a dynamic import, so a reader who never leaves English never downloads a
* catalogue -- which matters, because the main bundle is already large enough
* catalog -- which matters, because the main bundle is already large enough
* to warn about.
*/
/**
* The catalogue load that is in flight, so the first paint can wait for it.
* The catalog load that is in flight, so the first paint can wait for it.
*
* Without this, a cold load paints before the catalogue lands. Components
* recover -- the tree is rebuilt when the catalogue arrives -- but a string
* Without this, a cold load paints before the catalog lands. Components
* recover -- the tree is rebuilt when the catalog arrives -- but a string
* computed in an effect does not: a toast fired in that window is emitted in
* English and stays English, in an interface that is otherwise German.
* Reported as a stale-folder toast that ignored the language setting.
@@ -224,7 +224,7 @@ async function loadLanguageNow(tag: string): Promise<void> {
const mod = (await import(`../locales/${resolved}.ts`)) as { catalog: Catalog };
setCatalog(resolved, mod.catalog);
} catch {
// A catalogue that will not load leaves English in force rather than a
// A catalog that will not load leaves English in force rather than a
// half-rendered page. `resolveUiLanguage` should already have prevented
// this; it being reachable at all is why it is caught.
setCatalog(DEFAULT_UI_LANGUAGE, EMPTY);
+5 -5
View File
@@ -14,7 +14,7 @@
* is the reverse, which is why `uiLanguage` and `locale` are separate settings
* rather than one.
*
* Adding a language means adding its catalogue and then adding it here, in
* Adding a language means adding its catalog and then adding it here, in
* that order. RTL languages — Arabic, Hebrew, Persian — need bidi and layout
* work well beyond strings, so they are not simply a matter of another entry.
*/
@@ -26,8 +26,8 @@ export interface UiLanguage {
/**
* Machine-translated and not yet checked by somebody who speaks it.
*
* Stays true until a native speaker has actually read the catalogue and said
* so. It is not a measure of how complete the file is -- a catalogue can be
* Stays true until a native speaker has actually read the catalog and said
* so. It is not a measure of how complete the file is -- a catalog can be
* word-for-word finished and still read like a machine wrote it, which is
* the thing this flag is about. Removing it is a deliberate act by a person,
* not something a coverage number earns.
@@ -56,8 +56,8 @@ export const DEFAULT_UI_LANGUAGE = "en";
/**
* The language to actually render in.
*
* A stored preference is only honoured if its strings are still shipped: a
* catalogue can be withdrawn, and an account carrying `de` from another
* A stored preference is only honored if its strings are still shipped: a
* catalog can be withdrawn, and an account carrying `de` from another
* machine must not leave this one claiming to be German while showing English.
*/
export function resolveUiLanguage(stored: string | undefined | null): string {
+4 -4
View File
@@ -109,7 +109,7 @@ export function parseLdif(text: string): LdifRecord[] {
/**
* An identity for an entry, derived from its distinguished name.
*
* Mozilla's schema has no UID, so a re-import had nothing to be recognised by
* Mozilla's schema has no UID, so a re-import had nothing to be recognized by
* and duplicated everything (#223). The `dn` is what the file actually carries,
* and it does not need to be a durable identity to answer the only question
* being asked of it: have I imported this exact entry before? A migration is
@@ -123,7 +123,7 @@ export function parseLdif(text: string): LdifRecord[] {
* *same* address book, are one contact afterwards. Matching is per book, so
* filing two directories in two books keeps them apart.
*
* Normalised for case and for the spacing exporters differ in, which costs
* Normalized for case and for the spacing exporters differ in, which costs
* nothing when a file is compared against itself and helps when it is compared
* against a differently-produced export of the same directory.
*
@@ -131,10 +131,10 @@ export function parseLdif(text: string): LdifRecord[] {
* and duplicates on re-import, as everything did before.
*/
export function uidFromDn(dn: string): string | null {
const normalised = dn
const normalized = dn
.trim()
.toLowerCase()
.replace(/\s+/g, " ")
.replace(/\s*([,=])\s*/g, "$1");
return normalised ? `urn:x-ihasmail:ldif:${encodeURIComponent(normalised)}` : null;
return normalized ? `urn:x-ihasmail:ldif:${encodeURIComponent(normalized)}` : null;
}
+3 -3
View File
@@ -48,8 +48,8 @@ const ROLE_NAMES: Record<string, () => string> = {
/** The folder's name as the reader should see it. */
export function mailboxDisplayName(mailbox: { name: string; role?: string | null } | null | undefined): string {
if (!mailbox) return "";
const localised = mailbox.role ? ROLE_NAMES[mailbox.role] : undefined;
return localised ? localised() : mailbox.name;
const localized = mailbox.role ? ROLE_NAMES[mailbox.role] : undefined;
return localized ? localized() : mailbox.name;
}
/**
@@ -61,7 +61,7 @@ export function mailboxDisplayName(mailbox: { name: string; role?: string | null
* they were only looking at. Renaming a role folder is refused anyway, but
* relying on that would be relying on a rule enforced somewhere else.
*/
export function isLocalisedName(mailbox: { role?: string | null } | null | undefined): boolean {
export function isLocalizedName(mailbox: { role?: string | null } | null | undefined): boolean {
return Boolean(mailbox?.role && mailbox.role in ROLE_NAMES);
}
+1 -1
View File
@@ -11,7 +11,7 @@ import { marked } from "marked";
* something is DOMPurify, which the app already carries for mail.
*
* Rendered inline rather than in a shadow root the way mail bodies are: this
* output is ours, sanitised and styled by `.md-body`, where an email arrives
* output is ours, sanitized and styled by `.md-body`, where an email arrives
* with a design of its own that has to be quarantined from the app's.
*/
+1 -1
View File
@@ -144,7 +144,7 @@ export function cardFromLdif(rec: LdifRecord): Partial<ContactCard> | null {
/*
* The four custom fields have nowhere of their own to go: JSContact has no
* equivalent, and the schema does not say what they hold -- they are whatever
* their owner decided. Appending them to the note keeps them, labelled the
* their owner decided. Appending them to the note keeps them, labeled the
* way Thunderbird labels them, which is worth more than the tidiness of
* dropping something somebody chose to write down.
*/
+4 -4
View File
@@ -4,7 +4,7 @@
* The theme used to be one enum — `system | light | dark | ihasmail` — where
* "ihasmail" carried a whole palette and implied dark. That works for exactly
* one palette. With several, the two questions come apart: **which palette**
* (the colours) and **which mode** (light or dark), and they are chosen
* (the colors) and **which mode** (light or dark), and they are chosen
* separately.
*
* Every palette here is taken from the project that publishes it, all MIT, and
@@ -32,7 +32,7 @@ export interface PaletteMeta {
* the rest -- and are rendered translate="no" so a page translator leaves
* them alone. "Classic" is not a name, it is an adjective describing the
* theme, and a German reader should see "Klassisch". Reported by a native
* speaker reviewing the German catalogue (#247).
* speaker reviewing the German catalog (#247).
*/
translatable?: boolean;
}
@@ -49,7 +49,7 @@ export const PALETTES: PaletteMeta[] = [
{ id: "ayu", name: "Ayu", credit: "Ayu by Konstantin Pschera (MIT)" },
{ id: "kanagawa", name: "Kanagawa", credit: "Kanagawa by rebelot (MIT) — dark is Wave, light is Lotus" },
{ id: "everforest", name: "Everforest", credit: "Everforest by sainnhe (MIT)" },
// Named for the design system rather than for GitHub: the colours are MIT,
// Named for the design system rather than for GitHub: the colors are MIT,
// the name and the logo are trademarks, and nothing here is endorsed.
{ id: "primer", name: "Primer", credit: "GitHub's Primer primitives (MIT); not affiliated with or endorsed by GitHub" },
];
@@ -67,7 +67,7 @@ export function paletteMeta(id: PaletteId | string | null | undefined): PaletteM
* against the OS. That was not true while `ihasmail` was dark-only: the mode
* then had to be overridden by the palette, and the toggle had to remember
* which palette it had set aside on the way to light. Giving that palette a
* light half removed the override, the memory and the greyed-out control in
* light half removed the override, the memory and the grayed-out control in
* one go.
*/
export function effectiveMode(mode: Mode, prefersDark: boolean): ResolvedMode {
+1 -1
View File
@@ -50,7 +50,7 @@ export function splitLabel(label: string): { categoryKey: string; action: string
const loaded = new Map<string, Promise<PermissionCatalog | null>>();
/** The catalogue for a language, or null for English and for a language without a file. */
/** The catalog for a language, or null for English and for a language without a file. */
export function loadPermissionCatalog(tag: string = currentLanguage()): Promise<PermissionCatalog | null> {
if (tag === DEFAULT_UI_LANGUAGE) return Promise.resolve(null);
let pending = loaded.get(tag);
+4 -4
View File
@@ -7,8 +7,8 @@ import { plural, t } from "@/lib/i18n";
*
* This was a table of English strings carrying `label: "Monday"` and
* `short: "M"`, rendered straight into the picker. The long names could have
* become catalogue entries; the short ones could not, because "T" is both
* Tuesday and Thursday and "S" is both Saturday and Sunday, and a catalogue
* become catalog entries; the short ones could not, because "T" is both
* Tuesday and Thursday and "S" is both Saturday and Sunday, and a catalog
* cannot hold two translations under one key. Intl knows all of them.
*/
export const WEEKDAY_KEYS: Array<JSCalendarNDay["day"]> = ["mo", "tu", "we", "th", "fr", "sa", "su"];
@@ -61,7 +61,7 @@ export function ruleFromPreset(preset: RecurrencePreset, start: Date): JSCalenda
*
* Built as whole sentences with placeholders rather than by concatenation.
* The old version appended fragments -- `base += " on " + names` -- which is
* untranslatable however complete the catalogue is: German puts the weekday
* untranslatable however complete the catalog is: German puts the weekday
* list somewhere else in the clause, and a translator handed " on " alone
* cannot move it. Every branch below is one key a translator can rewrite in
* full, including the word order.
@@ -144,7 +144,7 @@ export function describeRule(rule: JSCalendarRecurrenceRule | undefined): string
* "first", "second", "last" -- words, not "1st".
*
* The suffix table this replaced ("st", "nd", "rd", "th") is English spelling
* rules in code: German writes "1.", Japanese "第1", and no catalogue can
* rules in code: German writes "1.", Japanese "第1", and no catalog can
* reach a suffix chosen by arithmetic. JSCalendar's nthOfPeriod is 1-5 or -1
* in practice, so five words and "last" cover it; anything else falls back to
* the bare number, which is wrong in no language.
+2 -2
View File
@@ -1,7 +1,7 @@
/**
* Settings that follow the account rather than the browser.
*
* Everything used to live in localStorage, which meant no preference travelled
* Everything used to live in localStorage, which meant no preference traveled
* between devices — most painfully the default identity, where the fallback is
* whichever address sorts first, so a forgotten setting sends mail from an
* address the recipient may not know (issue #54).
@@ -152,7 +152,7 @@ export async function flushSettingsPush(): Promise<void> {
if (!pending || !armed) return;
const body = pending;
pending = null;
// Serialise: two overlapping writes could land in either order.
// Serialize: two overlapping writes could land in either order.
inFlight = (inFlight ?? Promise.resolve()).then(() => writeSettings(body)).catch(() => undefined);
await inFlight;
}
+1 -1
View File
@@ -74,7 +74,7 @@ export async function collectShare(): Promise<SharedContent | null> {
* The bytes, rather than the Blob holding them.
*
* `new File([blob], …)` is correct and works in a browser, but a Blob
* only counts as a part where the File constructor recognises it as one
* only counts as a part where the File constructor recognizes it as one
* -- and where it does not, it is stringified instead, producing a file
* containing the thirteen characters "[object Blob]" and no error
* anywhere. That is exactly what CI caught on Node 22 while it passed
+3 -3
View File
@@ -183,7 +183,7 @@ export function rulesToSieve(rules: SieveRule[]): string {
*
* Saving replaces the whole script with a fresh serialization of the rules read
* out of it, so whatever was not read is deleted. `sieveToRules` cannot raise
* the alarm by itself: it skips what it does not recognise, so a script cut off
* the alarm by itself: it skips what it does not recognize, so a script cut off
* partway through parses cleanly into a shorter list and looks exactly like one
* that genuinely has fewer rules. That is the shape of the loss in #76 -- a
* truncated download, a plausible parse, and a save that wrote the short
@@ -326,10 +326,10 @@ export function reorderRules(rules: SieveRule[], fromId: string, toId: string, b
*
* Rebuilt as whole sentences with placeholders. The old version concatenated
* fragments -- a header name, an operator, a quoted value, joined by " and "
* -- which no catalogue could fix: German puts the verb last, Japanese does
* -- which no catalog could fix: German puts the verb last, Japanese does
* not separate list items with a word at all, and a translator handed " and "
* on its own cannot move anything. Reported by a native speaker reviewing the
* German catalogue (#247).
* German catalog (#247).
*
* Intl.ListFormat does the joining, so "A, B and C" becomes "A, B und C" and,
* for an anyof rule, the disjunction the language actually uses.
+3 -3
View File
@@ -75,7 +75,7 @@ describe("what the signature is allowed to mean", () => {
expect(shouldRemember(report)).toBe(true);
});
it("the same certificate again is recognised", async () => {
it("the same certificate again is recognized", async () => {
const crypto = await verifyMessage(fixture("signed-rsa.eml"));
if (crypto.kind !== "intact") throw new Error("fixture should verify");
const known: KnownSigner = { fingerprint: crypto.cert.fingerprint, name: "Ada Lovelace", firstSeen: "2026-09-01T00:00:00Z" };
@@ -129,7 +129,7 @@ describe("matching a certificate to an address", () => {
});
});
describe("canonicalisation", () => {
describe("canonicalization", () => {
it("turns a lone LF into CRLF and leaves an existing CRLF alone", () => {
const mixed = new TextEncoder().encode("a\nb\r\nc\n");
expect(new TextDecoder().decode(toCanonicalCrlf(mixed))).toBe("a\r\nb\r\nc\r\n");
@@ -141,7 +141,7 @@ describe("canonicalisation", () => {
});
/*
* The reason canonicalisation is applied at all: a store that hands back a
* The reason canonicalization is applied at all: a store that hands back a
* message with bare LFs would otherwise fail every signature it holds, and
* the message would look identical on screen while doing it.
*/
+1 -1
View File
@@ -3,7 +3,7 @@
*
* This works on bytes, not on a string, and that is the whole point. A
* signature is over an octet sequence: decode it to text, re-encode it, or let
* anything normalise a line ending on the way past, and the digest changes
* anything normalize a line ending on the way past, and the digest changes
* while the message still looks identical on screen. Every part here keeps a
* subarray of the original buffer rather than a rebuilt copy.
*
+1 -1
View File
@@ -19,7 +19,7 @@
* without a certificate authority anywhere in the picture.
*
* So nothing here ever renders the bare word "verified". The caller is given
* the crypto result and the trust judgement separately, and has to say both.
* the crypto result and the trust judgment separately, and has to say both.
*/
import { parseSignedData, type SignerInfo } from "./cms";
import { decodeTransfer, findPart, parseMime, toCanonicalCrlf, type MimePart } from "./mime";
+1 -1
View File
@@ -21,7 +21,7 @@
export interface SpamRule {
/** The rule's own name, as the filter wrote it. */
name: string;
/** What it contributed. Negative moves the message towards clean. */
/** What it contributed. Negative moves the message toward clean. */
score: number;
/** Rspamd's bracketed note, where there is one. */
detail?: string;
+1 -1
View File
@@ -8,7 +8,7 @@
* address book on it.
*
* Reads are gated as well as writes. A machine that was trusted once still has
* the residue, and honouring it would let a previous session's data surface in
* the residue, and honoring it would let a previous session's data surface in
* a later untrusted one.
*/
const PREFIX = "ihasmail:";
+1 -1
View File
@@ -2,7 +2,7 @@
* What the service worker cannot work out for itself.
*
* The worker can act on mail — see the note on `jmap()` in sw.js — but it
* cannot read a catalogue or a store. It is plain JavaScript copied into the
* cannot read a catalog or a store. It is plain JavaScript copied into the
* build, outside the bundle, with no i18n and no idea which mailbox is the
* archive. Both of those are things a tab knows and can simply write down.
*
+1 -1
View File
@@ -35,7 +35,7 @@ export interface SwipeDescriptor {
action: Exclude<SwipeAction, "none">;
label: string;
icon: SwipeIcon;
/** Which colour the strip behind the row takes. */
/** Which color the strip behind the row takes. */
tone: "danger" | "warn" | "accent" | "neutral";
/**
* Whether firing it takes the row out of the list. Those slide the rest of
+1 -1
View File
@@ -12,7 +12,7 @@
* mistake to undo.
* - **A name that is not a placeholder is left alone too.** Templates are
* written by hand and `{{` is not reserved anywhere else, but a body that
* silently ate an unrecognised token would be worse than one that shows it.
* silently ate an unrecognized token would be worse than one that shows it.
*
* Dates and times go through `datetime.ts` rather than `toLocaleDateString`,
* so a template follows the same date order and clock the rest of the app was
+7 -7
View File
@@ -41,7 +41,7 @@ export type Axis = "x" | "y" | null;
/**
* Which way a drag has committed, once it has moved far enough to tell.
*
* Deliberately biased towards the vertical. Scrolling is what a finger on a
* Deliberately biased toward the vertical. Scrolling is what a finger on a
* message list is doing almost every time, and a scroll misread as a swipe
* grabs the list out from under the reader, while a swipe misread as a scroll
* costs them a second attempt. So `x` has to win clearly -- a drag that is
@@ -67,7 +67,7 @@ export function swipeThreshold(width: number): number {
}
/**
* How far the row actually moves for a finger that has travelled `dx`.
* How far the row actually moves for a finger that has traveled `dx`.
*
* One-to-one until the action would fire, and increasingly reluctant after
* that. The resistance is the only thing that tells a thumb, without the
@@ -113,7 +113,7 @@ export function pullDistance(dy: number): number {
* without this the only feedback arrives after the row has already gone.
*
* iOS supports none of this and never has, so this is silently nothing there
* rather than something to apologise for. Wrapped because a vibration inside
* rather than something to apologize for. Wrapped because a vibration inside
* a cross-origin iframe throws rather than returning false.
*/
export function haptic(pattern: number | number[] = 8): void {
@@ -197,7 +197,7 @@ export function useTouchRow({ enabled, onLongPress, canSwipe, onSwipeMove, onSwi
if (onLongPress) {
timer.current = window.setTimeout(() => {
timer.current = null;
// Still here, still not moving: nothing has cancelled us.
// Still here, still not moving: nothing has canceled us.
if (!start.current || axis.current) return;
swallowClick.current = true;
onLongPress(start.current.target);
@@ -251,7 +251,7 @@ export function useTouchRow({ enabled, onLongPress, canSwipe, onSwipeMove, onSwi
/*
* Crossing back the other way mid-gesture. The direction is re-read
* rather than held from the lock, so a reader who overshoots, thinks
* better of it and drags back past centre gets the other action offered
* better of it and drags back past center gets the other action offered
* instead of the row refusing to move.
*/
if (d !== dir.current) {
@@ -322,7 +322,7 @@ export function useTouchRow({ enabled, onLongPress, canSwipe, onSwipeMove, onSwi
*
* Native listeners rather than React props because the move handler has to be
* able to call `preventDefault`, and React attaches its own passively. Bound
* to the scroll container itself so that everything inside it -- a virtualised
* to the scroll container itself so that everything inside it -- a virtualized
* list included -- comes down with the pull without knowing about it.
*/
export function usePullToRefresh(
@@ -542,7 +542,7 @@ export function swipeNavDirection(dx: number, width: number): -1 | 0 | 1 {
* moment it can be decided cleanly.
* - **It does not start on the toolbar.** Buttons live there.
*
* The axis lock is the shared one, so it keeps the same bias towards the
* The axis lock is the shared one, so it keeps the same bias toward the
* vertical: the day grid scrolls through the hours, and a scroll misread as a
* swipe throws the reader into another day.
*/