Files
sganggs f3658a0dd4 0.2.3: prepare follow-up fixes and client features (#407, #408, #410, #413, #431, #435, #436, #440, #444)
Add special-target activation without bypassing attack cadence, cooperative
card preferences and emotes, skill sound banks, per-operator voice choices,
text sizing, install prompts, closed-tab recovery and unanimous setup rerolls.
Retain late deployment elapsed time and resolve echo/counter pulses without
recursive hook overflow. Independent final review remains pending.

Golden: roster-016, roster-035, roster-036, roster-045;
boss-boss_1-pair, boss-boss_3-pair, boss-boss_5-pair, boss-boss_7-pair,
hidden-boss_9-pair; solo-HARD-9-boosted: Lemuen attack cadence.
diy-132: Necras extra activation targets.
coop2-NORMAL-14-standins: cooperative purchase preference and simulation fixes.
hidden-boss_10-solo: echo pulse FIFO resolution.
See DESIGN 28.13-28.24 for assumptions, sources and contributor credit.
2026-10-09 22:55:24 +08:00

241 lines
14 KiB
JavaScript

// 设置 →「文字大小」 (the owner's decision of 2026-10-09): the interface text root `--t` of css/theme.css, stepped by
// the settings store (`sp.pref.settings` textSize), applied to <html data-text> by ui/settings.js applyTextSize.
// A phone clamps the LAYOUT root `1rem` at 40 px (theme.css), which left the .18rem body text at 7.2 CSS px, while the
// browser's own font settings only inflate glyphs inside fixed boxes and page zoom is off (index.html's viewport,
// ui/device.js). Only font-size declarations read `--t`: the five modules that turn `1rem` into pixels — ui/fieldHost.js
// hudPadding / hudBands (the prep camera's HUD clearance), ui/gameLogic/panel.js panelSlots (the detail card's side),
// ui/underframe.js (the tile labels' budget) and ui/fallbackField.js (the DOM board) — must never move with it, and the
// field itself is sized from the host element's clientWidth (render/app.js).
//
// What this file guards: the default is the design's own sizes; a bad or missing value falls back to 小; the step lands
// on <html data-text>; the layout root's clamp is untouched; and every font-size that stayed on a plain `rem` is one of
// the deliberate exclusions (the in-match HUD's fixed boxes, the board-pinned DOM text, geometry-derived sizes, the
// ≥16 px iOS-zoom guards) — a new readable size that forgets `--t` fails here, the way `tools/i18n.mjs extract 0` fails
// on an unwrapped string. Browser counterpart: test/ui/text-scale.e2e.test.js (SP_E2E=1).
import { describe, test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync, readdirSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { DEFAULT_SETTINGS, TEXT_SIZES, sanitizeSettings } from '../../public/js/ui/gameLogic.js';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
const read = (p) => readFileSync(path.join(ROOT, p), 'utf8');
const CSS = 'public/css';
/** Every stylesheet under public/css (index.html loads all of them, devices.css last), as { rel, src }. */
function stylesheets() {
const out = [];
const visit = (rel) => {
for (const e of readdirSync(path.join(ROOT, rel), { withFileTypes: true })) {
const r = `${rel}/${e.name}`;
if (e.isDirectory()) visit(r);
else if (r.endsWith('.css')) out.push({ rel: r, src: read(r).replace(/\/\*[\s\S]*?\*\//g, '') });
}
};
visit(CSS);
return out;
}
/** Rules as { selector, body } (flat; @media blocks are walked into, as test/client-static.test.js does). */
function cssRules(src) {
const out = [];
const re = /([^{}]+)\{([^{}]*)\}/g;
let m;
while ((m = re.exec(src))) out.push({ selector: m[1].trim(), body: m[2] });
return out;
}
/**
* The font sizes that deliberately stay on a plain `rem` (or a bare px): the in-match HUD chrome in fixed boxes, the
* DOM text pinned onto board tiles, the geometry-derived sizes and the ≥16 px iOS-zoom guards. Listed by selector, the
* same list the codemod used — a rule that stops being an exclusion (or a new one that should have used `--t`) shows up
* as a failure with its file and selector.
*/
const NOT_SCALED = [
// pinned onto board tiles by JS-computed pixels (ui/standInTags.js, ui/underframe.js)
/\.sitag/, /\.tempnote/, /\.uframe/, /\.ff-/,
// in-match HUD chrome whose box the design fixed (a later pass: shop cards, top bar, bond strip, corner buttons)
/\.gm__/, /\.roundbox/, /\.capsule/, /\.lp\b/, /\.lp--/, /\.misstag/, /\.bossbar/, /\.readywrap/,
/\.bstrip/, /\.bslot/, /\.effect/, /\.efftip/, /\.ebubble/, /\.ticker/, /\.dpbox/, /\.vswitch/,
/\.specpill/, /\.otwarn/, /\.team/, /\.enemybtn/, /\.readybtn/, /\.ewheel/, /\.fwheel/, /\.chud/, /\.toolbtn/,
/\.shopbar/, /\.scard/, /\.scr\b/,
// text inside a fixed-height bar (the HP bar of the detail card, the boss bar's fill)
/\.dhp\b/,
// the 机变 card: its grid fits 7 lines of the effect text (game-panels.css), so a bigger font would clamp text away
/\.spcard/,
// decorative wordmarks and boot/rotate overlays whose size is the composition
/\.title-cn/, /\.title-en/, /\.title-bg__target/, /\.brief-boss__q/, /\.boot__/, /\.rotate-hint/,
// the join code's letter-spaced field, the px-floored 本局信息 dialog, the 16 px diagnostics box (iOS zoom guard)
/\.field--code/, /\.minfo-dlg/, /\.set-diag__text/,
// the keyboard hints of the 快捷键 list (a shared rule with the HUD's .toolbtn kbd)
/\.set-hint kbd/,
];
const notScaled = (selector) => NOT_SCALED.some((re) => re.test(selector));
/**
* Whole stylesheets a later pass owns: the shop bar's fixed cards (their height feeds the prep camera's HUD band,
* ui/fieldHost.js HUD_REM), and the 干员调配 / 统计 screens, whose text is already px-floored (`max(.15rem, 11px)`) and
* therefore readable on a phone as it is. Scaling them is step 2/3 of the change, not a forgotten line.
*/
const NOT_SCALED_FILES = [`${CSS}/screens/game-shop.css`, `${CSS}/screens/loadout.css`, `${CSS}/screens/stats.css`];
/** A font-size value that is neither scaled nor an exclusion: a plain `rem`, or `max(<rem>, …)`. */
function plainRem(value) {
if (/var\(/.test(value)) return false; // --t, or a geometry-derived size
if (/(^|[^r])em\b|%/.test(value.replace(/rem\b/g, ''))) return false;
return /^\s*(max\(\s*)?\d*\.?\d+rem\b/.test(value);
}
describe('文字大小: the setting', () => {
test('the default is 小 (the design\'s own sizes) and a saved profile without it keeps it', () => {
assert.deepEqual([...TEXT_SIZES], ['sm', 'md', 'lg', 'xl'], 'the steps, in the settings order');
assert.equal(DEFAULT_SETTINGS.textSize, 'sm');
assert.equal(sanitizeSettings(null).textSize, 'sm');
assert.equal(sanitizeSettings({ bgm: 0.4 }).textSize, 'sm', 'a profile saved before the setting existed');
});
test('every step survives sanitising; anything else falls back to 小', () => {
for (const v of TEXT_SIZES) assert.equal(sanitizeSettings({ textSize: v }).textSize, v);
for (const bad of ['huge', 'SM', 'xl2', 'md ', 1, true, null, undefined, {}, []]) {
assert.equal(sanitizeSettings({ textSize: bad }).textSize, 'sm', String(bad));
}
assert.equal(sanitizeSettings({ textSize: 'xl', quality: 'low' }).quality, 'low', 'the other settings are untouched');
});
test('applyTextSize writes the step to <html data-text>, and 小 when it is not a step', async () => {
const { applyTextSize } = await import('../../public/js/ui/settings.js');
const el = { dataset: {} };
const prev = globalThis.document;
globalThis.document = { documentElement: el };
try {
for (const v of TEXT_SIZES) {
applyTextSize(v);
assert.equal(el.dataset.text, v);
}
for (const bad of ['', 'big', null, undefined, 42]) {
applyTextSize(bad);
assert.equal(el.dataset.text, 'sm', String(bad));
}
applyTextSize(undefined);
assert.equal(el.dataset.text, 'sm', 'the attribute is always written, never left stale');
} finally {
if (prev === undefined) delete globalThis.document; else globalThis.document = prev;
}
});
test('the store applies the step on every change, and at boot before the first render', async () => {
const { settingsStore, updateSettings } = await import('../../public/js/ui/settings.js');
const el = { dataset: {} };
const prev = globalThis.document;
globalThis.document = { documentElement: el };
try {
updateSettings({ textSize: 'lg' });
assert.equal(el.dataset.text, 'lg', 'picking a step re-renders the text immediately');
assert.equal(settingsStore.get().textSize, 'lg', 'and it is saved with the settings (sp.pref.settings)');
updateSettings({ textSize: 'sm' });
assert.equal(el.dataset.text, 'sm');
} finally {
updateSettings({ textSize: DEFAULT_SETTINGS.textSize });
if (prev === undefined) delete globalThis.document; else globalThis.document = prev;
}
});
test('the settings modal offers the four steps (data-testid text-size), below 画面质量', () => {
const src = read('public/js/ui/settings.js');
assert.match(src, /data-testid="text-size"/);
assert.match(src, /const TEXT_SCALES = \[\[?.*'sm'.*'md'.*'lg'.*'xl'/s, 'the steps with their labels');
assert.match(src, /applyTextSize\(settingsStore\.get\(\)\.textSize\)/, 'applied at module load (before main.js renders)');
assert.ok(src.indexOf("t('文字大小')") > src.indexOf("t('画面质量')"), 'the row is below the quality row');
assert.match(read('public/css/screens/game.css'), /\.set-seg\.set-textsize button \{ min-width: \.62rem; \}/,
'four steps fit the row on a phone (1rem = 40 px)');
});
});
describe('文字大小: the CSS text root', () => {
const sheets = stylesheets();
const theme = sheets.find((s) => s.rel === `${CSS}/theme.css`).src;
test('theme.css defines --t for 小 and every step, and nothing else defines it', () => {
assert.match(theme, /:root \{ --t: 1rem; \}/, '小 = the design\'s own sizes');
for (const [attr, value] of [['md', /--t: max\(1rem, 64px\)/], ['lg', /--t: max\(calc\(1rem \* 1\.15\), 76px\)/], ['xl', /--t: max\(calc\(1rem \* 1\.3\), 88px\)/]]) {
assert.match(theme, new RegExp(`:root\\[data-text="${attr}"\\] \\{ ${value.source}; \\}`), `the ${attr} step`);
}
for (const { rel, src } of sheets) {
if (rel === `${CSS}/theme.css`) continue;
assert.doesNotMatch(src, /--t\s*:/, `${rel}: --t is defined in theme.css only`);
}
});
test('the layout root 1rem is untouched — the five rem→px readers must not move', () => {
assert.match(theme, /html \{\n {2}font-size: clamp\(40px, min\(calc\(100vw \/ 19\.2\), calc\(100vh \/ 10\.8\)\), 240px\);/,
'the desktop clamp');
assert.match(theme, /font-size: clamp\(40px, min\(calc\(100vw \/ 19\.2\), calc\(100svh \/ 10\.8\)\), 240px\);/,
'the svh clamp (mobile toolbars)');
assert.doesNotMatch(theme, /font-size: clamp\([^)]*var\(--t\)/, 'the root scale never reads the text root');
// and the modules that convert it keep their own floor: a text step must not reach them
for (const f of ['public/js/ui/fieldHost.js', 'public/js/ui/gameLogic/panel.js', 'public/js/ui/underframe.js', 'public/js/ui/fallbackField.js']) {
const src = read(f);
assert.doesNotMatch(src, /--t\b|data-text/, `${f}: the layout must not read the text root`);
assert.match(src, /getComputedStyle\(document\.documentElement\)\.fontSize|rem\b/, `${f}: still converts 1rem`);
}
});
test('readable text reads --t (body, .micro, buttons, modals, panels, settings)', () => {
const want = [
[theme, /body \{[^}]*font-size: calc\(\.18 \* var\(--t\)\)/s, 'theme.css body (the 7.2 px text)'],
[theme, /\.micro \{[^}]*font-size: calc\(\.11 \* var\(--t\)\)/s, 'theme.css .micro (92 render sites)'],
[theme, /:root\[data-text="md"\]/, 'the steps'],
];
for (const [src, re, what] of want) assert.match(src, re, what);
const byRel = new Map(sheets.map((s) => [s.rel, s.src]));
const samples = [
['components.css', /\.btn \{[^}]*font-size: calc\(\.18 \* var\(--t\)\)/s],
['screens/game.css', /\.set-row__label \{[^}]*font-size: calc\(\.18 \* var\(--t\)\)/s],
['screens/game-panels.css', /\.dtext \{ font-size: calc\(\.15 \* var\(--t\)\); line-height: 1\.65;/],
['screens/guide.css', /\.guide__tips li \{ font-size: calc\(\.18 \* var\(--t\)\)/],
['screens/lobby.css', /\.mode-card__desc/, ],
];
for (const [file, re] of samples) assert.match(byRel.get(`${CSS}/${file}`), re, file);
});
test('a newer reader would be caught: every plain rem font-size is a documented exclusion', () => {
const bad = [];
let scaled = 0;
for (const { rel, src } of sheets) {
if (NOT_SCALED_FILES.includes(rel)) continue;
for (const { selector, body } of cssRules(src)) {
for (const m of body.matchAll(/font-size:([^;}]*)/g)) {
const value = m[1];
if (/var\(--t\)/.test(value)) { scaled++; continue; }
if (!plainRem(value)) continue;
if (notScaled(selector)) continue;
bad.push(`${rel}: ${selector} { font-size:${value.trim()} }`);
}
}
}
assert.deepEqual(bad, [], `not on --t and not a documented exclusion (${scaled} declarations do read --t):`);
assert.ok(scaled >= 250, `${scaled} declarations read --t — a mass revert would fail here`);
});
test('the in-match HUD, the board-pinned text and the iOS zoom guards stay as they were', () => {
const byRel = new Map(sheets.map((s) => [s.rel, s.src]));
const plain = [
['screens/game-panels.css', /\.sitag \{[^}]*font-size: max\(\.12rem, 9px\)/, '.sitag'],
['screens/game-panels.css', /\.tempnote__title \{[^}]*font-size: \.15rem/, '.tempnote__title'],
['screens/game-panels.css', /\.uframe__label \{[^}]*font-size: \.14rem/, '.uframe__label'],
['screens/game-panels.css', /\.spcard__desc \{ flex: none; font-size: \.2rem/, '.spcard__desc (7 clamped lines)'],
['screens/game-panels.css', /\.dhp span \{[^}]*font-size: \.12rem/, '.dhp span (a fixed-height HP bar)'],
['screens/game-shop.css', /\.scard__name \{[^}]*font-size: \.19rem/, 'the shop card'],
['screens/game.css', /\.bslot \.bond__name \{[^}]*font-size: \.14rem/, 'the bond strip'],
['screens/game.css', /\.bossbar__txt \{[^}]*font-size: \.14rem/, 'the boss bar'],
['screens/game.css', /\.lp--md \{ font-size: \.24rem; \}/, 'the top bar LP'],
['screens/game.css', /\.sp-coarse \.set-diag__text \{ font-size: 16px; \}/, 'the 16 px iOS-zoom guard'],
['theme.css', /\.rotate-hint \{[\s\S]*?font-size: 16px/, 'the rotate hint'],
];
for (const [file, re, what] of plain) assert.match(byRel.get(`${CSS}/${file}`), re, what);
});
});