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)(:|$)/;
@@ -37,6 +37,7 @@ import { SettingsCardActions } from "@/components/settings/_card-actions";
import { QueryErrorRow } from "@/components/ui/query-error-row";
import { SettingsInfoTile } from "@/components/settings/_info-tile";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { ConfirmButton } from "@/components/ui/confirm-button";
import {
useAboutMeSaveTimeoutMs,
@@ -44,7 +45,6 @@ import {
} from "@/hooks/use-ai-capability";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { cn } from "@/lib/utils";
import { apiGet, apiPut } from "@/lib/api/api-fetch";
import { withBaseToken, isConflict } from "@/lib/api/optimistic-token";
@@ -61,9 +61,6 @@ interface AboutMeData {
fieldMaxChars: number;
}
const FIELD_CLASSES =
"border-input bg-background placeholder:text-muted-foreground focus-visible:border-input-focus w-full rounded-md border px-3 py-2 text-sm focus-visible:outline-none";
export function AboutMeNoteManager() {
const { t } = useTranslations();
const queryClient = useQueryClient();
@@ -134,10 +131,11 @@ export function AboutMeNoteManager() {
<Label htmlFor="settings-about-me-freetext">
{t("settings.ai.aboutMe.freeTextLabel")}
</Label>
<textarea
<Textarea
id="settings-about-me-freetext"
data-testid="settings-about-me-textarea"
className={cn(FIELD_CLASSES, "min-h-36 resize-y")}
className="resize-y"
rows={6}
value={value}
maxLength={maxChars}
placeholder={t("settings.ai.aboutMe.placeholder")}
@@ -31,10 +31,10 @@ import { Button } from "@/components/ui/button";
import { SettingsCardActions } from "@/components/settings/_card-actions";
import { QueryErrorRow } from "@/components/ui/query-error-row";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { FreeTextTakeOver } from "@/components/records/free-text-take-over";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { cn } from "@/lib/utils";
import { apiGet, apiPut } from "@/lib/api/api-fetch";
import { useAboutMeSaveTimeoutMs } from "@/hooks/use-ai-capability";
import { withBaseToken, isConflict } from "@/lib/api/optimistic-token";
@@ -50,9 +50,6 @@ interface AboutMeData {
fieldMaxChars: number;
}
const FIELD_CLASSES =
"border-input bg-background placeholder:text-muted-foreground focus-visible:border-input-focus w-full rounded-md border px-3 py-2 text-sm focus-visible:outline-none";
export function AllergyFreeTextNote({
leadingAction,
}: {
@@ -122,10 +119,10 @@ export function AllergyFreeTextNote({
<p className="text-muted-foreground text-xs">
{t("records.allergies.freeText.hint")}
</p>
<textarea
<Textarea
id="records-allergy-free-text"
data-testid="records-allergy-free-text"
className={cn(FIELD_CLASSES, "min-h-16 resize-y")}
className="resize-y"
rows={2}
value={value}
maxLength={fieldMaxChars}
@@ -29,11 +29,11 @@ import { showErrorToast } from "@/lib/errors/error-toast";
import { Button } from "@/components/ui/button";
import { SettingsCardActions } from "@/components/settings/_card-actions";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { FreeTextTakeOver } from "@/components/records/free-text-take-over";
import { QueryErrorCard } from "@/components/ui/query-error-card";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { cn } from "@/lib/utils";
import { apiGet, apiPut } from "@/lib/api/api-fetch";
import { useAboutMeSaveTimeoutMs } from "@/hooks/use-ai-capability";
@@ -47,9 +47,6 @@ interface AboutMeData {
fieldMaxChars: number;
}
const FIELD_CLASSES =
"border-input bg-background placeholder:text-muted-foreground focus-visible:border-input-focus w-full rounded-md border px-3 py-2 text-sm focus-visible:outline-none";
export function ConditionsManager() {
const { t } = useTranslations();
const queryClient = useQueryClient();
@@ -112,10 +109,10 @@ export function ConditionsManager() {
return (
<div className="space-y-1.5">
<Label htmlFor={`records-conditions-${key}`}>{label}</Label>
<textarea
<Textarea
id={`records-conditions-${key}`}
data-testid={`records-conditions-${key}`}
className={cn(FIELD_CLASSES, "min-h-16 resize-y")}
className="resize-y"
rows={2}
value={value[key]}
maxLength={fieldMaxChars}
+1 -1
View File
@@ -566,7 +566,7 @@ function FullBackupCard() {
data-lpignore="true"
data-1p-ignore="true"
data-bwignore="true"
className="h-9 text-sm"
className="h-9"
/>
<p className="text-muted-foreground text-xs">
{t("settings.sections.export.cards.fullBackup.passphraseHelp")}
@@ -130,7 +130,7 @@ export function CsvImportCard() {
maxLength={MAX_PASTE_CHARS}
spellCheck={false}
placeholder="type,value,unit,measuredAt,…"
className="font-mono text-xs"
className="font-mono sm:pointer-fine:text-xs"
/>
<p className="text-muted-foreground text-right text-xs tabular-nums">
{t("settings.sections.export.import.charCount", {
@@ -234,7 +234,7 @@ export function DoseHistoryImportCard() {
maxLength={MAX_PASTE_CHARS}
spellCheck={false}
placeholder="Date,Scheduled Date,Medication,…"
className="font-mono text-xs"
className="font-mono sm:pointer-fine:text-xs"
/>
<p className="text-muted-foreground text-right text-xs tabular-nums">
{t("settings.sections.export.import.charCount", {
@@ -211,7 +211,7 @@ export function JsonImportCard() {
maxLength={MAX_PASTE_CHARS}
spellCheck={false}
placeholder='{"measurements":[…],"moodEntries":[…]}'
className="font-mono text-xs"
className="font-mono sm:pointer-fine:text-xs"
/>
<p className="text-muted-foreground text-right text-xs tabular-nums">
{t("settings.sections.export.import.charCount", {