Files
HealthLog/src/__tests__/pwa-mobile-shell-guard.test.ts
T
Marc-André Bombeck 3a249bccba fix(settings): keep note and paste fields at 16 px on a phone
On /settings/anamnesis the four note fields were raw textareas styled
by three copies of a class constant with text-sm (14 px), and on
/settings/export the three paste fields used text-xs (12 px) and the
backup passphrase text-sm. iOS zooms the page into such a field on
focus and leaves it zoomed. The notes use the Textarea primitive now
and the export fields keep the small size only under a fine pointer.

The 16 px guard read the five field primitives and could not see a
call site override their size, or a raw field styled from a constant.
It now parses every field call site; four that are still small sit on
a list that only shrinks. The focus guard drops the three records
files, whose fields come from the primitive now.
2026-11-11 21:43:10 +01:00

344 lines
12 KiB
TypeScript

/**
* Structural guards for the installed app on a phone — the parts no browser
* in CI can show, because neither Chromium nor WebKit under Playwright
* reports a safe-area inset or opens an on-screen keyboard.
*
* 1. The authenticated shell takes the status-bar and side insets once,
* on its outer wrapper, and no 4rem header band pads itself inside its
* own height (which on a phone with a 59 px inset left the top bar a
* 5 px content box and pushed the logo across its bottom border).
* 2. The stylesheet carries the rules the shell and the keyboard bridge
* depend on.
* 3. Every form field keeps 16 px type on a touch screen: iOS Safari
* zooms the page into a focused field below 16 px, and a width
* breakpoint alone (`md:text-sm`) handed 14 px to a phone held sideways
* and to every iPad. The small size is allowed only behind
* `pointer-fine:`. That holds for the primitives and for every call
* site, whose `text-sm` replaces the primitive's size, and for a raw
* `<textarea>` styled from a class constant.
*/
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import ts from "typescript";
import { describe, expect, it } from "vitest";
import { stripComments } from "./helpers/source-files";
import { parse, productionSources, readSrc } from "./helpers/source-metrics";
const ROOT = resolve(__dirname, "../..");
const read = (path: string) => readFileSync(resolve(ROOT, path), "utf8");
/** Drop `//` and block comments so prose about a class cannot satisfy a check. */
function code(source: string, fileName?: string): string {
return stripComments(source, fileName);
}
describe("the shell owns the safe area", () => {
it("the authenticated shell wrapper takes the insets", () => {
const shell = code(read("src/components/layout/auth-shell.tsx"));
expect(shell).toMatch(/<div className=\{SHELL_ROOT_CLASS\}>/);
expect(read("src/components/layout/shell-metrics.ts")).toMatch(
/SHELL_ROOT_CLASS =\s*"shell-safe-area absolute inset-0 flex flex-col"/,
);
});
it.each([
"src/components/layout/top-bar.tsx",
"src/components/insights/coach-panel/conversations-panel.tsx",
"src/components/day/day-layer.tsx",
])("%s pads no header band with the status-bar inset", (path) => {
const source = code(read(path));
expect(source).not.toMatch(/paddingTop:\s*"env\(safe-area-inset-top/);
expect(source).not.toMatch(/\bpt-\[env\(safe-area-inset-top/);
});
it("globals.css defines the shell band, the keyboard rules and pointer-fine", () => {
const css = read("src/app/globals.css");
expect(css).toMatch(
/\.shell-safe-area\s*\{[^}]*padding-top:\s*env\(safe-area-inset-top/,
);
expect(css).toMatch(
/\[data-sheet-side="bottom"\]\s*\{\s*bottom:\s*var\(--keyboard-inset/,
);
expect(css).toMatch(
/html\[data-keyboard="open"\] \[data-keyboard-hide\]\s*\{\s*display:\s*none/,
);
expect(css).toMatch(/@custom-variant pointer-fine/);
});
it("bottom toasts clear the bar under the same query the bar follows", () => {
// Sonner's own mobile offset stops at 600 px; the bar shows up to `md`
// and on a phone held sideways, so the lift keys on `shell-mobile`.
const css = code(read("src/app/globals.css"), "globals.css");
expect(css).toMatch(
/html \[data-sonner-toaster\]\[data-y-position="bottom"\]\s*\{\s*@variant shell-mobile\s*\{\s*bottom:\s*calc\(4rem \+ env\(safe-area-inset-bottom/,
);
});
it("the bottom bar and the Coach button step aside for the keyboard", () => {
expect(read("src/components/layout/bottom-nav.tsx")).toContain(
'data-keyboard-hide=""',
);
expect(read("src/components/insights/layout-coach-fab.tsx")).toContain(
'data-keyboard-hide=""',
);
});
it("a bottom sheet on the keyboard drops its home-indicator padding", () => {
// Standing on the keyboard, the safe-area inset left a ~34 px gap
// between the sheet's last row and the keys.
expect(code(read("src/app/globals.css"), "globals.css")).toMatch(
/@custom-variant keyboard-open \(html\[data-keyboard="open"\] &\);/,
);
const sheet = code(read("src/components/ui/responsive-sheet.tsx"));
const padded = (sheet.match(
/"[^"]*pb-\[calc\(env\(safe-area-inset-bottom[^"]*"/g,
) ?? []) as string[];
expect(padded.length).toBe(2);
for (const cls of padded) expect(cls).toMatch(/\bkeyboard-open:pb-4\b/);
});
it("those rules sit under the utilities, and the bottom sheet sets no bottom utility", () => {
// Unlayered, the rules outranked every utility on the same element: a
// `bg-*` on the shell or a `bottom-*` on a sheet would have been ignored
// without a trace. In the components layer a utility wins, so the bottom
// sheet must not carry one that would pin it under the keyboard.
const css = code(read("src/app/globals.css"), "globals.css");
const start = css.indexOf("@layer components {");
expect(start).toBeGreaterThan(-1);
let depth = 0;
let end = start;
for (let i = css.indexOf("{", start); i < css.length; i += 1) {
if (css[i] === "{") depth += 1;
else if (css[i] === "}" && --depth === 0) {
end = i;
break;
}
}
const layer = css.slice(start, end);
expect(layer).toMatch(/\.shell-safe-area\s*\{/);
expect(layer).toMatch(/\[data-sheet-side="bottom"\]\s*\{\s*bottom:/);
expect(layer).toMatch(/\[data-keyboard-hide\]\s*\{\s*display:\s*none/);
expect(layer).toMatch(/\[data-sheet-side="left"\]\s*\{\s*border-left:/);
// Sonner's own rules are unlayered; the toast lift must stay so.
expect(layer).not.toMatch(/data-sonner-toaster/);
const sheet = code(read("src/components/ui/bottom-sheet.tsx"));
const bottomClass = sheet.match(/BOTTOM_SHEET_CLASS\s*=\s*"([^"]*)"/)?.[1];
expect(bottomClass).toBeDefined();
expect(bottomClass).not.toMatch(/(?<![\w-])bottom-/);
});
it("the sheet primitives name their side for those rules", () => {
expect(read("src/components/ui/sheet.tsx")).toMatch(
/data-sheet-side=\{side\}/,
);
expect(read("src/components/ui/bottom-sheet.tsx")).toMatch(
/data-sheet-side="bottom"/,
);
});
});
describe("form fields keep 16 px on a touch screen", () => {
const FIELDS = [
"src/components/ui/input.tsx",
"src/components/ui/textarea.tsx",
"src/components/ui/native-select.tsx",
"src/components/ui/date-field.tsx",
"src/components/ui/time-field.tsx",
];
it.each(FIELDS)(
"%s sets text-base and shrinks only under a fine pointer",
(path) => {
// The field's own box is the class string that draws its border; the
// rest of the file (a picker's buttons, a caption) is not typed into.
const boxes = (code(read(path)).match(
/"[^"]*\bborder-input(?![\w-])[^"]*"/g,
) ?? []) as string[];
expect(boxes.length, `${path} has no field class`).toBeGreaterThan(0);
for (const box of boxes) {
expect(box).toMatch(/\btext-base\b/);
// Any sub-16 px size must sit behind `pointer-fine:`; `file:` styles
// the upload button, not typed text.
const bare = box.match(
/(?<![\w:-])(?:(?:sm|md|lg|xl):)?text-(?:xs|sm)(?![\w-])/g,
);
expect(bare ?? []).toEqual([]);
}
},
);
});
/** Elements a person types into. */
const FIELD_TAGS = new Set([
"input",
"textarea",
"select",
"Input",
"Textarea",
"PasswordInput",
"NativeSelect",
]);
/** `type` values of an `<input>` nobody types into. */
const NOT_TYPED = new Set([
"checkbox",
"radio",
"range",
"file",
"hidden",
"color",
"button",
"submit",
"reset",
"image",
]);
/** A type size under 16 px that is not behind `pointer-fine:`. */
const SMALL_TYPE =
/(?<![\w:-])(?:(?:sm|md|lg|xl):)?text-(?:2xs|xs|sm)(?![\w-])/g;
/** Every string a `className` expression can produce, constants resolved. */
function classStrings(
node: ts.Node,
constants: ReadonlyMap<string, string>,
): string[] {
if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) {
return [node.text];
}
if (ts.isTemplateExpression(node)) {
return [
node.head.text,
...node.templateSpans.flatMap((span) => [
...classStrings(span.expression, constants),
span.literal.text,
]),
];
}
if (ts.isIdentifier(node)) {
const value = constants.get(node.text);
return value === undefined ? [] : [value];
}
const out: string[] = [];
node.forEachChild((child) => {
out.push(...classStrings(child, constants));
});
return out;
}
/** `rel:line <Tag> class` for each field whose classes go under 16 px. */
function smallFields(
rel: string,
text: string,
): {
fields: number;
small: string[];
} {
const sf = parse(rel, text);
const constants = new Map<string, string>();
sf.forEachChild((statement) => {
if (!ts.isVariableStatement(statement)) return;
for (const decl of statement.declarationList.declarations) {
if (
ts.isIdentifier(decl.name) &&
decl.initializer &&
(ts.isStringLiteral(decl.initializer) ||
ts.isNoSubstitutionTemplateLiteral(decl.initializer))
) {
constants.set(decl.name.text, decl.initializer.text);
}
}
});
let fields = 0;
const small: string[] = [];
const visit = (node: ts.Node) => {
if (
(ts.isJsxOpeningElement(node) || ts.isJsxSelfClosingElement(node)) &&
FIELD_TAGS.has(node.tagName.getText(sf))
) {
const attribute = (name: string) =>
node.attributes.properties.find(
(prop): prop is ts.JsxAttribute =>
ts.isJsxAttribute(prop) && prop.name.getText(sf) === name,
);
const type = attribute("type")?.initializer;
const typed = !(
type &&
ts.isStringLiteral(type) &&
NOT_TYPED.has(type.text)
);
const className = attribute("className")?.initializer;
if (typed && className) {
fields += 1;
const line = sf.getLineAndCharacterOfPosition(node.getStart()).line + 1;
for (const cls of classStrings(className, constants)) {
for (const match of cls.matchAll(SMALL_TYPE)) {
small.push(
`${rel}:${line} <${node.tagName.getText(sf)}> ${match[0]}`,
);
}
}
}
}
node.forEachChild(visit);
};
visit(sf);
return { fields, small };
}
describe("no field call site goes under 16 px on a touch screen", () => {
/**
* Call sites still under 16 px, fixed by a change of their own. An entry
* that stops matching fails, so the list only shrinks.
*/
const OPEN: Readonly<Record<string, string>> = {
"components/settings/coach-memory-section.tsx":
"/settings/coach memory editor",
"components/settings/access/managed-profile-handover.tsx":
"read-only handover link field",
"components/cycle/cycle-settings.tsx": "raw cycle-length number field",
"components/command-palette/command-palette.tsx":
"search field shrinks at `md:` without `pointer-fine:`",
};
const scan = productionSources()
.filter((rel) => rel.endsWith(".tsx"))
.map((rel) => ({ rel, ...smallFields(rel, readSrc(rel)) }));
it("reads the field call sites (floor)", () => {
const fields = scan.reduce((sum, file) => sum + file.fields, 0);
expect(fields).toBeGreaterThan(90);
});
it("finds a class constant on a raw field, and spares fine-pointer sizes and checkboxes", () => {
const { small } = smallFields(
"probe.tsx",
[
'const FIELD_CLASSES = "border-input w-full text-sm";',
"export const A = () => (",
" <>",
' <textarea className={cn(FIELD_CLASSES, "min-h-16")} />',
' <Textarea className="font-mono sm:pointer-fine:text-xs" />',
' <input type="checkbox" className="text-sm" />',
' <Input className={`h-9 ${wide ? "md:text-sm" : ""}`} />',
" </>",
");",
].join("\n"),
);
expect(small).toEqual([
"probe.tsx:4 <textarea> text-sm",
"probe.tsx:7 <Input> md:text-sm",
]);
});
it("no typed-into field sets a size under 16 px outside a fine pointer", () => {
const offenders = scan
.filter((file) => !(file.rel in OPEN))
.flatMap((file) => file.small);
expect(offenders).toEqual([]);
});
it.each(Object.keys(OPEN))("%s is still open", (rel) => {
expect(scan.find((file) => file.rel === rel)?.small ?? []).not.toEqual([]);
});
});