From 38b067a33b20ade152de3a9c0c42334379e0ce9b Mon Sep 17 00:00:00 2001 From: fujibee Date: Wed, 15 Jul 2026 11:41:32 -0700 Subject: [PATCH 1/3] fix(app): filter non-numeric characters out of the font-size draft --- app/src/modals.test.ts | 41 ++++++++++++++++++++++++++++++++++++++++- app/src/modals.tsx | 25 ++++++++++++++++++++++++- 2 files changed, 64 insertions(+), 2 deletions(-) diff --git a/app/src/modals.test.ts b/app/src/modals.test.ts index 3b7f33d..a2e0271 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 } from "./modals"; function esc(overrides: Partial<{ isComposing: boolean; keyCode: number; defaultPrevented: boolean }> = {}) { return { @@ -32,3 +32,42 @@ 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(""); + }); +}); diff --git a/app/src/modals.tsx b/app/src/modals.tsx index 5a956e9..89c8139 100644 --- a/app/src/modals.tsx +++ b/app/src/modals.tsx @@ -399,6 +399,29 @@ 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; +} + export function SettingsModal(props: { onClose: () => void; terminalFontSize: number; @@ -467,7 +490,7 @@ export function SettingsModal(props: { max={MAX_TERMINAL_FONT_SIZE} value={fontSizeText} onChange={(e) => { - const text = e.target.value; + const text = sanitizeNumberDraft(e.target.value); setFontSizeText(text); const n = Number(text); if (text.trim() !== "" && Number.isFinite(n) && n >= MIN_TERMINAL_FONT_SIZE && n <= MAX_TERMINAL_FONT_SIZE) { From 19dde0236dd246a3ad18f590f074dcea3955d2c6 Mon Sep 17 00:00:00 2001 From: fujibee Date: Wed, 15 Jul 2026 13:52:12 -0700 Subject: [PATCH 2/3] fix(app): switch font-size input off type=number, fix IME + restore arrow-key stepping --- app/src/App.css | 14 ---------- app/src/modals.tsx | 68 ++++++++++++++++++++++++++++++++++++++++------ 2 files changed, 59 insertions(+), 23 deletions(-) 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.tsx b/app/src/modals.tsx index 89c8139..0cc345f 100644 --- a/app/src/modals.tsx +++ b/app/src/modals.tsx @@ -438,6 +438,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); @@ -484,18 +495,57 @@ export function SettingsModal(props: { From cc44ff3a6c21a03516f8fff3dadda430eff2fce7 Mon Sep 17 00:00:00 2001 From: fujibee Date: Wed, 15 Jul 2026 13:55:43 -0700 Subject: [PATCH 3/3] fix(app): don't hijack IME candidate navigation in font-size arrow-key stepping --- app/src/modals.test.ts | 30 +++++++++++++++++++++++++++++- app/src/modals.tsx | 37 ++++++++++++++++++++++++++++++++----- 2 files changed, 61 insertions(+), 6 deletions(-) diff --git a/app/src/modals.test.ts b/app/src/modals.test.ts index a2e0271..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 { sanitizeNumberDraft, shouldCloseOnEscape } from "./modals"; +import { sanitizeNumberDraft, shouldCloseOnEscape, stepFontSize } from "./modals"; function esc(overrides: Partial<{ isComposing: boolean; keyCode: number; defaultPrevented: boolean }> = {}) { return { @@ -71,3 +71,31 @@ describe("sanitizeNumberDraft", () => { 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 0cc345f..367c0ff 100644 --- a/app/src/modals.tsx +++ b/app/src/modals.tsx @@ -422,6 +422,25 @@ export function sanitizeNumberDraft(raw: string): string { 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; @@ -540,12 +559,20 @@ export function SettingsModal(props: { // type="number" gave ArrowUp/ArrowDown stepping for free; // type="text" doesn't, so re-implement it (step 1, clamped). if (e.key !== "ArrowUp" && e.key !== "ArrowDown") return; + // Never hijack IME candidate navigation — during composition, + // ArrowUp/ArrowDown move the IME's own conversion-candidate + // selection, not this field's value. isComposingFontSize.current + // and e.nativeEvent.isComposing cover the standard case; + // keyCode 229 is the legacy fallback some engines still report + // for a composition keydown where isComposing isn't reliably set. + if (isComposingFontSize.current || e.nativeEvent.isComposing || e.keyCode === 229) return; e.preventDefault(); - const current = Number(fontSizeText); - const base = Number.isFinite(current) ? current : props.terminalFontSize; - const next = e.key === "ArrowUp" ? base + 1 : base - 1; - const clamped = Math.min(MAX_TERMINAL_FONT_SIZE, Math.max(MIN_TERMINAL_FONT_SIZE, next)); - commitFontSizeText(String(clamped)); + const direction = e.key === "ArrowUp" ? 1 : -1; + commitFontSizeText( + String( + stepFontSize(fontSizeText, props.terminalFontSize, direction, MIN_TERMINAL_FONT_SIZE, MAX_TERMINAL_FONT_SIZE), + ), + ); }} />