feat(FN-1025): harden mobile keyboard-open layout for terminal modal

- Add CSS rules to properly handle viewport height when mobile virtual keyboard is open
- Add regression tests for keyboard-open overlap detection
- Add TerminalModal test coverage for keyboard-related height contract
- Document mobile keyboard layout behavior in dashboard README
This commit is contained in:
gsxdsm
2026-04-05 23:26:59 -07:00
parent 2a3a320d19
commit 3cd8f8ed54
4 changed files with 230 additions and 0 deletions

View File

@@ -126,6 +126,7 @@ Access a fully functional PTY (pseudo-terminal) shell directly from the dashboar
- **Scrollback Buffer**: 5KB of scrollback history with replay on reconnect
- **Reconnection Support**: Automatic reconnect with exponential backoff if connection drops
- **Reliable Prompt Delivery**: Initial shell prompt visible through first keyst press
- **Mobile Keyboard Support**: On mobile devices, the terminal modal automatically adjusts when the on-screen keyboard opens, constraining its height to fit entirely above the keyboard. This ensures the status bar and terminal content remain visible and interactive without bottom overlap. Protected by regression tests for both CSS contract and component behavior.
- **Keyboard Shortcuts**:
- `Ctrl+C` - Send SIGINT to process (copy if text selected)
- `Ctrl+V` - Paste from clipboard

View File

@@ -0,0 +1,163 @@
import { describe, it, expect } from "vitest";
import { readFileSync } from "fs";
import { resolve } from "path";
/**
* CSS contract tests for the terminal modal mobile keyboard-open layout.
*
* These tests parse the compiled CSS to assert that the keyboard-open
* selector includes all three declarations needed to fully constrain
* the modal height above the on-screen keyboard:
*
* 1. min-height: auto — neutralizes inherited desktop min-height (90vh)
* 2. height: <expr> — sets exact height to visual viewport
* 3. max-height: <expr> — caps height at visual viewport
*
* Without any one of these, the modal can extend below the keyboard.
*/
const css = readFileSync(resolve(__dirname, "../styles.css"), "utf-8");
describe("terminal mobile keyboard layout CSS contract", () => {
// Extract the mobile @media block
const mediaMatch = css.match(
/@media\s*\([^)]*max-width:\s*768px[^)]*\)\s*\{/,
);
const mediaStart = mediaMatch ? css.indexOf(mediaMatch[0]) : -1;
// The keyboard-open selector is nested inside the mobile @media block.
// Find it within the CSS text.
const keyboardOpenSelectorPattern =
/\.terminal-modal\[style\*="--keyboard-overlap"\]/;
/**
* Helper: find the rule block for the keyboard-open selector inside the
* terminal modal's mobile media query. Returns the declarations block text.
*/
function findKeyboardOpenRule(): string {
const searchFrom = terminalMediaStart >= 0 ? terminalMediaStart : 0;
const selectorMatch = css
.slice(searchFrom)
.match(
new RegExp(
keyboardOpenSelectorPattern.source +
/\s*\{([^}]*)\}/.source,
),
);
return selectorMatch?.[1] ?? "";
}
/**
* Find the terminal-modal mobile @media block. The terminal modal mobile
* responsive section starts with a comment "=== Terminal Modal Mobile Responsive ===".
*/
const terminalMobileComment = "Terminal Modal Mobile Responsive";
const terminalMediaStart = css.indexOf(terminalMobileComment);
// The shared expression for height and max-height
const viewportExpression =
"var(--vv-height, calc(100dvh - var(--keyboard-overlap, 0px)))";
it("keyboard-open selector exists inside mobile @media block", () => {
expect(mediaStart).toBeGreaterThanOrEqual(0);
const afterMedia = css.slice(mediaStart);
expect(afterMedia).toMatch(keyboardOpenSelectorPattern);
});
it("keyboard-open selector includes min-height: auto", () => {
const ruleBody = findKeyboardOpenRule();
expect(ruleBody).toContain("min-height: auto");
});
it("keyboard-open selector includes height with viewport/overlap expression", () => {
const ruleBody = findKeyboardOpenRule();
// height must use the same expression as max-height
expect(ruleBody).toContain(`height: ${viewportExpression}`);
});
it("keyboard-open selector includes max-height with viewport/overlap expression", () => {
const ruleBody = findKeyboardOpenRule();
expect(ruleBody).toContain(`max-height: ${viewportExpression}`);
});
it("height and max-height use the identical expression", () => {
const ruleBody = findKeyboardOpenRule();
// Count occurrences of the expression — should appear exactly twice
const occurrences = ruleBody.split(viewportExpression).length - 1;
expect(occurrences).toBe(2);
});
it("keyboard-open selector appears after the base mobile .terminal-modal rule", () => {
// The keyboard-open rule should override the base mobile rule,
// so it must appear later in the stylesheet.
const afterSection = css.slice(terminalMediaStart);
const baseRuleMatch = afterSection.match(/^\s+\.terminal-modal\s*\{/m);
const keyboardMatch = afterSection.match(keyboardOpenSelectorPattern);
expect(baseRuleMatch).not.toBeNull();
expect(keyboardMatch).not.toBeNull();
const basePos = afterSection.indexOf(baseRuleMatch![0]);
const keyboardPos = afterSection.indexOf(keyboardMatch![0]);
expect(keyboardPos).toBeGreaterThan(basePos);
});
describe("base mobile .terminal-modal rule", () => {
/**
* Extract the .terminal-modal rule inside the terminal modal's mobile
* @media block. This is the indented `.terminal-modal {` that appears
* after the "Terminal Modal Mobile Responsive" comment.
*/
function findMobileTerminalModalRule(): string {
const searchFrom = terminalMediaStart >= 0 ? terminalMediaStart : 0;
const afterSection = css.slice(searchFrom);
// Match the first indented .terminal-modal { ... } in this section
const match = afterSection.match(
/^\s+\.terminal-modal\s*\{([^}]*)\}/m,
);
return match?.[1] ?? "";
}
it("sets width: 100% on mobile", () => {
const ruleBody = findMobileTerminalModalRule();
expect(ruleBody).toContain("width: 100%");
});
it("sets height: 100dvh on mobile", () => {
const ruleBody = findMobileTerminalModalRule();
expect(ruleBody).toContain("height: 100dvh");
});
it("sets max-height: 100dvh on mobile", () => {
const ruleBody = findMobileTerminalModalRule();
expect(ruleBody).toContain("max-height: 100dvh");
});
});
describe("desktop .terminal-modal base rule", () => {
/**
* Extract the desktop .terminal-modal rule (top-level, not inside any
* @media block). This is the first .terminal-modal { ... } in the file
* that is not indented (i.e., not nested inside a media query).
*/
function findDesktopTerminalModalRule(): string {
// Match a top-level .terminal-modal { ... } (not indented)
// Use multiline with ^ to match start-of-line
const match = css.match(/^\.terminal-modal\s*\{([^}]*)\}/m);
return match?.[1] ?? "";
}
it("has min-height: 90vh on desktop", () => {
const ruleBody = findDesktopTerminalModalRule();
expect(ruleBody).toContain("min-height: 90vh");
});
it("has max-height: 90vh on desktop", () => {
const ruleBody = findDesktopTerminalModalRule();
expect(ruleBody).toContain("max-height: 90vh");
});
});
});

View File

@@ -2571,4 +2571,60 @@ describe("TerminalModal — FN-872 real-device keyboard overlap refinement", ()
expect(modal.style.getPropertyValue("--keyboard-overlap")).toBe("167px");
});
});
/**
* Regression (FN-1025): terminal moves up when keyboard is open but not
* high enough — bottom still overlapped.
*
* The root cause was that the CSS only set max-height (not height) in the
* keyboard-open selector, and the inherited min-height: 90vh from desktop
* prevented the modal from shrinking to fit above the keyboard.
*
* This test verifies the component correctly sets BOTH --keyboard-overlap
* and --vv-height CSS variables so the CSS contract can constrain the modal
* to the visual viewport height (via height + max-height + min-height: auto).
*/
it("FN-1025: sets both --keyboard-overlap and --vv-height for partial overlap (moves up but still overlapped)", async () => {
// Simulate a keyboard that partially covers the terminal — the classic
// "moves up but not enough" scenario. Overlap of 150px on a 667px screen
// means the modal should shrink to 517px (vv.height).
const { listeners, mockVV } = simulateChromeAndroid(150);
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
await waitFor(() => {
const modal = screen.getByTestId("terminal-modal");
// --keyboard-overlap must be set so the CSS selector matches
expect(modal.style.getPropertyValue("--keyboard-overlap")).toBe("150px");
// --vv-height must be set so height/max-height resolve correctly
// vv.height = 667 - 150 = 517
expect(modal.style.getPropertyValue("--vv-height")).toBe("517px");
});
});
it("FN-1025: updates both CSS variables when keyboard height changes", async () => {
const { listeners, mockVV } = simulateChromeAndroid(150);
render(<TerminalModal isOpen={true} onClose={mockOnClose} />);
await waitFor(() => {
const modal = screen.getByTestId("terminal-modal");
expect(modal.style.getPropertyValue("--keyboard-overlap")).toBe("150px");
expect(modal.style.getPropertyValue("--vv-height")).toBe("517px");
});
// Keyboard grows taller: overlap increases from 150 to 300
Object.defineProperty(mockVV, "height", { value: 367, writable: true, configurable: true });
act(() => {
for (const cb of listeners.resize) cb();
});
await waitFor(() => {
const modal = screen.getByTestId("terminal-modal");
// overlap = 667 - 367 = 300
expect(modal.style.getPropertyValue("--keyboard-overlap")).toBe("300px");
expect(modal.style.getPropertyValue("--vv-height")).toBe("367px");
});
});
});

View File

@@ -7881,6 +7881,16 @@ body {
works uniformly across Chrome Android and iOS Safari.
Falls back to 100dvh - overlap when --vv-height is not available. */
.terminal-modal[style*="--keyboard-overlap"] {
/* Neutralize inherited desktop min-height (90vh) so the modal can
shrink below the full-viewport mobile default when the keyboard
is open. Without this, min-height keeps the modal taller than
the available space and the bottom overlaps the keyboard. */
min-height: auto;
/* Apply both height and max-height so the modal is *exactly* the
visual viewport height — not just capped at it. Using height
ensures the element cannot be taller than max-height due to
inherited min-height or flex layout. */
height: var(--vv-height, calc(100dvh - var(--keyboard-overlap, 0px)));
max-height: var(--vv-height, calc(100dvh - var(--keyboard-overlap, 0px)));
}
}