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.
This commit is contained in:
Marc-André Bombeck committed 2026-11-11 21:43:10 +01:00
1 parent 2a60f21a8b
commit 3a249bccba
9 files changed
+193 -28

No files matched your search

+179 -3
View File
@@ -9,17 +9,21 @@
* 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 primitive keeps 16 px type on a touch screen: iOS
* Safari zooms the page into a focused field below 16 px, and a width
* 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:`.
* `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");
@@ -165,3 +169,175 @@ describe("form fields keep 16 px on a touch screen", () => {
},
);
});
/** 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([]);
});
});
@@ -41,9 +41,6 @@ const TEXT_FIELD_FILES = [
"src/components/ui/calendar.tsx",
"src/components/insights/coach-panel/coach-input.tsx",
"src/components/cycle/cycle-settings.tsx",
"src/components/records/about-me-note-manager.tsx",
"src/components/records/conditions-manager.tsx",
"src/components/records/allergy-free-text-note.tsx",
];
const FOCUS_VARIANT = /(^|:)(focus|focus-visible|focus-within|has-focus)(:|$)/;