@remit/web-client 0.0.156 → 0.0.158

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remit/web-client",
3
- "version": "0.0.156",
3
+ "version": "0.0.158",
4
4
  "type": "module",
5
5
  "description": "Remit web client, published as composable primitives — the app shell, auth shells, and runtime config. A distributor imports what it composes and bundles it.",
6
6
  "exports": {
@@ -17,6 +17,7 @@
17
17
  "files": [
18
18
  "src",
19
19
  "harness",
20
+ "spellcheck",
20
21
  "vite.base.ts",
21
22
  "public",
22
23
  "index.html"
@@ -33,6 +34,9 @@
33
34
  "test": "npm run test:typecheck && npm run test:run"
34
35
  },
35
36
  "peerDependencies": {
37
+ "dictionary-en": "^4",
38
+ "dictionary-en-gb": "^3",
39
+ "dictionary-nl": "^2",
36
40
  "react": "^19",
37
41
  "react-dom": "^19",
38
42
  "vite": "^7",
@@ -43,6 +47,15 @@
43
47
  "@types/react-dom": "^19"
44
48
  },
45
49
  "peerDependenciesMeta": {
50
+ "dictionary-en": {
51
+ "optional": true
52
+ },
53
+ "dictionary-en-gb": {
54
+ "optional": true
55
+ },
56
+ "dictionary-nl": {
57
+ "optional": true
58
+ },
46
59
  "vite": {
47
60
  "optional": true
48
61
  },
@@ -101,6 +114,9 @@
101
114
  "@types/nodemailer": "*",
102
115
  "@types/pngjs": "^6.0.5",
103
116
  "@vitejs/plugin-react": "^4",
117
+ "dictionary-en": "4.0.0",
118
+ "dictionary-en-gb": "3.0.0",
119
+ "dictionary-nl": "2.0.0",
104
120
  "electrodb": "*",
105
121
  "esbuild": "*",
106
122
  "expect-env": "*",
@@ -0,0 +1,86 @@
1
+ /**
2
+ * Every dictionary this repo knows how to ship, and everything the image owes
3
+ * for carrying one. Adding a language is a row here, its `dictionary-*` package
4
+ * in `devDependencies`, and its tag in `REMIT_SPELLCHECK_LANGUAGES` — no code.
5
+ *
6
+ * A `.aff` and a `.dic` are data an engine reads at runtime, so carrying one is
7
+ * aggregation and reader's own licence is untouched. What each licence does
8
+ * oblige is that its text travels with the file and that the served bytes are
9
+ * the upstream source; both are build outputs, generated from this table.
10
+ */
11
+
12
+ export interface DictionarySource {
13
+ /** BCP 47, and the directory name the browser fetches under. */
14
+ readonly tag: string;
15
+ readonly package: string;
16
+ readonly project: string;
17
+ readonly authors: string;
18
+ readonly source: string;
19
+ /** SPDX, with the option taken where upstream offers a choice. */
20
+ readonly licence: string;
21
+ }
22
+
23
+ export const DICTIONARY_SOURCES: readonly DictionarySource[] = [
24
+ {
25
+ tag: "en",
26
+ package: "dictionary-en",
27
+ project: "SCOWL",
28
+ authors: "Kevin Atkinson and contributors",
29
+ source: "https://github.com/wooorm/dictionaries/tree/main/dictionaries/en",
30
+ licence: "MIT AND BSD-3-Clause",
31
+ },
32
+ {
33
+ tag: "en-GB",
34
+ package: "dictionary-en-gb",
35
+ project: "SCOWL",
36
+ authors: "Kevin Atkinson and contributors",
37
+ source:
38
+ "https://github.com/wooorm/dictionaries/tree/main/dictionaries/en-GB",
39
+ licence: "MIT AND BSD-3-Clause",
40
+ },
41
+ {
42
+ tag: "nl",
43
+ package: "dictionary-nl",
44
+ project: "OpenTaal",
45
+ authors: "Stichting OpenTaal",
46
+ source: "https://github.com/wooorm/dictionaries/tree/main/dictionaries/nl",
47
+ licence: "CC-BY-3.0",
48
+ },
49
+ ];
50
+
51
+ /**
52
+ * What the published image carries. Everything else in the table above is a
53
+ * language this repo can build and does not; the list is the switch.
54
+ */
55
+ export const DEFAULT_SPELLCHECK_LANGUAGES = "en,en-GB,nl";
56
+
57
+ /**
58
+ * `defaultComposeLanguages` appends `en` to every account's candidate set, so a
59
+ * build without it would leave the one language every account is guaranteed to
60
+ * offer as the one with no dictionary. The switch normalises rather than
61
+ * validates.
62
+ */
63
+ export const resolveLanguages = (
64
+ requested: string | undefined,
65
+ ): readonly DictionarySource[] => {
66
+ const asked = (requested ?? DEFAULT_SPELLCHECK_LANGUAGES)
67
+ .split(",")
68
+ .map((tag) => tag.trim())
69
+ .filter((tag) => tag !== "");
70
+ if (asked.length === 0) return [];
71
+
72
+ const known = new Map(
73
+ DICTIONARY_SOURCES.map((source) => [source.tag.toLowerCase(), source]),
74
+ );
75
+ const wanted: DictionarySource[] = [];
76
+ for (const tag of ["en", ...asked]) {
77
+ const source = known.get(tag.toLowerCase());
78
+ if (!source) {
79
+ throw new Error(
80
+ `REMIT_SPELLCHECK_LANGUAGES names "${tag}", which no dictionary in packages/web-client/spellcheck/languages.ts covers. Known tags: ${DICTIONARY_SOURCES.map((entry) => entry.tag).join(", ")}.`,
81
+ );
82
+ }
83
+ if (!wanted.includes(source)) wanted.push(source);
84
+ }
85
+ return wanted;
86
+ };
@@ -0,0 +1,385 @@
1
+ /**
2
+ * Turns `REMIT_SPELLCHECK_LANGUAGES` into what the browser fetches: the engine,
3
+ * one directory per language holding the upstream `.aff`, `.dic` and licence
4
+ * text byte for byte, a manifest, and the notice generated from it.
5
+ *
6
+ * Nothing here is bundled and nothing is fetched at page load. The build's own
7
+ * job is that the notice describes the image exactly — which is the reason the
8
+ * language set is fixed at build time at all — so a tag nobody can ship, or a
9
+ * dictionary whose licence file is missing, fails the build by name.
10
+ *
11
+ * Two audiences build this package: this repo, where `npm run build:hunspell`
12
+ * leaves an engine in `build/hunspell/`, and a distributor building from the
13
+ * published tarball, which carries neither an engine nor a dictionary. Every
14
+ * failure below therefore names the file it wanted and both ways past it —
15
+ * `REMIT_SPELLCHECK_ENGINE_DIR` for an engine built elsewhere, and an empty
16
+ * `REMIT_SPELLCHECK_LANGUAGES` for a build with no spellchecker at all.
17
+ */
18
+
19
+ import { createHash } from "node:crypto";
20
+ import { existsSync, readdirSync, readFileSync } from "node:fs";
21
+ import { createRequire } from "node:module";
22
+ import { dirname, join } from "node:path";
23
+ import { fileURLToPath } from "node:url";
24
+ import type { Plugin } from "vite";
25
+ import type { DictionarySource } from "./languages.ts";
26
+ import { resolveLanguages } from "./languages.ts";
27
+
28
+ const require = createRequire(import.meta.url);
29
+ const here = dirname(fileURLToPath(import.meta.url));
30
+ // The repo root when this file is a checkout, and `node_modules` when it is an
31
+ // installed package — which holds neither the engine nor the pins. Nothing is
32
+ // read from here without a fallback and an error that names the alternative.
33
+ const repoRoot = join(here, "..", "..", "..");
34
+ // Where `npm run build:hunspell` leaves the engine. A distributor building the
35
+ // web client from the published package has no such tree, so the location is
36
+ // theirs to name.
37
+ const engineDir =
38
+ process.env.REMIT_SPELLCHECK_ENGINE_DIR ??
39
+ join(repoRoot, "build", "hunspell");
40
+
41
+ const WAYS_OUT = [
42
+ "Set REMIT_SPELLCHECK_ENGINE_DIR to a directory holding hunspell.wasm, hunspell.mjs, LICENSE, license.hunspell and pin.env — in this repo `npm run build:hunspell` writes one into build/hunspell/.",
43
+ "Or set REMIT_SPELLCHECK_LANGUAGES= (empty) to build a web client with no spellchecker at all; the browser's own checker stays on either way.",
44
+ ].join("\n");
45
+
46
+ const LICENCE_NAMES = ["LICENSE", "license", "LICENCE", "licence"];
47
+
48
+ /** The engine's own files, in the order the browser needs them explained. */
49
+ const ENGINE_FILES = [
50
+ "hunspell.wasm",
51
+ "hunspell.mjs",
52
+ "LICENSE",
53
+ "license.hunspell",
54
+ ];
55
+
56
+ interface StagedFile {
57
+ readonly path: string;
58
+ readonly source: Buffer;
59
+ }
60
+
61
+ interface StagedLanguage extends DictionarySource {
62
+ readonly version: string;
63
+ readonly bytes: number;
64
+ readonly files: readonly StagedFile[];
65
+ }
66
+
67
+ /**
68
+ * The pins describe the engine, so they are read from beside it: an engine dir
69
+ * a distributor points at carries the `pin.env` its own build used, and the
70
+ * checksum in NOTICE.txt is then the checksum of the bytes being served rather
71
+ * than of whatever this checkout happens to pin.
72
+ */
73
+ const pinFile = (): string => {
74
+ const beside = join(engineDir, "pin.env");
75
+ if (existsSync(beside)) return beside;
76
+ const inRepo = join(repoRoot, "docker", "hunspell", "pin.env");
77
+ if (existsSync(inRepo)) return inRepo;
78
+ throw new Error(
79
+ `The engine at ${engineDir} carries no pin.env, and neither does ${inRepo}, so nothing here can say which Hunspell release is being served and NOTICE.txt would have to guess.\n${WAYS_OUT}`,
80
+ );
81
+ };
82
+
83
+ let pins: Record<string, string> | null = null;
84
+
85
+ const readPin = (name: string): string => {
86
+ const path = pinFile();
87
+ pins ??= Object.fromEntries(
88
+ readFileSync(path, "utf8")
89
+ .split("\n")
90
+ .flatMap((line) => {
91
+ const match = /^([A-Z0-9_]+)=(.*)$/.exec(line.trim());
92
+ return match ? [[match[1], match[2]] as const] : [];
93
+ }),
94
+ );
95
+ const found = pins[name];
96
+ if (found === undefined) throw new Error(`${path} has no ${name}`);
97
+ return found;
98
+ };
99
+
100
+ const licenceIn = (directory: string): string => {
101
+ const named = LICENCE_NAMES.find((name) => existsSync(join(directory, name)));
102
+ if (named) return named;
103
+ const found = readdirSync(directory).find((entry) =>
104
+ /^licen[cs]e/i.test(entry),
105
+ );
106
+ if (found) return found;
107
+ throw new Error(
108
+ `${directory} ships no licence file. A dictionary whose licence text cannot travel with it cannot ship, so the build stops here rather than producing a NOTICE.txt that claims one exists.`,
109
+ );
110
+ };
111
+
112
+ const stageEngine = (): StagedFile[] =>
113
+ ENGINE_FILES.map((name) => {
114
+ const path = join(engineDir, name);
115
+ if (!existsSync(path)) {
116
+ throw new Error(
117
+ `${path} is missing, so this build has no spellchecking engine to serve.\n${WAYS_OUT}`,
118
+ );
119
+ }
120
+ return { path: name, source: readFileSync(path) };
121
+ });
122
+
123
+ /**
124
+ * A `dictionary-*` package is a devDependency of @remit/web-client, which a
125
+ * consumer of the published tarball never receives — so a missing one is the
126
+ * ordinary case out here, not a broken install.
127
+ */
128
+ const dictionaryDir = (source: DictionarySource): string => {
129
+ try {
130
+ // Resolved through the package's own entry point: `dictionary-*` declares an
131
+ // `exports` map, so its package.json is not a resolvable subpath.
132
+ return dirname(require.resolve(source.package));
133
+ } catch {
134
+ throw new Error(
135
+ `${source.package} is not installed, so the ${source.tag} dictionary cannot be staged. It is a devDependency of @remit/web-client and a consumer of the published package never gets it.\nInstall ${source.package} alongside @remit/web-client, or drop "${source.tag}" from REMIT_SPELLCHECK_LANGUAGES — set it empty to build with no spellchecker at all.`,
136
+ );
137
+ }
138
+ };
139
+
140
+ const stageLanguage = (source: DictionarySource): StagedLanguage => {
141
+ const directory = dictionaryDir(source);
142
+ const version = JSON.parse(
143
+ readFileSync(join(directory, "package.json"), "utf8"),
144
+ ).version;
145
+ const licence = licenceIn(directory);
146
+ const at = (name: string) => `dictionaries/${source.tag}/${name}`;
147
+ const aff = readFileSync(join(directory, "index.aff"));
148
+ const dic = readFileSync(join(directory, "index.dic"));
149
+ return {
150
+ ...source,
151
+ version,
152
+ bytes: aff.byteLength + dic.byteLength,
153
+ files: [
154
+ { path: at("index.aff"), source: aff },
155
+ { path: at("index.dic"), source: dic },
156
+ { path: at("LICENSE"), source: readFileSync(join(directory, licence)) },
157
+ ],
158
+ };
159
+ };
160
+
161
+ /** A licence that says the served form must be the source says it out loud. */
162
+ const correspondence = (licence: string): boolean =>
163
+ /GPL|MPL/.test(licence.toUpperCase());
164
+
165
+ const noticeFor = (
166
+ engineVersion: string,
167
+ languages: readonly StagedLanguage[],
168
+ ): string => {
169
+ const blocks = languages.map((language) =>
170
+ [
171
+ `${language.tag} — ${language.project}`,
172
+ ` Authors: ${language.authors}`,
173
+ ` Source: ${language.source}`,
174
+ ` Package: ${language.package}@${language.version}`,
175
+ ` Licence: ${language.licence}`,
176
+ ` Licence text: dictionaries/${language.tag}/LICENSE, beside this file`,
177
+ ...(correspondence(language.licence)
178
+ ? [
179
+ ` The served index.aff and index.dic are the unmodified upstream source.`,
180
+ ]
181
+ : []),
182
+ ].join("\n"),
183
+ );
184
+ return [
185
+ "This build of Remit Reader carries the spelling dictionaries listed below.",
186
+ "Each stays under its own licence; the licence text travels with the files,",
187
+ "and nothing in this image modifies a dictionary. Reader itself is MIT.",
188
+ "",
189
+ "Engine",
190
+ "",
191
+ ` Hunspell ${engineVersion}`,
192
+ " Source: https://github.com/hunspell/hunspell",
193
+ ` Tarball: https://github.com/hunspell/hunspell/releases/download/v${engineVersion}/hunspell-${engineVersion}.tar.gz`,
194
+ ` sha256: ${readPin("HUNSPELL_SHA256")}`,
195
+ " Licence: MPL-1.1 (the option taken from Hunspell's MPL-1.1/GPL-2.0/LGPL-2.1 triple)",
196
+ " Licence text: LICENSE, beside this file",
197
+ " Build recipe: docker/hunspell/build.sh",
198
+ "",
199
+ "Dictionaries",
200
+ "",
201
+ ...blocks.map((block) => `${block}\n`),
202
+ ].join("\n");
203
+ };
204
+
205
+ export interface SpellcheckBuild {
206
+ readonly languages: readonly StagedLanguage[];
207
+ /** Paths relative to `directory`, which is where all of them are emitted. */
208
+ readonly files: readonly StagedFile[];
209
+ /** `spellcheck/<digest>/`, relative to the build's output root. */
210
+ readonly directory: string;
211
+ /** What the browser resolves against the document to reach `directory`. */
212
+ readonly base: string;
213
+ /** Where the dev server answers for `directory`, always document-rooted. */
214
+ readonly servePath: string;
215
+ /** Tag → the bytes opening that language downloads, engine included. */
216
+ readonly bytes: Readonly<Record<string, number>>;
217
+ }
218
+
219
+ const EMPTY_BUILD: SpellcheckBuild = {
220
+ languages: [],
221
+ files: [],
222
+ directory: "",
223
+ base: "",
224
+ servePath: "",
225
+ bytes: {},
226
+ };
227
+
228
+ /**
229
+ * One digest over everything served, folded into the directory name. Nothing
230
+ * the browser fetches is versioned otherwise — `hunspell.wasm` and `index.dic`
231
+ * are the same two paths in every build — and an unversioned path cannot be
232
+ * cached, which is what left every composer open re-downloading its dictionary.
233
+ */
234
+ const digestOf = (files: readonly StagedFile[]): string => {
235
+ const hash = createHash("sha256");
236
+ for (const file of [...files].sort((left, right) =>
237
+ left.path.localeCompare(right.path),
238
+ )) {
239
+ hash.update(file.path);
240
+ hash.update("\0");
241
+ hash.update(file.source);
242
+ }
243
+ return hash.digest("hex").slice(0, 16);
244
+ };
245
+
246
+ const documentRooted = (viteBase: string | undefined): boolean =>
247
+ viteBase === undefined || viteBase.startsWith("/");
248
+
249
+ /**
250
+ * An absolute base names where the app is mounted, and the staged directory
251
+ * hangs off it. A relative one — Storybook's, always `./` — belongs to a build
252
+ * published under a path it cannot know here (`/reader/pr/<n>/<sha>/`), so it
253
+ * gets the directory alone and the document resolves it at run time. An unset
254
+ * base is vite's own default of `/`, not a relative one.
255
+ */
256
+ const browserBase = (
257
+ viteBase: string | undefined,
258
+ directory: string,
259
+ ): string =>
260
+ documentRooted(viteBase)
261
+ ? `${(viteBase ?? "/").replace(/\/$/, "")}/${directory}`
262
+ : directory;
263
+
264
+ export const stageSpellcheck = (
265
+ requested: string | undefined,
266
+ viteBase: string | undefined,
267
+ ): SpellcheckBuild => {
268
+ const wanted = resolveLanguages(requested);
269
+ if (wanted.length === 0) return EMPTY_BUILD;
270
+
271
+ // The engine first: it is the file a build outside this repo is most likely
272
+ // to be missing, and its error is the one that names both ways out.
273
+ const engine = stageEngine();
274
+ const engineVersion = readPin("HUNSPELL_VERSION");
275
+ const languages = wanted.map(stageLanguage);
276
+ const wasmBytes =
277
+ engine.find((file) => file.path === "hunspell.wasm")?.source.byteLength ??
278
+ 0;
279
+
280
+ const payload = [
281
+ ...engine,
282
+ ...languages.flatMap((language) => language.files),
283
+ ];
284
+ const directory = `spellcheck/${digestOf(payload)}/`;
285
+
286
+ const manifest = {
287
+ engine: {
288
+ project: "Hunspell",
289
+ version: engineVersion,
290
+ licence: "MPL-1.1",
291
+ source: "https://github.com/hunspell/hunspell",
292
+ recipe: "docker/hunspell/build.sh",
293
+ bytes: wasmBytes,
294
+ },
295
+ languages: languages.map((language) => ({
296
+ tag: language.tag,
297
+ project: language.project,
298
+ authors: language.authors,
299
+ source: language.source,
300
+ package: `${language.package}@${language.version}`,
301
+ licence: language.licence,
302
+ licenceFile: `dictionaries/${language.tag}/LICENSE`,
303
+ bytes: language.bytes,
304
+ })),
305
+ };
306
+
307
+ return {
308
+ languages,
309
+ directory,
310
+ base: browserBase(viteBase, directory),
311
+ servePath: `${(documentRooted(viteBase) ? (viteBase ?? "/") : "/").replace(/\/$/, "")}/${directory}`,
312
+ bytes: Object.fromEntries(
313
+ languages.map((language) => [language.tag, wasmBytes + language.bytes]),
314
+ ),
315
+ files: [
316
+ ...payload,
317
+ {
318
+ path: "manifest.json",
319
+ source: Buffer.from(`${JSON.stringify(manifest, null, "\t")}\n`),
320
+ },
321
+ {
322
+ path: "NOTICE.txt",
323
+ source: Buffer.from(noticeFor(engineVersion, languages)),
324
+ },
325
+ ],
326
+ };
327
+ };
328
+
329
+ export const spellcheckPlugin = (): Plugin => {
330
+ let staged: SpellcheckBuild | null = null;
331
+
332
+ return {
333
+ name: "remit-spellcheck",
334
+ enforce: "pre",
335
+ config(userConfig) {
336
+ staged = stageSpellcheck(
337
+ process.env.REMIT_SPELLCHECK_LANGUAGES,
338
+ userConfig.base,
339
+ );
340
+ return {
341
+ define: {
342
+ __REMIT_SPELLCHECK_LANGUAGES__: JSON.stringify(
343
+ staged.languages.map((language) => language.tag),
344
+ ),
345
+ __REMIT_SPELLCHECK_BASE__: JSON.stringify(staged.base),
346
+ __REMIT_SPELLCHECK_BYTES__: JSON.stringify(staged.bytes),
347
+ },
348
+ };
349
+ },
350
+ configureServer(server) {
351
+ server.middlewares.use((request, response, next) => {
352
+ const held = staged;
353
+ const url = (request.url ?? "").split("?")[0];
354
+ if (!held || held.servePath === "" || !url.startsWith(held.servePath))
355
+ return next();
356
+ const wanted = decodeURIComponent(url.slice(held.servePath.length));
357
+ const file = held.files.find((entry) => entry.path === wanted);
358
+ if (!file) return next();
359
+ response.setHeader("Content-Length", file.source.byteLength);
360
+ response.setHeader(
361
+ "Content-Type",
362
+ wanted.endsWith(".mjs")
363
+ ? "text/javascript; charset=utf-8"
364
+ : wanted.endsWith(".wasm")
365
+ ? "application/wasm"
366
+ : wanted.endsWith(".json")
367
+ ? "application/json; charset=utf-8"
368
+ : "text/plain; charset=utf-8",
369
+ );
370
+ response.end(file.source);
371
+ });
372
+ },
373
+ generateBundle() {
374
+ const held = staged;
375
+ if (!held) return;
376
+ for (const file of held.files) {
377
+ this.emitFile({
378
+ type: "asset",
379
+ fileName: `${held.directory}${file.path}`,
380
+ source: file.source,
381
+ });
382
+ }
383
+ },
384
+ };
385
+ };
@@ -16,10 +16,22 @@ import { composeSpellcheck, spellcheckFailure } from "./compose-spellcheck.js";
16
16
  interface WorkerMessage {
17
17
  readonly type: string;
18
18
  readonly language?: string;
19
+ readonly base?: string;
19
20
  }
20
21
 
21
22
  const opened: { url: string; messages: WorkerMessage[] }[] = [];
22
23
 
24
+ /**
25
+ * What `REMIT_SPELLCHECK_LANGUAGES` writes into the bundle. Nothing defines it
26
+ * outside a Vite build, so a test that wants a build carrying English says so.
27
+ */
28
+ const built = (...tags: string[]): void => {
29
+ Object.defineProperty(globalThis, "__REMIT_SPELLCHECK_LANGUAGES__", {
30
+ value: tags,
31
+ configurable: true,
32
+ });
33
+ };
34
+
23
35
  class FakeWorker {
24
36
  readonly messages: WorkerMessage[] = [];
25
37
  constructor(url: URL) {
@@ -42,24 +54,42 @@ const withWorker = <T>(run: () => Promise<T>): Promise<T> => {
42
54
 
43
55
  afterEach(() => {
44
56
  opened.length = 0;
57
+ built();
45
58
  });
46
59
 
47
60
  describe("the checker the composer opens", () => {
48
61
  it("asks the worker for the language the message is in", async () => {
62
+ built("en", "en-GB", "nl");
49
63
  const provider = await withWorker(() =>
50
64
  composeSpellcheck(() => undefined).provider("en"),
51
65
  );
52
66
 
53
- assert.equal(provider?.language, "en");
67
+ assert.equal(
68
+ provider?.language,
69
+ "en",
70
+ "plain English is checked against English, not a region's",
71
+ );
54
72
  assert.equal(opened.length, 1);
55
73
  assert.match(opened[0].url, /rich-text-spellcheck-worker/);
56
- assert.deepEqual(opened[0].messages, [{ type: "open", language: "en" }]);
74
+ assert.deepEqual(
75
+ opened[0].messages,
76
+ [
77
+ {
78
+ type: "open",
79
+ language: "en",
80
+ base: "http://localhost/spellcheck/",
81
+ bytesExpected: 0,
82
+ },
83
+ ],
84
+ "the worker is told an absolute place to fetch from, resolved against the page",
85
+ );
57
86
  provider?.close();
58
87
  });
59
88
 
60
89
  it("starts no worker for a language the build carries no words for", async () => {
90
+ built("en", "en-GB", "nl");
61
91
  const provider = await withWorker(() =>
62
- composeSpellcheck(() => undefined).provider("nl"),
92
+ composeSpellcheck(() => undefined).provider("de"),
63
93
  );
64
94
 
65
95
  assert.equal(provider, null);
@@ -70,9 +100,14 @@ describe("the checker the composer opens", () => {
70
100
  const reported: PushErrorInput[] = [];
71
101
  const { onStatus } = composeSpellcheck((input) => reported.push(input));
72
102
 
73
- onStatus?.({ state: "opening", language: "en" });
103
+ onStatus?.({
104
+ state: "opening",
105
+ language: "en",
106
+ bytesLoaded: 65_536,
107
+ bytesTotal: 167_936,
108
+ });
74
109
  onStatus?.({ state: "ready", language: "en" });
75
- onStatus?.({ state: "unavailable", language: "nl" });
110
+ onStatus?.({ state: "unavailable", language: "de" });
76
111
 
77
112
  assert.deepEqual(reported, []);
78
113
  });
@@ -15,6 +15,10 @@ interface DrawerProps {
15
15
  * Modal navigation drawer. Slides in from the side with a scrim behind.
16
16
  * Dismissed by scrim tap, escape key, or the close button. Focus moves into
17
17
  * the drawer on open and returns to the previously focused element on close.
18
+ *
19
+ * Visibility is `isOpen` alone, at every width: the drawer is the intelligence
20
+ * surface wherever the rail has no room, which includes the two-pane desktop
21
+ * band between 1024 and 1280px.
18
22
  */
19
23
  export const Drawer = ({
20
24
  isOpen,
@@ -67,7 +71,7 @@ export const Drawer = ({
67
71
 
68
72
  return (
69
73
  <div
70
- className="fixed inset-0 z-50 lg:hidden"
74
+ className="fixed inset-0 z-50"
71
75
  role="dialog"
72
76
  aria-modal="true"
73
77
  aria-label={ariaLabel}
@@ -0,0 +1,45 @@
1
+ import type { RemitImapMessageAuthenticity } from "@remit/api-http-client/types.gen.ts";
2
+ import { ShieldAlert } from "lucide-react";
3
+
4
+ /**
5
+ * Danger banner rendered above the thread body when DKIM mismatch is detected.
6
+ * Design spec (03-reading-and-intelligence.md): "a danger banner renders above
7
+ * the body: 'This message claims to be a company but was sent from a personal
8
+ * mailbox.' with a 'Why?' link that opens/highlights this section."
9
+ *
10
+ * The link sits in the sentence at the sentence's own size. Set apart as
11
+ * smaller, right-floated chrome it read as a label rather than the way in.
12
+ */
13
+ export function AuthenticityBanner({
14
+ authenticity,
15
+ onOpenIntelligence,
16
+ }: {
17
+ authenticity: RemitImapMessageAuthenticity;
18
+ onOpenIntelligence?: () => void;
19
+ }) {
20
+ if (!authenticity.dkimMismatch) return null;
21
+
22
+ return (
23
+ <div className="flex items-start gap-2 rounded-none border-b border-danger/20 bg-danger-soft px-5 py-2.5 text-sm">
24
+ <ShieldAlert className="mt-0.5 size-4 shrink-0 text-danger" />
25
+ <p className="flex-1 leading-snug text-fg">
26
+ This message claims to be from{" "}
27
+ <span className="font-semibold">{authenticity.fromDomain}</span> but was
28
+ sent
29
+ {authenticity.dkimDomain
30
+ ? ` via ${authenticity.dkimDomain}`
31
+ : " from a different domain"}
32
+ .{" "}
33
+ {onOpenIntelligence && (
34
+ <button
35
+ type="button"
36
+ onClick={onOpenIntelligence}
37
+ className="font-medium text-danger hover:underline"
38
+ >
39
+ Why?
40
+ </button>
41
+ )}
42
+ </p>
43
+ </div>
44
+ );
45
+ }
@@ -6,7 +6,6 @@ import {
6
6
  import type { RemitImapMessageAuthenticity } from "@remit/api-http-client/types.gen.ts";
7
7
  import { MobileReadingPane } from "@remit/ui";
8
8
  import { useQuery } from "@tanstack/react-query";
9
- import { ShieldAlert } from "lucide-react";
10
9
  import { useCallback, useEffect, useMemo, useRef, useState } from "react";
11
10
  import type { ComposeMode } from "@/components/compose/ComposeProvider";
12
11
  import { InlineCompose } from "@/components/compose/InlineCompose";
@@ -18,6 +17,7 @@ import { useMarkAsRead } from "@/hooks/useMarkAsRead";
18
17
  import { useIsDesktop } from "@/hooks/useMediaQuery";
19
18
  import { useSwipeNavigation } from "@/hooks/useSwipeNavigation";
20
19
  import { useToggleStar } from "@/hooks/useToggleStar";
20
+ import { AuthenticityBanner } from "./AuthenticityBanner";
21
21
  import { MessageCard } from "./MessageCard";
22
22
 
23
23
  interface ConversationViewProps {
@@ -95,46 +95,6 @@ const LoadingSkeleton = () => (
95
95
  </div>
96
96
  );
97
97
 
98
- /**
99
- * Danger banner rendered above the thread body when DKIM mismatch is detected.
100
- * Design spec (03-reading-and-intelligence.md): "a danger banner renders above
101
- * the body: 'This message claims to be a company but was sent from a personal
102
- * mailbox.' with a 'Why?' link that opens/highlights this section."
103
- */
104
- function AuthenticityBanner({
105
- authenticity,
106
- onOpenIntelligence,
107
- }: {
108
- authenticity: RemitImapMessageAuthenticity;
109
- onOpenIntelligence?: () => void;
110
- }) {
111
- if (!authenticity.dkimMismatch) return null;
112
-
113
- return (
114
- <div className="flex items-start gap-2 rounded-none border-b border-danger/20 bg-danger-soft px-5 py-2.5 text-sm">
115
- <ShieldAlert className="mt-0.5 size-4 shrink-0 text-danger" />
116
- <p className="flex-1 leading-snug text-fg">
117
- This message claims to be from{" "}
118
- <span className="font-semibold">{authenticity.fromDomain}</span> but was
119
- sent
120
- {authenticity.dkimDomain
121
- ? ` via ${authenticity.dkimDomain}`
122
- : " from a different domain"}
123
- .
124
- </p>
125
- {onOpenIntelligence && (
126
- <button
127
- type="button"
128
- onClick={onOpenIntelligence}
129
- className="shrink-0 text-2xs font-medium text-danger hover:underline"
130
- >
131
- Why?
132
- </button>
133
- )}
134
- </div>
135
- );
136
- }
137
-
138
98
  export const ConversationView = ({
139
99
  threadId,
140
100
  mailboxId,
@@ -1167,13 +1167,51 @@ function MailboxReading() {
1167
1167
  onToolbarDiscardDraft,
1168
1168
  onToolbarMove,
1169
1169
  composeState,
1170
+ handleDeselectIfRemoved,
1170
1171
  } = useMailboxPane();
1171
- // The rail's own width gate, not the shell tier: between 1024 and 1280 the
1172
- // reading pane is mounted but the rail is not, so "enabled" would promise an
1173
- // open that cannot happen.
1172
+ // Which surface intelligence has here: the rail between 1280 and up, the
1173
+ // mobile drawer below that, where the reading pane is mounted and the rail
1174
+ // has no room.
1174
1175
  const railFits = useAppShellLayout()?.showIntelligencePane ?? false;
1175
1176
  const hasThread = Boolean(conversation);
1176
- const canToggleIntelligence = railFits && hasThread;
1177
+
1178
+ // The drawer is modal, so it opens only when it is asked for — and only for
1179
+ // the thread it was asked for. `intelligenceOpen` is the rail's persisted
1180
+ // preference and the DKIM auto-open sets it on every tier, so driving the
1181
+ // drawer from it would throw a scrim over a message the moment one was
1182
+ // selected. Naming the thread is also what closes it again when the reader
1183
+ // moves on: a bare flag would still be set when they came back.
1184
+ const [drawerThreadId, setDrawerThreadId] = useState<string | null>(null);
1185
+ const openThreadId = conversation?.threadId ?? null;
1186
+ // Derived rather than stored: the drawer is up only while the thread it was
1187
+ // opened for is still the one on screen, so moving to another one closes it
1188
+ // with no effect to run. Closing it from an effect would paint one frame of
1189
+ // an open drawer over the newly opened thread first.
1190
+ const drawerOpen =
1191
+ !railFits && openThreadId !== null && drawerThreadId === openThreadId;
1192
+
1193
+ const closeIntelligenceDrawer = useCallback(
1194
+ () => setDrawerThreadId(null),
1195
+ [],
1196
+ );
1197
+ const openIntelligenceDrawer = useCallback(
1198
+ () => setDrawerThreadId(openThreadId),
1199
+ [openThreadId],
1200
+ );
1201
+ // The banner's "Why?" — always an open, never a close.
1202
+ const openIntelligence = railFits
1203
+ ? onToggleIntelligence
1204
+ : openIntelligenceDrawer;
1205
+ // The toolbar's control, which toggles whichever surface this width has.
1206
+ const toggleIntelligence = useCallback(() => {
1207
+ if (railFits) {
1208
+ onToggleIntelligence();
1209
+ return;
1210
+ }
1211
+ setDrawerThreadId(drawerOpen ? null : openThreadId);
1212
+ }, [railFits, onToggleIntelligence, drawerOpen, openThreadId]);
1213
+ const intelligenceShowing =
1214
+ hasThread && (railFits ? intelligenceOpen : drawerOpen);
1177
1215
 
1178
1216
  const detailPane =
1179
1217
  composeState.isOpen && !conversation ? (
@@ -1186,9 +1224,7 @@ function MailboxReading() {
1186
1224
  selectedMessageId={conversation.messageId}
1187
1225
  authenticity={conversation.authenticity}
1188
1226
  onOpenIntelligence={
1189
- conversation.authenticity?.dkimMismatch
1190
- ? onToggleIntelligence
1191
- : undefined
1227
+ conversation.authenticity?.dkimMismatch ? openIntelligence : undefined
1192
1228
  }
1193
1229
  composeRequest={toolbarComposeRequest}
1194
1230
  onComposeClose={onClearComposeRequest}
@@ -1198,37 +1234,54 @@ function MailboxReading() {
1198
1234
  );
1199
1235
 
1200
1236
  return (
1201
- <section className="flex h-full w-full min-w-0 flex-col bg-canvas">
1202
- <MessageToolbar
1203
- hasThread={hasThread}
1204
- intelligenceOpen={canToggleIntelligence && intelligenceOpen}
1205
- canToggleIntelligence={canToggleIntelligence}
1206
- onToggleIntelligence={onToggleIntelligence}
1207
- onReply={hasThread ? onToolbarReply : undefined}
1208
- onReplyAll={hasThread ? onToolbarReplyAll : undefined}
1209
- onForward={hasThread ? onToolbarForward : undefined}
1210
- canDelete={hasThread || hasRemitDraftOpen}
1211
- onDelete={
1212
- hasThread
1213
- ? onToolbarDelete
1214
- : hasRemitDraftOpen
1215
- ? onToolbarDiscardDraft
1237
+ <>
1238
+ <section className="flex h-full w-full min-w-0 flex-col bg-canvas">
1239
+ <MessageToolbar
1240
+ hasThread={hasThread}
1241
+ intelligenceOpen={intelligenceShowing}
1242
+ canToggleIntelligence={hasThread}
1243
+ onToggleIntelligence={toggleIntelligence}
1244
+ onReply={hasThread ? onToolbarReply : undefined}
1245
+ onReplyAll={hasThread ? onToolbarReplyAll : undefined}
1246
+ onForward={hasThread ? onToolbarForward : undefined}
1247
+ canDelete={hasThread || hasRemitDraftOpen}
1248
+ onDelete={
1249
+ hasThread
1250
+ ? onToolbarDelete
1251
+ : hasRemitDraftOpen
1252
+ ? onToolbarDiscardDraft
1253
+ : undefined
1254
+ }
1255
+ onToggleStar={hasThread ? onToolbarStar : undefined}
1256
+ isStarred={selectedThread?.hasStars}
1257
+ moveContext={
1258
+ hasThread && mailboxAccountId
1259
+ ? {
1260
+ accountId: mailboxAccountId,
1261
+ currentMailboxId: mailboxId,
1262
+ onMove: onToolbarMove,
1263
+ }
1216
1264
  : undefined
1217
- }
1218
- onToggleStar={hasThread ? onToolbarStar : undefined}
1219
- isStarred={selectedThread?.hasStars}
1220
- moveContext={
1221
- hasThread && mailboxAccountId
1222
- ? {
1223
- accountId: mailboxAccountId,
1224
- currentMailboxId: mailboxId,
1225
- onMove: onToolbarMove,
1226
- }
1227
- : undefined
1228
- }
1229
- />
1230
- <div className="min-h-0 flex-1 overflow-hidden">{detailPane}</div>
1231
- </section>
1265
+ }
1266
+ />
1267
+ <div className="min-h-0 flex-1 overflow-hidden">{detailPane}</div>
1268
+ </section>
1269
+ <Drawer
1270
+ isOpen={drawerOpen}
1271
+ onClose={closeIntelligenceDrawer}
1272
+ ariaLabel="Message details"
1273
+ side="right"
1274
+ >
1275
+ <IntelligencePane
1276
+ onClose={closeIntelligenceDrawer}
1277
+ thread={selectedThread}
1278
+ mailboxId={mailboxId}
1279
+ accountId={mailboxAccountId}
1280
+ hideCloseButton
1281
+ onAfterOptimisticRemove={handleDeselectIfRemoved}
1282
+ />
1283
+ </Drawer>
1284
+ </>
1232
1285
  );
1233
1286
  }
1234
1287
 
@@ -26,9 +26,10 @@ export interface MessageToolbarProps {
26
26
  hasThread: boolean;
27
27
  intelligenceOpen: boolean;
28
28
  /**
29
- * Whether pressing the intelligence toggle would open a rail: the view has
30
- * one, the width allows it, and a thread is selected. The button renders
31
- * either waydisabled when false, never absent (#52).
29
+ * Whether pressing the intelligence toggle would open anything: the view has
30
+ * an intelligence surface at this width the rail, or the drawer where the
31
+ * rail does not fit and a thread is selected. The button renders either
32
+ * way, disabled when false, never absent (#52).
32
33
  */
33
34
  canToggleIntelligence: boolean;
34
35
  onToggleIntelligence: () => void;
@@ -0,0 +1,131 @@
1
+ import { type IntelligenceData, IntelligencePanel } from "@remit/ui";
2
+ import type { Meta, StoryObj } from "@storybook/react-vite";
3
+ import { useState } from "react";
4
+ import { Drawer } from "@/components/layout/Drawer";
5
+ import { AuthenticityBanner } from "@/components/mail/AuthenticityBanner";
6
+
7
+ /**
8
+ * The authenticity warning over the reading pane, and where its "Why?" goes.
9
+ *
10
+ * Between 1024 and 1280px the shell has room for the reading pane but not the
11
+ * intelligence rail, so the link opens the same right-anchored drawer the phone
12
+ * uses — `MailboxPane.Reading` mounts it whenever the rail does not fit. The
13
+ * warning itself reads as one sentence: the link sits in the text flow at the
14
+ * body's own size rather than floating off to the right as chrome.
15
+ */
16
+ const meta: Meta<typeof AuthenticityBanner> = {
17
+ title: "Flows/Reading/Authenticity Warning",
18
+ component: AuthenticityBanner,
19
+ parameters: { layout: "centered" },
20
+ };
21
+ export default meta;
22
+
23
+ type Story = StoryObj<typeof AuthenticityBanner>;
24
+
25
+ const TWO_PANE_WIDTH = 1100;
26
+
27
+ const authenticity = {
28
+ fromDomain: "mondialrelay.fr",
29
+ dkimDomain: "gmail.example",
30
+ dkimMismatch: true,
31
+ };
32
+
33
+ const intelligence: IntelligenceData = {
34
+ sender: {
35
+ name: "Mondial Relay",
36
+ email: "delivery.notice.4421@gmail.example",
37
+ trust: "unknown",
38
+ firstSeenLabel: "today",
39
+ inboundCount: 1,
40
+ replyCount: 0,
41
+ },
42
+ authenticity: {
43
+ verdict: "mismatch",
44
+ fromDomain: "mondialrelay.fr",
45
+ dkimDomain: "gmail.example",
46
+ claimedBrand: "Mondial Relay",
47
+ summary:
48
+ "The display name claims “Mondial Relay”, but the message was sent and signed by a personal gmail.example mailbox — not mondialrelay.fr. Real carriers send from their own domain.",
49
+ similarCount: 15,
50
+ },
51
+ category: { value: "automated" },
52
+ flags: {},
53
+ similar: [
54
+ {
55
+ id: "sim_dhl",
56
+ mailboxId: "mbx_personal_junk",
57
+ threadId: "thr_sim_dhl",
58
+ fromName: "DHL Express",
59
+ subject: "Action required: customs fee outstanding",
60
+ timeLabel: "Thu",
61
+ matched: "body",
62
+ },
63
+ {
64
+ id: "sim_postnl",
65
+ mailboxId: "mbx_personal_junk",
66
+ threadId: "thr_sim_postnl",
67
+ fromName: "PostNL",
68
+ subject: "Uw pakket kon niet worden bezorgd",
69
+ timeLabel: "28 May",
70
+ matched: "body",
71
+ },
72
+ ],
73
+ };
74
+
75
+ /**
76
+ * The reading pane as `MailboxPane.Reading` composes it at this width: the
77
+ * banner above the thread body, and the drawer it opens over the panes.
78
+ */
79
+ const TwoPaneReading = () => {
80
+ const [drawerOpen, setDrawerOpen] = useState(false);
81
+ return (
82
+ <div
83
+ className="flex overflow-hidden rounded-lg border border-line bg-canvas"
84
+ // The transform makes the frame a containing block, so the drawer's
85
+ // `position: fixed` resolves against these 1100px instead of the
86
+ // Storybook canvas. `relative` does not do this for fixed children.
87
+ style={{ width: TWO_PANE_WIDTH, height: 700, transform: "translateZ(0)" }}
88
+ >
89
+ <div className="w-[38%] shrink-0 border-r border-line bg-surface-sunken" />
90
+ <section className="flex min-w-0 flex-1 flex-col">
91
+ <header className="border-b border-line px-5 pt-5 pb-3">
92
+ <h1 className="max-w-2xl text-lg font-semibold leading-snug text-fg">
93
+ Your parcel could not be delivered
94
+ </h1>
95
+ <p className="mt-1 text-2xs text-fg-subtle">1 message</p>
96
+ </header>
97
+ <AuthenticityBanner
98
+ authenticity={authenticity}
99
+ onOpenIntelligence={() => setDrawerOpen(true)}
100
+ />
101
+ <div className="min-h-0 flex-1 overflow-y-auto px-5 py-4 text-sm text-fg">
102
+ <p>
103
+ We attempted to deliver your parcel today and nobody was home. Pay
104
+ the outstanding €2.40 redelivery fee within 24 hours to schedule a
105
+ new attempt.
106
+ </p>
107
+ </div>
108
+ </section>
109
+ <Drawer
110
+ isOpen={drawerOpen}
111
+ onClose={() => setDrawerOpen(false)}
112
+ ariaLabel="Message details"
113
+ side="right"
114
+ >
115
+ <IntelligencePanel data={intelligence} hideCloseButton />
116
+ </Drawer>
117
+ </div>
118
+ );
119
+ };
120
+
121
+ /** Two panes, no rail: press "Why?" and the drawer carries the panel. */
122
+ export const TwoPaneDesktop: Story = {
123
+ render: () => <TwoPaneReading />,
124
+ };
125
+
126
+ /** The same surface on the dark theme. */
127
+ export const TwoPaneDesktopDark: Story = {
128
+ name: "Two Pane Desktop (dark)",
129
+ parameters: { theme: "dark" },
130
+ render: () => <TwoPaneReading />,
131
+ };
@@ -6,7 +6,7 @@ import { DESKTOP_MEDIA_QUERY, useMatchMedia } from "@remit/ui";
6
6
  * layout — phones, narrow tablets, and a large tablet in portrait, which is
7
7
  * 1024px wide but has no room for the three-pane desktop grid (#682).
8
8
  *
9
- * The CSS-gated mobile chrome (Drawer, ComposeFab) uses `lg:hidden`, and the
9
+ * The CSS-gated mobile chrome (ComposeFab) uses `lg:hidden`, and the
10
10
  * `lg` variant is redefined in `@remit/ui`'s token sheet with the same
11
11
  * condition — change `DESKTOP_MEDIA_QUERY` and both move together.
12
12
  */
package/vite.base.ts CHANGED
@@ -4,6 +4,7 @@ import tailwindcss from "@tailwindcss/vite";
4
4
  import { tanstackRouter } from "@tanstack/router-plugin/vite";
5
5
  import react from "@vitejs/plugin-react";
6
6
  import type { AliasOptions, BuildOptions, PluginOption } from "vite";
7
+ import { spellcheckPlugin } from "./spellcheck/vite-plugin.ts";
7
8
 
8
9
  /**
9
10
  * The one place the web-client build toolchain is described. Both the dev
@@ -54,4 +55,5 @@ export const webClientPlugins = (routes: RouterPaths = {}): PluginOption[] => [
54
55
  tanstackRouter({ target: "react", autoCodeSplitting: true, ...routes }),
55
56
  react(),
56
57
  tailwindcss(),
58
+ spellcheckPlugin(),
57
59
  ];