Four right-to-left languages at /ar/, /he/, /fa/ and /ur/ — thirteen in all. The layout no longer has a left and a right, it has a start and an end: the margins, insets and corners are CSS logical properties now, so they follow dir on <html>. Directional glyphs flip; keys, safety codes and session descriptors are pinned left-to-right so bidi cannot reorder what two people compare against each other's screens. Also fixed: the Service Worker was registered as './sw.js', which 404s from every locale subdirectory, so twelve of the thirteen pages had no worker at all. And the bundler ran before the dictionaries were generated, so a newly added language could reach the page ahead of the app that renders it.
189 lines
11 KiB
JavaScript
189 lines
11 KiB
JavaScript
// Right-to-left is a layout property, not a translation. Arabic strings in a left-to-right
|
|
// page are not a localized app — the avatar still sits on the wrong side of the name, the
|
|
// drawer still flies in from the wrong edge, the chevron still points away from "next".
|
|
//
|
|
// So this test guards the mechanism rather than the wording: that direction reaches the
|
|
// document, that the source no longer hard-codes a physical side, and that the two things
|
|
// which must NOT mirror — the swipe maths and machine-readable text — are handled.
|
|
//
|
|
// It reads the source rather than rendering it because there is no DOM here that resolves
|
|
// logical properties: jsdom parses `margin-inline-start` but has no layout engine to
|
|
// mirror it, so an assertion about pixels would be an assertion about jsdom.
|
|
|
|
import assert from 'node:assert/strict';
|
|
import { readFileSync, readdirSync, statSync } from 'node:fs';
|
|
import path from 'node:path';
|
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
|
|
const ROOT = fileURLToPath(new URL('..', import.meta.url));
|
|
const read = (rel) => readFileSync(path.join(ROOT, rel), 'utf8');
|
|
const site = JSON.parse(read('locales/site.json'));
|
|
|
|
/** Every source file the UI is built from. */
|
|
function sources() {
|
|
const out = [];
|
|
const walk = (dir) => {
|
|
for (const entry of readdirSync(path.join(ROOT, dir))) {
|
|
const rel = `${dir}/${entry}`;
|
|
if (statSync(path.join(ROOT, rel)).isDirectory()) walk(rel);
|
|
else if (/\.(jsx?|css)$/.test(entry) && !rel.endsWith('i18n/generated.js')) out.push(rel);
|
|
}
|
|
};
|
|
walk('src');
|
|
return out;
|
|
}
|
|
|
|
// ── The direction reaches the page ───────────────────────────────────────────────────
|
|
// It has to be in the served HTML, not applied by the app: styling and first paint both
|
|
// happen long before React mounts, so a direction set in JS shows one frame of a mirrored
|
|
// layout to every RTL reader.
|
|
{
|
|
const rtl = site.locales.filter((code) => JSON.parse(read(`locales/${code}.json`)).dir === 'rtl');
|
|
assert.ok(rtl.length > 0, 'no RTL locale is registered — this test has nothing to protect');
|
|
|
|
for (const code of rtl) {
|
|
const locale = JSON.parse(read(`locales/${code}.json`));
|
|
const page = read(code === site.defaultLocale ? 'index.html' : `${code}/index.html`);
|
|
assert.match(page, new RegExp(`<html lang="${locale.htmlLang}" dir="rtl">`),
|
|
`${code}: the generated page does not declare its writing direction`);
|
|
assert.equal(JSON.parse(read(`${code}/manifest.json`)).dir, 'rtl',
|
|
`${code}: an installed PWA would launch left-to-right`);
|
|
}
|
|
|
|
assert.ok(read('templates/index.template.html').includes('src/styles/rtl.css'),
|
|
'the mirroring stylesheet is not linked from the template');
|
|
}
|
|
|
|
// ── The runtime exposes direction ────────────────────────────────────────────────────
|
|
{
|
|
const i18n = await import(pathToFileURL(path.join(ROOT, 'src/i18n/index.js')));
|
|
const rtl = site.locales.find((code) => JSON.parse(read(`locales/${code}.json`)).dir === 'rtl');
|
|
|
|
assert.equal(i18n.localeDir(site.defaultLocale), 'ltr');
|
|
assert.equal(i18n.localeDir(rtl), 'rtl');
|
|
assert.equal(i18n.isRTL(rtl), true);
|
|
assert.equal(i18n.isRTL(site.defaultLocale), false);
|
|
assert.equal(i18n.localeDir('nope'), 'ltr', 'an unknown locale must not throw or mirror');
|
|
|
|
// The sign is what call sites multiply an offset by, so it is the part that breaks
|
|
// silently: a drawer with the wrong sign slides off-screen instead of open.
|
|
assert.equal(i18n.direction(site.defaultLocale), 1);
|
|
assert.equal(i18n.direction(rtl), -1);
|
|
|
|
assert.equal(i18n.LTR_TEXT.dir, 'ltr');
|
|
assert.equal(i18n.LTR_TEXT.style.unicodeBidi, 'isolate',
|
|
'an LTR run inside RTL text must be isolated, or it drags the sentence around it');
|
|
}
|
|
|
|
// ── No physical sides left in the UI ─────────────────────────────────────────────────
|
|
// A single margin-left is enough to put an icon on the wrong side of its label, and it is
|
|
// invisible to anyone testing in English. Logical properties are the whole mechanism, so
|
|
// a regression here is a regression in RTL support.
|
|
{
|
|
// camelCase in React style objects, kebab-case in stylesheets and injected CSS strings.
|
|
const banned = [
|
|
[/\bmarginLeft\s*:/, 'marginLeft → marginInlineStart'],
|
|
[/\bmarginRight\s*:/, 'marginRight → marginInlineEnd'],
|
|
[/\bpaddingLeft\s*:/, 'paddingLeft → paddingInlineStart'],
|
|
[/\bpaddingRight\s*:/, 'paddingRight → paddingInlineEnd'],
|
|
[/\bborderLeft\s*:/, 'borderLeft → borderInlineStart'],
|
|
[/\bborderRight\s*:/, 'borderRight → borderInlineEnd'],
|
|
[/textAlign\s*:\s*['"](?:left|right)['"]/, "textAlign: 'left'/'right' → 'start'/'end'"],
|
|
[/(?<![-\w])margin-(?:left|right)\s*:/, 'margin-left/right → margin-inline-start/end'],
|
|
[/(?<![-\w])padding-(?:left|right)\s*:/, 'padding-left/right → padding-inline-start/end'],
|
|
[/(?<![-\w])border-(?:left|right)\s*:/, 'border-left/right → border-inline-start/end'],
|
|
[/text-align\s*:\s*(?:left|right)\b/, 'text-align: left/right → start/end'],
|
|
[/(?<![-\w])\bml-\d/, 'Tailwind ml-* → ms-*'],
|
|
[/(?<![-\w])\bmr-\d/, 'Tailwind mr-* → me-*'],
|
|
[/(?<![-\w])\bpl-\d/, 'Tailwind pl-* → ps-*'],
|
|
[/(?<![-\w])\bpr-\d/, 'Tailwind pr-* → pe-*'],
|
|
[/(?<![-\w])\btext-(?:left|right)\b/, 'Tailwind text-left/right → text-start/end'],
|
|
];
|
|
|
|
// rtl.css is where the exceptions live — it exists precisely to say "left" on purpose.
|
|
const files = sources().filter((f) => f !== 'src/styles/rtl.css');
|
|
|
|
// A notch is on the physical left of the handset whichever way the text runs, so
|
|
// safe-area insets are the one place where a physical side is the correct answer.
|
|
const physicalOnPurpose = (line) => line.includes('env(safe-area-inset-');
|
|
|
|
const offences = [];
|
|
for (const file of files) {
|
|
const lines = read(file).split('\n');
|
|
lines.forEach((line, i) => {
|
|
if (physicalOnPurpose(line)) return;
|
|
for (const [pattern, fix] of banned) {
|
|
if (pattern.test(line)) offences.push(`${file}:${i + 1} ${fix}`);
|
|
}
|
|
});
|
|
}
|
|
assert.deepEqual(offences, [], `physical directions left in the UI:\n ${offences.join('\n ')}`);
|
|
}
|
|
|
|
// ── The gesture mirrors with the layout ──────────────────────────────────────────────
|
|
// The drawer keeps one logical offset (0 open, -width closed) in both directions, and
|
|
// mirrors at exactly two points: the pixels it paints and the finger that drives it. Miss
|
|
// either and the panel tracks the wrong way under the thumb.
|
|
{
|
|
const app = read('src/app.jsx');
|
|
assert.match(app, /const DIR = direction\(\);/, 'app.jsx does not resolve the layout direction');
|
|
assert.match(app, /translate3d\(' \+ \(x \* DIR\)/, 'the drawer paints an unmirrored offset');
|
|
assert.match(app, /d\.base \+ \(e\.clientX - d\.x0\) \* DIR/, 'the drawer drag is not mirrored');
|
|
assert.ok(app.includes('d.vel.add(e.clientX * DIR'),
|
|
'the velocity tracker samples raw screen x, so a flick would settle the wrong way');
|
|
}
|
|
|
|
// ── Machine text is pinned left-to-right ─────────────────────────────────────────────
|
|
// Bidi reordering rewrites exactly the strings this app is made of. A session descriptor
|
|
// or a SAS code printed in the wrong order still looks like a key, so the reader compares
|
|
// it happily against their peer's screen and confirms a channel they never verified.
|
|
{
|
|
const app = read('src/app.jsx');
|
|
for (const field of ['value: sasInput', 'value: answerInput', 'value: offerInput']) {
|
|
const at = [...app.matchAll(new RegExp(field.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g'))]
|
|
.map((m) => m.index);
|
|
assert.notEqual(at.length, 0, `${field} is gone — this guard now protects nothing`);
|
|
// Every one of them, not just the first: the app renders the SAS code in two
|
|
// places, and it was the one further up the file that got missed.
|
|
for (const i of at) {
|
|
assert.ok(app.slice(Math.max(0, i - 260), i).includes("dir: 'ltr'"),
|
|
`the field holding ${field} at offset ${i} must be dir="ltr" or bidi reorders what the user compares`);
|
|
}
|
|
}
|
|
|
|
// Displaying a code is as dangerous as typing one, and easier to miss. The safety
|
|
// code is drawn one character per tile in a flex row, and a flex row follows the
|
|
// writing direction — so in Arabic the first character lands on the right and the
|
|
// code reads backwards. It still looks like a code, which is the whole problem.
|
|
const cells = app.indexOf("key: 'cells'");
|
|
assert.notEqual(cells, -1, 'the safety-code tiles are gone — this guard now protects nothing');
|
|
assert.ok(app.slice(cells, cells + 60).includes("dir: 'ltr'"),
|
|
'the safety-code tiles sit in a flex row, so they must be pinned left-to-right');
|
|
|
|
// The group code, wherever it is put on screen. A plain `group.sasCode` also appears
|
|
// in guard conditions, which render nothing — so the window looks both ways and only
|
|
// the sites that are actually inside an element have to carry the direction.
|
|
const group = read('src/components/ui/GroupChat.jsx');
|
|
// `!group.sasCode` is an existence check that renders nothing; everything else puts
|
|
// the digits somewhere a person can read them.
|
|
const shown = [...group.matchAll(/(!?)group\.sasCode/g)]
|
|
.filter((m) => m[1] !== '!')
|
|
.map((m) => m.index);
|
|
assert.ok(shown.length >= 2, 'the group safety code is no longer rendered where expected');
|
|
let pinned = 0;
|
|
for (const i of shown) {
|
|
if (group.slice(Math.max(0, i - 420), i + 260).includes("dir: 'ltr'")) pinned += 1;
|
|
}
|
|
assert.equal(pinned, shown.length,
|
|
`${shown.length - pinned} of ${shown.length} group-safety-code sites are not pinned ` +
|
|
'left-to-right — everyone in the group compares those digits against each other');
|
|
|
|
const rtlCss = read('src/styles/rtl.css');
|
|
for (const selector of ['[dir="rtl"] code', '[dir="rtl"] pre', '[dir="rtl"] .sb-sc']) {
|
|
assert.ok(rtlCss.includes(selector), `rtl.css no longer isolates ${selector}`);
|
|
}
|
|
assert.match(rtlCss, /unicode-bidi:\s*isolate/, 'isolation is what stops a run dragging its neighbours');
|
|
}
|
|
|
|
console.log('rtl-layout.test.mjs: all assertions passed');
|