feat: dock mobile keybar to bottom of screen with larger touch targets

The mobile keybar was a small floating overlay that obscured terminal
content. Now it's a full-width bar docked to the bottom using flexbox
layout, so the terminal shrinks to accommodate it. Buttons use 44px
height (Apple HIG recommended touch target) and evenly fill the width.
Also uses 100dvh for correct mobile viewport sizing.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-08 04:06:45 -04:00
parent 1abc0d04dc
commit 3e0c9f87c8
5 changed files with 83 additions and 114 deletions
+56
View File
@@ -0,0 +1,56 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## What is webterm
A Go server that exposes terminal sessions over HTTP/WebSocket, with a dashboard mode for multiple sessions showing live-updating terminal tiles. Uses Ghostty WebAssembly for terminal rendering in the browser.
## Build Commands (always use Makefile)
Never run raw `go test`, `go vet`, or `bun` commands directly.
- `make check` — lint + tests + coverage (run before and after changes)
- `make race` — run Go tests with race detector
- `make test` — run Go tests only
- `make lint` — run `go vet`
- `make format` — run `gofmt`
- `make fuzz` — run all fuzz targets briefly
- `make build` — build frontend (TypeScript typecheck + bun bundle)
- `make build-fast` — build frontend without typecheck
- `make build-go` — build Go CLI binary to `bin/webterm`
- `make build-all` — full reproducible build from scratch
- `make bundle-watch` — watch mode for frontend development
- `make install-dev` — install Go and frontend dependencies
- `make bump-patch` — bump patch version, commit, and tag
Run `go run ./cmd/webterm` to start the server on port 8080.
## Architecture
**Go backend** (`webterm/` package): HTTP/WebSocket server with PTY-backed terminal sessions.
- `cli.go` — CLI flag parsing and startup orchestration
- `server.go` — HTTP router, WebSocket handler, SSE events, screenshot cache
- `session.go``Session` interface and `SessionConnector` interface for bidirectional session I/O
- `terminal_session.go``TerminalSession`: PTY-backed local session using `creack/pty`
- `docker_exec_session.go` — Docker exec-based sessions (attach to running containers)
- `session_manager.go` — manages session lifecycle (create, lookup, cleanup)
- `docker_watcher.go` / `docker_http.go` — Docker API integration for auto-discovering containers with `webterm-command` labels
- `replay.go` — ring buffer that captures terminal output for reconnect replay
- `internal/terminalstate/tracker.go` — VT state tracker using `go-te` for generating screenshots
- `svg_exporter.go` / `png_exporter.go` — render terminal state to SVG/PNG for dashboard thumbnails
- `config.go` — YAML manifest parsing for landing/compose modes
**Frontend** (`webterm/static/js/terminal.ts`): Single TypeScript file bundled with bun. Uses `ghostty-web` WASM for terminal rendering. Handles WebSocket connection, reconnect, virtual keyboard, theme/font controls.
**Static assets** are embedded into the Go binary via `assets_embed.go` (`go:embed`). Override at runtime with `WEBTERM_STATIC_PATH`.
**Entry point**: `cmd/webterm/main.go` calls `webterm.RunCLI()`.
## Key Patterns
- Sessions implement the `Session` interface; two implementations: `TerminalSession` (local PTY) and `DockerExecSession`
- The `SessionConnector` interface decouples session I/O from the WebSocket layer
- Docker integration uses raw HTTP against the Docker socket (no Docker SDK dependency)
- Version is read from the `VERSION` file and injected via `-ldflags` at build time
+1 -1
View File
@@ -13,7 +13,7 @@
}, },
}, },
"packages": { "packages": {
"ghostty-web": ["ghostty-web@github:rcarmo/ghostty-web#fcc47d4", {}, "rcarmo-ghostty-web-fcc47d4"], "ghostty-web": ["ghostty-web@github:rcarmo/ghostty-web#fcc47d4", {}, "rcarmo-ghostty-web-fcc47d4", "sha512-tq0cFciI32VTyOXDoLHQQDndeA6jhFuZ/3TWYx3VlYDzRhYkWAtTBi6t29isYPzdiKNIWggjkn3Ve/+Qub/wBg=="],
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
} }
+1 -1
View File
@@ -1775,7 +1775,7 @@ func (s *LocalServer) handleRoot(w http.ResponseWriter, r *http.Request) {
escapedFont := strings.ReplaceAll(fontFamily, `"`, "&quot;") escapedFont := strings.ReplaceAll(fontFamily, `"`, "&quot;")
dataAttrs := fmt.Sprintf(`data-session-websocket-url="%s" data-session-route-key="%s" data-session-name="%s" data-font-size="%d" data-scrollback="1000" data-theme="%s" data-font-family="%s"`, htmlAttrEscape(wsURL), htmlAttrEscape(routeKey), htmlAttrEscape(app.Name), s.fontSize, htmlAttrEscape(theme), escapedFont) dataAttrs := fmt.Sprintf(`data-session-websocket-url="%s" data-session-route-key="%s" data-session-name="%s" data-font-size="%d" data-scrollback="1000" data-theme="%s" data-font-family="%s"`, htmlAttrEscape(wsURL), htmlAttrEscape(routeKey), htmlAttrEscape(app.Name), s.fontSize, htmlAttrEscape(theme), escapedFont)
cacheBust := "?v=" + Version cacheBust := "?v=" + Version
page := fmt.Sprintf(`<!DOCTYPE html><html><head><meta charset="utf-8"><title>%s</title><link rel="stylesheet" href="/static/monospace.css%s"><style>html,body{width:100%%;height:100%%}body{background:%s;margin:0;padding:0;overflow:hidden;font-family:var(--webterm-mono)}.webterm-terminal{width:100%%;height:100%%;display:block;overflow:hidden}</style></head><body><div id="terminal" class="webterm-terminal" %s></div><script type="module" src="/static/js/terminal.js%s"></script></body></html>`, htmlEscape(app.Name), cacheBust, themeBG, dataAttrs, cacheBust) page := fmt.Sprintf(`<!DOCTYPE html><html><head><meta charset="utf-8"><title>%s</title><link rel="stylesheet" href="/static/monospace.css%s"><style>html,body{width:100%%;height:100%%}body{background:%s;margin:0;padding:0;overflow:hidden;font-family:var(--webterm-mono);display:flex;flex-direction:column;height:100vh;height:100dvh}.webterm-terminal{width:100%%;flex:1;min-height:0;display:block;overflow:hidden}</style></head><body><div id="terminal" class="webterm-terminal" %s></div><script type="module" src="/static/js/terminal.js%s"></script></body></html>`, htmlEscape(app.Name), cacheBust, themeBG, dataAttrs, cacheBust)
w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Cache-Control", "no-cache") w.Header().Set("Cache-Control", "no-cache")
_, _ = io.WriteString(w, page) _, _ = io.WriteString(w, page)
File diff suppressed because one or more lines are too long
+10 -79
View File
@@ -1222,12 +1222,11 @@ class WebTerminal {
); );
} }
/** Setup draggable mobile extended keyboard bar */ /** Setup bottom-docked mobile extended keyboard bar */
private setupMobileKeybar(): void { private setupMobileKeybar(): void {
const keybar = document.createElement("div"); const keybar = document.createElement("div");
keybar.className = "mobile-keybar"; keybar.className = "mobile-keybar";
keybar.innerHTML = ` keybar.innerHTML = `
<button class="keybar-drag" title="Drag to move">⋮⋮</button>
<button data-key="\\x1b" title="Escape">Esc</button> <button data-key="\\x1b" title="Escape">Esc</button>
<button data-modifier="ctrl" title="Ctrl modifier">Ctrl</button> <button data-modifier="ctrl" title="Ctrl modifier">Ctrl</button>
<button data-modifier="alt" title="Alt modifier">Alt</button> <button data-modifier="alt" title="Alt modifier">Alt</button>
@@ -1238,37 +1237,33 @@ class WebTerminal {
<button data-key="\\x1b[B" title="Down">↓</button> <button data-key="\\x1b[B" title="Down">↓</button>
<button data-key="\\x1b[D" title="Left">←</button> <button data-key="\\x1b[D" title="Left">←</button>
<button data-key="\\x1b[C" title="Right">→</button> <button data-key="\\x1b[C" title="Right">→</button>
<button data-key="\\x0d" title="Return" class="keybar-return">⏎</button> <button data-key="\\x0d" title="Return">⏎</button>
`; `;
// Inject styles // Inject styles
const style = document.createElement("style"); const style = document.createElement("style");
style.textContent = ` style.textContent = `
.mobile-keybar { .mobile-keybar {
position: fixed; display: flex;
bottom: 80px; flex-wrap: wrap;
right: 0;
display: grid;
grid-template-columns: repeat(6, auto);
gap: 4px; gap: 4px;
padding: 6px; padding: 6px;
background: rgba(40, 40, 40, 0.95); background: rgba(40, 40, 40, 0.95);
border-radius: 8px 0 0 8px; flex-shrink: 0;
box-shadow: 0 2px 10px rgba(0,0,0,0.3);
z-index: 10000;
touch-action: none; touch-action: none;
user-select: none; user-select: none;
-webkit-user-select: none; -webkit-user-select: none;
} }
.mobile-keybar button { .mobile-keybar button {
min-width: 36px; flex: 1 1 0;
height: 32px; min-width: 0;
padding: 0 8px; height: 44px;
padding: 0 4px;
border: 1px solid #555; border: 1px solid #555;
border-radius: 4px; border-radius: 4px;
background: #333; background: #333;
color: #eee; color: #eee;
font-size: 13px; font-size: 15px;
font-family: system-ui, sans-serif; font-family: system-ui, sans-serif;
cursor: pointer; cursor: pointer;
touch-action: manipulation; touch-action: manipulation;
@@ -1280,19 +1275,6 @@ class WebTerminal {
background: #0066cc; background: #0066cc;
border-color: #0088ff; border-color: #0088ff;
} }
.mobile-keybar .keybar-drag {
min-width: 24px;
padding: 0 4px;
cursor: grab;
color: #888;
}
.mobile-keybar .keybar-drag:active {
cursor: grabbing;
}
.mobile-keybar .keybar-return {
grid-column: 6;
grid-row: 2;
}
`; `;
document.head.appendChild(style); document.head.appendChild(style);
this.mobileKeybarStyle = style; this.mobileKeybarStyle = style;
@@ -1377,57 +1359,6 @@ class WebTerminal {
}); });
}); });
// Setup drag functionality
this.setupKeybarDrag(keybar);
}
/** Make the keybar draggable */
private setupKeybarDrag(keybar: HTMLElement): void {
const dragHandle = keybar.querySelector(".keybar-drag") as HTMLElement;
if (!dragHandle) return;
let isDragging = false;
let startX = 0;
let startY = 0;
let startRight = 0;
let startBottom = 0;
const onTouchStart = (e: TouchEvent) => {
if (e.touches.length !== 1) return;
isDragging = true;
const touch = e.touches[0];
startX = touch.clientX;
startY = touch.clientY;
const rect = keybar.getBoundingClientRect();
startRight = window.innerWidth - rect.right;
startBottom = window.innerHeight - rect.bottom;
e.preventDefault();
};
const onTouchMove = (e: TouchEvent) => {
if (!isDragging || e.touches.length !== 1) return;
const touch = e.touches[0];
const deltaX = startX - touch.clientX;
const deltaY = startY - touch.clientY;
const newRight = Math.max(0, Math.min(window.innerWidth - 100, startRight + deltaX));
const newBottom = Math.max(0, Math.min(window.innerHeight - 50, startBottom + deltaY));
keybar.style.right = `${newRight}px`;
keybar.style.bottom = `${newBottom}px`;
e.preventDefault();
};
const onTouchEnd = () => {
isDragging = false;
};
this.addTrackedListener(dragHandle, "touchstart", onTouchStart as EventListener, { passive: false });
this.addTrackedListener(document, "touchmove", onTouchMove as EventListener, { passive: false });
this.addTrackedListener(document, "touchend", onTouchEnd as EventListener);
} }
/** Deactivate all modifiers */ /** Deactivate all modifiers */