CI / verify (pull_request) Successful in 1m10s
CI / infrastructure-smoke (pull_request) Successful in 34s
CI / package-release (pull_request) Successful in 50s
CI / migration-smoke (pull_request) Successful in 2m26s
CI / image-build (pull_request) Successful in 1m12s
CI / deploy-smoke (same-origin) (pull_request) Successful in 42s
CI / package-publication (pull_request) Successful in 54s
CI / deploy-smoke (split) (pull_request) Successful in 3m17s
Stage 1 PR 6 (ADR 0062). /account shows who is signed in; /account/appearance chooses System, Light, or Dark and reduced motion (System, Reduce, Allow). - Core: ekklesia_core_user_preference, one typed, CHECK-constrained, nullable column per preference (null = never chosen), migration 0008_user_preferences (additive), @ekklesia/core/account store, and the authenticated-baseline permission core.account.self.manage. - API: GET /api/v1/account (name, email, preferences) and partial PATCH /api/v1/account/preferences, self-only by construction: no user id is accepted anywhere; trusted Origin and body limit on the mutation. - Shell: the account control shows name, email, and initials (never the unvetted image URL) and offers Account, Appearance, Sign out; native radio groups apply a choice at once through the ADR 0060 controller and save it; the server's answer wins and a failed save reverts. - First paint: a classic, same-origin, render-blocking appearance.js in <head> applies a two-word localStorage cache before paint (no inline script, CSP unchanged). The account replaces the cache on every load, sign-out clears it, and other tabs follow via the storage event. - Deploy smoke: real-Chromium checks for light/dark, OS emulation, overrides, reload persistence, pre-paint, Shadow DOM modules, reduced motion, the display staying light, and phone width; the browser container gets a 512 MiB /dev/shm so a long session no longer fails requests with ERR_INSUFFICIENT_RESOURCES. Co-Authored-By: Claude Opus 5.5 <[email protected]>
302 lines
11 KiB
TypeScript
302 lines
11 KiB
TypeScript
import { readFile } from "node:fs/promises";
|
|
import path from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
import {
|
|
createEkklesiaThemeController,
|
|
DESIGN_TOKEN_APPEARANCE_ATTRIBUTE,
|
|
DESIGN_TOKEN_MOTION_ATTRIBUTE,
|
|
EKKLESIA_APPEARANCES,
|
|
EKKLESIA_MOTION_PREFERENCES,
|
|
} from "@ekklesia/design-tokens";
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
import { accountDisplayName, accountInitials } from "../src/browser/AccountAvatar.js";
|
|
import {
|
|
APPEARANCE_CACHE_KEY,
|
|
createLocalAppearanceCache,
|
|
createMemoryAppearanceCache,
|
|
parseAppearanceCache,
|
|
serializeAppearanceChoice,
|
|
} from "../src/browser/appearance-cache.js";
|
|
import { createAppearancePreference } from "../src/browser/appearance-preference.js";
|
|
|
|
/**
|
|
* The browser half of account appearance (ADR 0062): the pre-paint cache, the pre-paint script,
|
|
* and the one handle that puts a choice into effect through the ADR 0060 theme controller.
|
|
*/
|
|
|
|
const packageDirectory = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
|
|
|
|
function memoryStorage(initial: Record<string, string> = {}) {
|
|
const values = new Map(Object.entries(initial));
|
|
return {
|
|
getItem: vi.fn((key: string) => values.get(key) ?? null),
|
|
removeItem: vi.fn((key: string) => {
|
|
values.delete(key);
|
|
}),
|
|
setItem: vi.fn((key: string, value: string) => {
|
|
values.set(key, value);
|
|
}),
|
|
values,
|
|
};
|
|
}
|
|
|
|
function throwingStorage() {
|
|
const fail = () => {
|
|
throw new Error("SecurityError: storage is disabled");
|
|
};
|
|
return { getItem: vi.fn(fail), removeItem: vi.fn(fail), setItem: vi.fn(fail) };
|
|
}
|
|
|
|
function recordingTarget() {
|
|
const attributes = new Map<string, string>();
|
|
return {
|
|
attributes,
|
|
setAttribute: (name: string, value: string) => {
|
|
attributes.set(name, value);
|
|
},
|
|
};
|
|
}
|
|
|
|
describe("the appearance cache", () => {
|
|
it("accepts every combination of the design tokens' own values, and round-trips each", () => {
|
|
for (const appearance of EKKLESIA_APPEARANCES) {
|
|
for (const motion of EKKLESIA_MOTION_PREFERENCES) {
|
|
const serialized = serializeAppearanceChoice({ appearance, motion });
|
|
expect(parseAppearanceCache(serialized)).toEqual({ appearance, motion });
|
|
}
|
|
}
|
|
});
|
|
|
|
it("refuses anything else, so a stale or tampered value is no cache at all", () => {
|
|
for (const value of [
|
|
null,
|
|
undefined,
|
|
"",
|
|
"dark",
|
|
"dark reduce extra",
|
|
"Dark reduce",
|
|
"dark reduce",
|
|
"sepia reduce",
|
|
"dark none",
|
|
'{"appearance":"dark","motion":"reduce"}',
|
|
42,
|
|
]) {
|
|
expect(parseAppearanceCache(value), String(value)).toBeUndefined();
|
|
}
|
|
});
|
|
|
|
it("holds two words under one namespaced key, and nothing about the account", () => {
|
|
const storage = memoryStorage();
|
|
const cache = createLocalAppearanceCache(storage);
|
|
|
|
cache.write({ appearance: "dark", motion: "reduce" });
|
|
|
|
expect([...storage.values.entries()]).toEqual([[APPEARANCE_CACHE_KEY, "dark reduce"]]);
|
|
expect(APPEARANCE_CACHE_KEY).toBe("ekklesia.dashboard.appearance");
|
|
expect(cache.read()).toEqual({ appearance: "dark", motion: "reduce" });
|
|
cache.clear();
|
|
expect(storage.values.size).toBe(0);
|
|
expect(cache.read()).toBeUndefined();
|
|
});
|
|
|
|
it("never breaks when storage is missing or throws", () => {
|
|
for (const storage of [undefined, throwingStorage()]) {
|
|
const cache = createLocalAppearanceCache(storage);
|
|
expect(() => cache.write({ appearance: "dark", motion: "allow" })).not.toThrow();
|
|
expect(cache.read()).toBeUndefined();
|
|
expect(() => cache.clear()).not.toThrow();
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("the appearance preference", () => {
|
|
function build(initial?: {
|
|
appearance: "system" | "light" | "dark";
|
|
motion: "system" | "reduce" | "allow";
|
|
}) {
|
|
const root = recordingTarget();
|
|
const meta = recordingTarget();
|
|
const cache = createMemoryAppearanceCache(initial);
|
|
const theme = createEkklesiaThemeController({
|
|
appearance: initial?.appearance ?? "system",
|
|
motion: initial?.motion ?? "system",
|
|
target: root,
|
|
});
|
|
const preference = createAppearancePreference({ cache, colorSchemeMeta: meta, theme });
|
|
return { cache, meta, preference, root, theme };
|
|
}
|
|
|
|
it("drives the ADR 0060 theme controller: the root's two attributes are the whole effect", () => {
|
|
const { preference, root, theme } = build();
|
|
|
|
preference.apply({ appearance: "dark", motion: "reduce" });
|
|
|
|
expect(root.attributes.get(DESIGN_TOKEN_APPEARANCE_ATTRIBUTE)).toBe("dark");
|
|
expect(root.attributes.get(DESIGN_TOKEN_MOTION_ATTRIBUTE)).toBe("reduce");
|
|
expect(theme.colorScheme()).toBe("dark");
|
|
expect(theme.reducedMotion()).toBe(true);
|
|
});
|
|
|
|
it("remembers what it applied for the next first paint, and narrows the colour scheme with it", () => {
|
|
const { cache, meta, preference } = build();
|
|
|
|
preference.apply({ appearance: "light", motion: "allow" });
|
|
expect(cache.read()).toEqual({ appearance: "light", motion: "allow" });
|
|
expect(meta.attributes.get("content")).toBe("light");
|
|
|
|
preference.apply({ appearance: "system", motion: "allow" });
|
|
expect(meta.attributes.get("content")).toBe("light dark");
|
|
});
|
|
|
|
it("gives a stable snapshot between changes, and notifies only on a real change", () => {
|
|
const { preference } = build();
|
|
const listener = vi.fn();
|
|
preference.subscribe(listener);
|
|
const before = preference.current();
|
|
|
|
expect(preference.current()).toBe(before);
|
|
preference.apply({ appearance: "system", motion: "system" });
|
|
expect(listener).not.toHaveBeenCalled();
|
|
expect(preference.current()).toBe(before);
|
|
|
|
preference.apply({ appearance: "dark", motion: "system" });
|
|
expect(listener).toHaveBeenCalledOnce();
|
|
expect(preference.current()).toEqual({ appearance: "dark", motion: "system" });
|
|
});
|
|
|
|
it("follows another tab through the cache, without writing it back", () => {
|
|
const { cache, preference } = build({ appearance: "light", motion: "system" });
|
|
const write = vi.spyOn(cache, "write");
|
|
const listener = vi.fn();
|
|
preference.subscribe(listener);
|
|
|
|
cache.write({ appearance: "dark", motion: "reduce" });
|
|
write.mockClear();
|
|
preference.syncFromCache();
|
|
|
|
expect(preference.current()).toEqual({ appearance: "dark", motion: "reduce" });
|
|
expect(listener).toHaveBeenCalledOnce();
|
|
expect(write).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("follows another tab's sign-out back to the system", () => {
|
|
const { cache, preference } = build({ appearance: "dark", motion: "reduce" });
|
|
|
|
cache.clear();
|
|
preference.syncFromCache();
|
|
|
|
expect(preference.current()).toEqual({ appearance: "system", motion: "system" });
|
|
});
|
|
|
|
it("forgets on sign-out: the system again, and no cached choice for the next person", () => {
|
|
const { cache, meta, preference, root } = build({ appearance: "dark", motion: "reduce" });
|
|
|
|
preference.forget();
|
|
|
|
expect(cache.read()).toBeUndefined();
|
|
expect(root.attributes.get(DESIGN_TOKEN_APPEARANCE_ATTRIBUTE)).toBe("system");
|
|
expect(root.attributes.get(DESIGN_TOKEN_MOTION_ATTRIBUTE)).toBe("system");
|
|
expect(meta.attributes.get("content")).toBe("light dark");
|
|
});
|
|
});
|
|
|
|
describe("the pre-paint appearance script", () => {
|
|
let meta: HTMLMetaElement;
|
|
|
|
beforeEach(() => {
|
|
vi.resetModules();
|
|
window.localStorage.clear();
|
|
document.documentElement.removeAttribute(DESIGN_TOKEN_APPEARANCE_ATTRIBUTE);
|
|
document.documentElement.removeAttribute(DESIGN_TOKEN_MOTION_ATTRIBUTE);
|
|
meta = document.createElement("meta");
|
|
meta.setAttribute("name", "color-scheme");
|
|
meta.setAttribute("content", "light dark");
|
|
document.head.append(meta);
|
|
});
|
|
|
|
afterEach(() => {
|
|
meta.remove();
|
|
window.localStorage.clear();
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
async function runBootScript(): Promise<void> {
|
|
await import("../src/browser/appearance-boot.js");
|
|
}
|
|
|
|
it("puts a cached explicit choice on the root and narrows the colour scheme, before anything renders", async () => {
|
|
window.localStorage.setItem(APPEARANCE_CACHE_KEY, "dark reduce");
|
|
|
|
await runBootScript();
|
|
|
|
expect(document.documentElement.getAttribute(DESIGN_TOKEN_APPEARANCE_ATTRIBUTE)).toBe("dark");
|
|
expect(document.documentElement.getAttribute(DESIGN_TOKEN_MOTION_ATTRIBUTE)).toBe("reduce");
|
|
expect(meta.getAttribute("content")).toBe("dark");
|
|
});
|
|
|
|
it("leaves the colour scheme following the system for a cached System choice", async () => {
|
|
window.localStorage.setItem(APPEARANCE_CACHE_KEY, "system allow");
|
|
|
|
await runBootScript();
|
|
|
|
expect(document.documentElement.getAttribute(DESIGN_TOKEN_APPEARANCE_ATTRIBUTE)).toBe("system");
|
|
expect(document.documentElement.getAttribute(DESIGN_TOKEN_MOTION_ATTRIBUTE)).toBe("allow");
|
|
expect(meta.getAttribute("content")).toBe("light dark");
|
|
});
|
|
|
|
it("does nothing without a valid cache", async () => {
|
|
window.localStorage.setItem(APPEARANCE_CACHE_KEY, "neon reduce");
|
|
|
|
await runBootScript();
|
|
|
|
expect(document.documentElement.hasAttribute(DESIGN_TOKEN_APPEARANCE_ATTRIBUTE)).toBe(false);
|
|
expect(meta.getAttribute("content")).toBe("light dark");
|
|
});
|
|
|
|
it("does nothing, and throws nothing, where storage is blocked", async () => {
|
|
vi.spyOn(Storage.prototype, "getItem").mockImplementation(() => {
|
|
throw new Error("SecurityError");
|
|
});
|
|
|
|
await expect(runBootScript()).resolves.toBeUndefined();
|
|
expect(document.documentElement.hasAttribute(DESIGN_TOKEN_APPEARANCE_ATTRIBUTE)).toBe(false);
|
|
});
|
|
|
|
it("names exactly the token stylesheet's attributes, and reads storage without ever writing it", async () => {
|
|
const source = await readFile(
|
|
path.join(packageDirectory, "src", "browser", "appearance-boot.ts"),
|
|
"utf8",
|
|
);
|
|
|
|
expect(source).toContain(`"${DESIGN_TOKEN_APPEARANCE_ATTRIBUTE}"`);
|
|
expect(source).toContain(`"${DESIGN_TOKEN_MOTION_ATTRIBUTE}"`);
|
|
expect(source).not.toMatch(/setItem|removeItem|fetch|cookie/u);
|
|
// Types only from the token package, so the render-blocking script stays tiny.
|
|
const cacheSource = await readFile(
|
|
path.join(packageDirectory, "src", "browser", "appearance-cache.ts"),
|
|
"utf8",
|
|
);
|
|
expect(cacheSource).toMatch(/^import type \{[^}]*\} from "@ekklesia\/design-tokens";$/mu);
|
|
});
|
|
});
|
|
|
|
describe("account initials", () => {
|
|
it.each([
|
|
[{ email: "[email protected]", name: "Ada Lovelace" }, "AL"],
|
|
[{ email: "[email protected]", name: "Ada King Lovelace" }, "AL"],
|
|
[{ email: "[email protected]", name: " ada lovelace " }, "AL"],
|
|
[{ email: "[email protected]", name: "Cher" }, "C"],
|
|
[{ email: "[email protected]", name: "Émile Zola" }, "ÉZ"],
|
|
[{ email: "[email protected]", name: " " }, "B"],
|
|
])("draws %o as %s", (account, initials) => {
|
|
expect(accountInitials(account)).toBe(initials);
|
|
});
|
|
|
|
it("shows the email as the name when the name is blank", () => {
|
|
expect(accountDisplayName({ email: "[email protected]", name: " " })).toBe(
|
|
"[email protected]",
|
|
);
|
|
expect(accountDisplayName({ email: "[email protected]", name: "Ada" })).toBe("Ada");
|
|
});
|
|
});
|