diff --git a/app/src/App.css b/app/src/App.css index 2cbbbda..ba6af2a 100644 --- a/app/src/App.css +++ b/app/src/App.css @@ -325,20 +325,6 @@ select:focus { .modal select { width: 100%; } -/* The native number-input spinner (up/down arrows) renders as unstyled - * browser chrome that doesn't pick up the app's dark theme — hide it. - * (This field also sets step="any" for free decimal typing, so keyboard - * ArrowUp/ArrowDown stepping is UA-dependent regardless of this rule — - * typing/pasting a value is the guaranteed path either way.) */ -.modal input[type="number"] { - -moz-appearance: textfield; - appearance: textfield; -} -.modal input[type="number"]::-webkit-inner-spin-button, -.modal input[type="number"]::-webkit-outer-spin-button { - -webkit-appearance: none; - margin: 0; -} .path-row { display: flex; gap: 6px; diff --git a/app/src/modals.test.ts b/app/src/modals.test.ts index 3b7f33d..92167cb 100644 --- a/app/src/modals.test.ts +++ b/app/src/modals.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { shouldCloseOnEscape } from "./modals"; +import { sanitizeNumberDraft, shouldCloseOnEscape, stepFontSize } from "./modals"; function esc(overrides: Partial<{ isComposing: boolean; keyCode: number; defaultPrevented: boolean }> = {}) { return { @@ -32,3 +32,70 @@ describe("shouldCloseOnEscape", () => { expect(shouldCloseOnEscape(esc({ defaultPrevented: true }))).toBe(false); }); }); + +describe("sanitizeNumberDraft", () => { + it("passes plain digits through unchanged", () => { + expect(sanitizeNumberDraft("12")).toBe("12"); + }); + + it("strips letters and symbols WKWebView can let slip into a number input", () => { + expect(sanitizeNumberDraft("1a2b")).toBe("12"); + expect(sanitizeNumberDraft("1e5")).toBe("15"); + expect(sanitizeNumberDraft("!@#12$%")).toBe("12"); + }); + + it("keeps a single leading minus sign", () => { + expect(sanitizeNumberDraft("-12")).toBe("-12"); + }); + + it("drops a minus sign anywhere but the first character", () => { + expect(sanitizeNumberDraft("1-2")).toBe("12"); + expect(sanitizeNumberDraft("12-")).toBe("12"); + expect(sanitizeNumberDraft("--12")).toBe("-12"); + }); + + it("keeps only the first decimal point", () => { + expect(sanitizeNumberDraft("1.2.3")).toBe("1.23"); + expect(sanitizeNumberDraft("1..2")).toBe("1.2"); + }); + + it("allows a bare decimal point mid-edit (e.g. typing '12.' before the fraction)", () => { + expect(sanitizeNumberDraft("12.")).toBe("12."); + }); + + it("returns an empty string for entirely non-numeric input", () => { + expect(sanitizeNumberDraft("abc")).toBe(""); + }); + + it("passes an already-empty string through unchanged", () => { + expect(sanitizeNumberDraft("")).toBe(""); + }); +}); + +describe("stepFontSize", () => { + it("steps up by 1 from a valid draft", () => { + expect(stepFontSize("12", 12, 1, 8, 24)).toBe(13); + }); + + it("steps down by 1 from a valid draft", () => { + expect(stepFontSize("12", 12, -1, 8, 24)).toBe(11); + }); + + it("falls back to the committed value when the draft doesn't parse (e.g. empty, mid-edit)", () => { + expect(stepFontSize("", 12, 1, 8, 24)).toBe(13); + expect(stepFontSize("-", 12, 1, 8, 24)).toBe(13); + expect(stepFontSize(".", 12, -1, 8, 24)).toBe(11); + }); + + it("clamps at the maximum", () => { + expect(stepFontSize("24", 24, 1, 8, 24)).toBe(24); + }); + + it("clamps at the minimum", () => { + expect(stepFontSize("8", 8, -1, 8, 24)).toBe(8); + }); + + it("steps from a decimal draft and can land on a non-integer", () => { + expect(stepFontSize("12.5", 12.5, 1, 8, 24)).toBe(13.5); + }); +}); diff --git a/app/src/modals.tsx b/app/src/modals.tsx index 5a956e9..367c0ff 100644 --- a/app/src/modals.tsx +++ b/app/src/modals.tsx @@ -399,6 +399,48 @@ export function ConfirmModal(props: { export const MIN_TERMINAL_FONT_SIZE = 8; export const MAX_TERMINAL_FONT_SIZE = 24; +// type="number" doesn't reliably block non-numeric characters in every +// webview engine (koit's real-hardware report: WKWebView on macOS let them +// through into the field). Strips anything that isn't a digit, a leading +// "-", or the first "." — applied to the DRAFT text itself before it's +// shown, not just before committing, so a rejected character never +// visibly lands in the field even for a frame. +export function sanitizeNumberDraft(raw: string): string { + let result = ""; + let seenDot = false; + for (let i = 0; i < raw.length; i++) { + const ch = raw[i]; + if (ch === "-" && i === 0) { + result += ch; + } else if (ch === "." && !seenDot) { + seenDot = true; + result += ch; + } else if (ch >= "0" && ch <= "9") { + result += ch; + } + } + return result; +} + +// ArrowUp/ArrowDown stepping for the font-size field, extracted as a pure +// function so its clamping/fallback logic is unit-testable independent of +// the composition-guarded DOM event handler that calls it (see the +// onKeyDown below — the IME-composition check itself isn't something this +// helper can or should own). +export function stepFontSize( + draftText: string, + fallback: number, + direction: 1 | -1, + min: number, + max: number, +): number { + // Number("") is 0, not NaN — without the trim/empty check an empty draft + // would step from 0 instead of falling back to the last committed value. + const current = draftText.trim() === "" ? NaN : Number(draftText); + const base = Number.isFinite(current) ? current : fallback; + return Math.min(max, Math.max(min, base + direction)); +} + export function SettingsModal(props: { onClose: () => void; terminalFontSize: number; @@ -415,6 +457,17 @@ export function SettingsModal(props: { // field). Free typing (including decimals, an empty field mid-edit) is // always shown; only a complete, valid, in-range value is committed. const [fontSizeText, setFontSizeText] = useState(() => String(props.terminalFontSize)); + // A ref, not state — read/written synchronously inside the same tick as + // composition/change events, no re-render needed for it on its own. + const isComposingFontSize = useRef(false); + const commitFontSizeText = (raw: string) => { + const text = sanitizeNumberDraft(raw); + setFontSizeText(text); + const n = Number(text); + if (text.trim() !== "" && Number.isFinite(n) && n >= MIN_TERMINAL_FONT_SIZE && n <= MAX_TERMINAL_FONT_SIZE) { + props.onTerminalFontSizeChange(n); + } + }; // Computed once per modal open, not on every keystroke — the full zone // list (400+ IANA names) doesn't change while the dropdown is open. const [timeZones] = useState(listTimeZones); @@ -461,18 +514,65 @@ export function SettingsModal(props: {