diff --git a/README.md b/README.md
index 629d2c5..a9a8b61 100644
--- a/README.md
+++ b/README.md
@@ -1492,6 +1492,22 @@ Things I chose while building, with the reasoning:
into view as you type. No strings, so no i18n; the additive change sits on top of the merged
scene-heat rework unchanged.
+102. **An API key is not a login.** "When trying to use a different provider on the web app it
+ glitches out completely" — a photo of the first-run gate with iOS's saved-password sheet laid
+ over it ("password for proxmox…"). Two causes, both fixed. (1) The key fields were
+ `type="password"` with `autocomplete="off"`, which iOS/Safari (and 1Password/LastPass) read as
+ a login and answer with their own overlay; the gate, Settings and the provider rows now carry
+ `autoComplete="new-password"`, `data-form-type="other"`, `data-lpignore="true"`,
+ `autoCorrect`/`autoCapitalize="off"` and `inputMode="text"` — the mask stays, the password UI
+ goes. (2) The gate's keyboard handling scrolled to the *Continue* button rather than the field
+ being edited, so with the provider rows open the keyboard covered the input and the locked
+ wrapper gave no way to reach it; the wrapper stays user-scrollable, alignment now anchors on
+ the focused element (Continue is only the fallback), and the corner controls hide while the
+ keyboard is up instead of landing on the card. The providers suite drives a fake
+ `visualViewport` (shrunk to 340px, installed before the app mounts) and pins all of it: the
+ field stays in view, the card stays scrollable, the corners step aside — plus the
+ autofill-hardening attributes on the field itself.
+
101. **Frutiger Aero has its soundtrack.** Picking the theme starts a random track from the
Frutiger Aero playlist (Bigblamboii's set on SoundCloud) and draws every following track at
random too, so the set shuffles itself. The player is the credit bar from the user's mock: a
diff --git a/components/ApiKeyGate.tsx b/components/ApiKeyGate.tsx
index 3be8a8d..270b487 100644
--- a/components/ApiKeyGate.tsx
+++ b/components/ApiKeyGate.tsx
@@ -115,17 +115,22 @@ function GateScreen() {
if (window.matchMedia("(pointer: fine)").matches) inputRef.current?.focus();
}, []);
- // Keep the field and the Continue button in view while the on-screen keyboard
- // is up (the card itself is locked, so this is done by scrolling the wrapper
- // programmatically rather than by the user).
+ // Keep what the listener is actually editing in view while the on-screen
+ // keyboard is up: the focused field when there is one (the provider rows sit
+ // far below the Continue button), the button otherwise. The wrapper stays
+ // user-scrollable underneath, this just does the first move.
const alignKeyboard = useCallback(() => {
const wrap = wrapRef.current;
- const submit = submitRef.current;
- if (!wrap || !submit) return;
+ if (!wrap) return;
+ const active = document.activeElement;
+ const focused =
+ active instanceof HTMLElement && active !== wrap && wrap.contains(active) ? active : null;
+ const target = focused ?? submitRef.current;
+ if (!target) return;
const wrapBox = wrap.getBoundingClientRect();
- const btnBox = submit.getBoundingClientRect();
- const below = btnBox.bottom + 12 - wrapBox.bottom;
- const above = wrapBox.top + 12 - btnBox.top;
+ const box = target.getBoundingClientRect();
+ const below = box.bottom + 12 - wrapBox.bottom;
+ const above = wrapBox.top + 12 - box.top;
if (below > 0) wrap.scrollTop += below;
else if (above > 0) wrap.scrollTop -= above;
}, []);
@@ -205,7 +210,7 @@ function GateScreen() {
>
{/* corner controls: theme + help ---------------------------------- */}
) : null}
@@ -116,8 +119,16 @@ export function ProvidersSection() {
}}
placeholder={saved ? t("providers.keySaved") : provider.keyHint}
aria-label={`${provider.label} ${t("providers.key")}`}
- autoComplete="off"
+ /* See the gate: an API key must not raise the browser's
+ saved-password UI ("new-password" + the data hints). */
+ autoComplete="new-password"
+ autoCorrect="off"
+ autoCapitalize="off"
spellCheck={false}
+ inputMode="text"
+ enterKeyHint="done"
+ data-form-type="other"
+ data-lpignore="true"
className="min-w-0 flex-1 rounded-xl border border-[var(--hairline)] bg-[var(--surface-strong)] px-3 py-2 text-[13px] text-[var(--text)] outline-none placeholder:text-[var(--text-subtle)] focus:border-accent-400"
/>
diff --git a/package.json b/package.json
index 4103a64..0c0f673 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "freezmo",
- "version": "0.7.1",
+ "version": "0.7.2",
"private": true,
"license": "GPL-3.0-only",
"scripts": {
diff --git a/public/sw.js b/public/sw.js
index be8e4ce..0faa220 100644
--- a/public/sw.js
+++ b/public/sw.js
@@ -9,7 +9,7 @@
*
* Bumping CACHE_VERSION is how a new deploy invalidates old assets.
*/
-const CACHE_VERSION = "freezmo-v93";
+const CACHE_VERSION = "freezmo-v94";
const SHELL_CACHE = `${CACHE_VERSION}-shell`;
const ASSET_CACHE = `${CACHE_VERSION}-assets`;
diff --git a/tests/providers-test.mjs b/tests/providers-test.mjs
index 9469db4..df2cde0 100644
--- a/tests/providers-test.mjs
+++ b/tests/providers-test.mjs
@@ -245,6 +245,28 @@ try {
"freezmo.providers.v1": JSON.stringify({ keys: {}, models: {}, customBaseUrl: PROVIDER_BASE, generation: null }),
},
});
+ /* A driveable visual viewport, installed before the app mounts, so the
+ on-screen-keyboard layout can be tested without a real keyboard. */
+ await page.addInitScript(() => {
+ window.__vv = { resize: [] };
+ Object.defineProperty(window, "visualViewport", {
+ configurable: true,
+ get: () => ({
+ get height() {
+ return window.__vvHeight ?? window.innerHeight;
+ },
+ get offsetTop() {
+ return window.__vvOffset ?? 0;
+ },
+ addEventListener: (type, handler) => {
+ (window.__vv[type] ??= []).push(handler);
+ },
+ removeEventListener: (type, handler) => {
+ window.__vv[type] = (window.__vv[type] ?? []).filter((entry) => entry !== handler);
+ },
+ }),
+ });
+ });
await page.goto(`http://127.0.0.1:${GATE_PORT}/`, { waitUntil: "networkidle" });
await page.waitForTimeout(1400);
@@ -257,6 +279,66 @@ try {
const gateKey = page.locator('input[aria-label="Custom (OpenAI-compatible) API key"]');
await gateKey.scrollIntoViewIfNeeded();
+
+ /* An API key is not a login: the field must keep iOS/Safari's saved
+ password sheet away ("new-password" + the password-manager hints), or
+ the AutoFill overlay lands on top of the gate (as it did on the user's
+ phone). */
+ const hardening = await gateKey.evaluate((element) => ({
+ autocomplete: element.getAttribute("autocomplete"),
+ formType: element.getAttribute("data-form-type"),
+ lpignore: element.getAttribute("data-lpignore"),
+ inputMode: element.getAttribute("inputmode"),
+ }));
+ check(
+ "gate: the key field keeps the password UI away",
+ hardening.autocomplete === "new-password" &&
+ hardening.formType === "other" &&
+ hardening.lpignore === "true" &&
+ hardening.inputMode === "text",
+ JSON.stringify(hardening),
+ );
+
+ /* The on-screen keyboard: fake the visual viewport shrinking the way a
+ phone keyboard does, then check the layout still holds — the focused
+ field stays in view, the wrapper stays scrollable and the corner
+ controls step aside instead of landing on the card. */
+ await gateKey.focus();
+ await page.evaluate(() => {
+ window.__vvHeight = 340;
+ (window.__vv.resize ?? []).forEach((handler) => handler());
+ });
+ await page.waitForTimeout(500);
+ const keyboardLayout = await page.evaluate(() => {
+ const input = document.querySelector('input[aria-label="Custom (OpenAI-compatible) API key"]');
+ const root = document.querySelector("div[style*='height']");
+ const wrap = input?.closest("div[class*='overflow-y-auto']");
+ const corner = document.querySelector("button[aria-label='How do I get a key?']");
+ return {
+ inputBottom: input ? Math.round(input.getBoundingClientRect().bottom) : null,
+ rootHeight: root ? Math.round(root.getBoundingClientRect().height) : null,
+ wrapped: Boolean(wrap),
+ scrollable: wrap ? getComputedStyle(wrap).overflowY : null,
+ cornerVisible: corner ? corner.offsetParent !== null : false,
+ };
+ });
+ check(
+ "gate: with the keyboard up the field stays in view",
+ keyboardLayout.inputBottom !== null && keyboardLayout.rootHeight !== null && keyboardLayout.inputBottom <= keyboardLayout.rootHeight,
+ JSON.stringify(keyboardLayout),
+ );
+ check(
+ "gate: with the keyboard up the card stays scrollable",
+ keyboardLayout.wrapped && keyboardLayout.scrollable === "auto",
+ JSON.stringify(keyboardLayout),
+ );
+ check("gate: the corner controls step aside for the keyboard", keyboardLayout.cornerVisible === false, JSON.stringify(keyboardLayout));
+ await page.evaluate(() => {
+ window.__vvHeight = undefined;
+ (window.__vv.resize ?? []).forEach((handler) => handler());
+ });
+ await page.waitForTimeout(300);
+
await gateKey.fill(PROVIDER_KEY);
await gateKey.locator("xpath=..").getByRole("button", { name: "Save key", exact: true }).click();
await page.waitForTimeout(2200);