breakglass UI: mobile-first rework (chat input was hidden on phones)
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful

Mobile is the primary client for fixing the devvm, but the first cut was
desktop-first and the chat input was unreachable on a phone:
- Root cause: the shell used height:100%/100vh, so on mobile browsers the
  composer at the bottom sat behind the address/tool bar — you saw the VM
  buttons but no place to type. Switched #app to 100dvh (dynamic viewport
  height) with a 100vh fallback; body no longer scrolls (chat scrolls
  internally), killing iOS rubber-banding.
- Layout is now mobile-first single-column: the chat fills the screen with
  the composer pinned at the bottom and always visible. The VM power controls
  moved into a slide-up bottom sheet behind a compact " VM" header button
  (backdrop + close + grab handle). At ≥900px the sheet becomes a static side
  column again and the toggle is hidden — desktop unchanged.
- Touch targets ≥40px; composer textarea bumped to 16px so iOS Safari doesn't
  auto-zoom on focus (which itself shoved the composer out of view).

Verified at 390×844 (iPhone) and 1280×800 via Playwright: input box renders at
y=723–821 (inside the 844 viewport), sheet slides in on tap, desktop keeps the
2-column side panel.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Viktor Barzin 2026-06-12 23:11:46 +00:00
parent 4f361d91eb
commit aa054cac3f
7 changed files with 194 additions and 95 deletions

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -6,8 +6,8 @@
<meta name="color-scheme" content="dark" />
<meta name="robots" content="noindex, nofollow" />
<title>devvm breakglass</title>
<script type="module" crossorigin src="./assets/index-DNECe1Jo.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-DKeuidum.css">
<script type="module" crossorigin src="./assets/index-DFUUDy82.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-B_5XZQm9.css">
</head>
<body>
<div id="app"></div>

View file

@ -5,13 +5,14 @@
import VmControls from './VmControls.svelte';
// ── session lifecycle ────────────────────────────────────────────────────
// sessionId is the id we POST with. The backend also reports an authoritative
// id in the first {kind:"session"} frame of a turn; Chat bubbles that up so
// the rail always shows what the agent is actually resuming.
let sessionId = $state('');
let sessionState = $state('connecting'); // connecting | ready | error
let sessionError = $state('');
let streaming = $state(false); // a chat turn is in flight (drives the rail dot)
let streaming = $state(false);
// Mobile: the VM controls live in a slide-up sheet. Desktop: a side column
// (CSS hides the toggle and pins the sheet open as a column ≥900px).
let showControls = $state(false);
async function newSession() {
sessionState = 'connecting';
@ -27,7 +28,6 @@
onMount(newSession);
// Chat reports the live session id from the stream's session frame.
function onLiveSession(id) {
if (id) sessionId = id;
}
@ -43,55 +43,75 @@
<div class="rail-title">
<span class="glyph" aria-hidden="true">🔧</span>
<h1>devvm <span class="accent">breakglass</span></h1>
<span class="rail-tag">emergency recovery</span>
</div>
<div class="rail-status">
<span class="dot dot--{dotState}" aria-hidden="true"></span>
<span class="rail-session">
<div class="rail-right">
<span class="rail-status">
<span class="dot dot--{dotState}" aria-hidden="true"></span>
{#if sessionState === 'error'}
<span class="session-bad">session unavailable</span>
<span class="session-bad">offline</span>
{:else if sessionState === 'connecting'}
<span class="session-meta">opening session</span>
<span class="session-meta">connecting</span>
{:else}
<span class="session-label">session</span>
<code class="session-id" title={sessionId}>{shortId}</code>
{#if streaming}<span class="session-meta">· agent working</span>{/if}
{/if}
</span>
<!-- Mobile-only: open the VM control sheet. Hidden on desktop (column). -->
<button
class="controls-toggle"
onclick={() => (showControls = true)}
aria-label="Open direct VM controls"
>
<span class="controls-toggle-label">VM</span>
</button>
<button
class="new-session"
onclick={newSession}
disabled={streaming || sessionState === 'connecting'}
title={streaming ? 'wait for the current turn to finish' : 'start a fresh session'}
>
New session
New
</button>
</div>
</header>
{#if sessionState === 'error'}
<div class="rail-error" role="alert">
Could not reach the breakglass backend — {sessionError}. The cluster or
network may be down. The manual VM controls below still work independently
of the chat agent.
Can't reach the breakglass backend — {sessionError}. The cluster or network
may be down. The <strong>⚡ VM</strong> power controls still work without the chat.
</div>
{/if}
<main class="grid">
<section class="col col--chat" aria-label="Recovery chat">
<main class="stage">
<section class="chat-pane" aria-label="Recovery chat">
<Chat
{sessionId}
sessionReady={sessionState === 'ready'}
onLiveSession={onLiveSession}
{onLiveSession}
onStreamingChange={(v) => (streaming = v)}
/>
</section>
<aside class="col col--controls" aria-label="Direct VM control">
<aside class="controls-pane" class:open={showControls} aria-label="Direct VM control">
<div class="sheet-grip" aria-hidden="true"></div>
<div class="controls-head">
<span class="controls-head-title">Direct VM control</span>
<button class="sheet-close" onclick={() => (showControls = false)} aria-label="Close VM controls"></button>
</div>
<VmControls />
</aside>
</main>
<!-- backdrop behind the mobile sheet -->
<button
class="sheet-backdrop"
class:show={showControls}
onclick={() => (showControls = false)}
tabindex="-1"
aria-hidden="true"
></button>
</div>
<style>
@ -101,93 +121,73 @@
flex-direction: column;
max-width: 1500px;
margin: 0 auto;
padding: 0 18px 18px;
}
/* ── status rail ─────────────────────────────────────────────────────── */
/* ── status rail (compact, single row on mobile) ─────────────────────── */
.rail {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
flex-wrap: wrap;
padding: 16px 4px 14px;
gap: 10px;
padding: 10px 14px;
border-bottom: 1px solid var(--line);
flex: none;
}
.rail-title {
display: flex;
align-items: baseline;
gap: 12px;
gap: 9px;
min-width: 0;
}
.glyph {
font-size: 19px;
font-size: 17px;
transform: translateY(2px);
filter: saturate(0.85);
}
h1 {
margin: 0;
font-family: var(--mono);
font-size: 19px;
font-size: 16px;
font-weight: 600;
letter-spacing: 0.02em;
color: var(--ink);
white-space: nowrap;
}
.accent {
color: var(--cyan);
text-shadow: 0 0 18px rgba(61, 209, 214, 0.35);
}
.rail-tag {
font-family: var(--mono);
font-size: 10.5px;
text-transform: uppercase;
letter-spacing: 0.22em;
color: var(--ink-faint);
border: 1px solid var(--line-strong);
border-radius: 999px;
padding: 3px 9px;
}
.rail-status {
.rail-right {
display: flex;
align-items: center;
gap: 14px;
font-family: var(--mono);
font-size: 13px;
gap: 8px;
flex: none;
}
.rail-session {
.rail-status {
display: inline-flex;
align-items: baseline;
align-items: center;
gap: 7px;
white-space: nowrap;
}
.session-label {
color: var(--ink-faint);
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.16em;
font-family: var(--mono);
font-size: 12px;
}
.session-id {
color: var(--cyan);
font-family: var(--mono);
letter-spacing: 0.04em;
}
.session-meta {
color: var(--amber);
font-size: 12px;
}
.session-bad {
color: var(--danger-bright);
}
/* connection lamp */
.dot {
width: 9px;
height: 9px;
border-radius: 50%;
flex: none;
background: var(--ink-faint);
box-shadow: 0 0 0 0 transparent;
}
.dot--ready {
background: var(--cyan);
@ -203,28 +203,33 @@
background: var(--danger);
box-shadow: 0 0 10px 1px var(--danger-glow);
}
@keyframes breathe {
0%, 100% { opacity: 0.55; }
50% { opacity: 1; }
}
@keyframes breathe { 0%, 100% { opacity: 0.55; } 50% { opacity: 1; } }
@keyframes pulse {
0%, 100% { transform: scale(0.82); opacity: 0.7; }
50% { transform: scale(1.15); opacity: 1; }
}
/* touch-friendly buttons */
.controls-toggle,
.new-session {
min-height: 40px;
padding: 0 13px;
border-radius: var(--radius-sm);
border: 1px solid var(--line-strong);
background: var(--bg-2);
color: var(--ink-dim);
border: 1px solid var(--line-strong);
border-radius: var(--radius-sm);
padding: 7px 13px;
font-size: 12px;
font-size: 13px;
letter-spacing: 0.02em;
transition: border-color 0.15s, color 0.15s, background 0.15s;
display: inline-flex;
align-items: center;
gap: 5px;
}
.new-session:hover:not(:disabled) {
border-color: var(--cyan-dim);
color: var(--ink);
.controls-toggle {
border-color: #5a4a2a;
color: var(--amber);
}
.controls-toggle:active,
.new-session:active {
background: var(--bg-3);
}
.new-session:disabled {
@ -232,7 +237,7 @@
}
.rail-error {
margin: 12px 0 0;
margin: 10px 12px 0;
padding: 11px 14px;
border: 1px solid var(--danger-deep);
border-left-width: 3px;
@ -241,32 +246,117 @@
border-radius: var(--radius-sm);
font-size: 13px;
line-height: 1.5;
flex: none;
}
/* ── layout ──────────────────────────────────────────────────────────── */
.grid {
/* ── stage ───────────────────────────────────────────────────────────── */
.stage {
flex: 1;
min-height: 0;
display: grid;
grid-template-columns: minmax(0, 1fr) 376px;
gap: 18px;
padding-top: 16px;
display: flex;
min-width: 0;
padding: 10px;
}
.col {
.chat-pane {
flex: 1;
min-height: 0;
min-width: 0;
display: flex;
flex-direction: column;
}
@media (max-width: 940px) {
.grid {
grid-template-columns: 1fr;
grid-auto-rows: minmax(0, auto);
overflow: auto;
/* ── VM controls: a slide-up bottom sheet on mobile ──────────────────── */
.controls-pane {
position: fixed;
left: 0;
right: 0;
bottom: 0;
z-index: 40;
max-height: 86dvh;
overflow-y: auto;
background: var(--bg-1);
border-top: 1px solid var(--line-strong);
border-radius: 16px 16px 0 0;
box-shadow: 0 -18px 40px rgba(0, 0, 0, 0.55);
padding: 8px 14px calc(14px + env(safe-area-inset-bottom));
transform: translateY(101%);
transition: transform 0.26s cubic-bezier(0.32, 0.72, 0, 1);
}
.controls-pane.open {
transform: translateY(0);
}
.sheet-grip {
width: 38px;
height: 4px;
border-radius: 99px;
background: var(--line-strong);
margin: 4px auto 10px;
}
.controls-head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 10px;
}
.controls-head-title {
font-family: var(--mono);
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.2em;
color: var(--amber);
}
.sheet-close {
width: 34px;
height: 34px;
border-radius: var(--radius-sm);
border: 1px solid var(--line-strong);
background: var(--bg-2);
color: var(--ink-dim);
font-size: 14px;
}
.sheet-backdrop {
position: fixed;
inset: 0;
z-index: 30;
border: 0;
padding: 0;
background: rgba(0, 0, 0, 0.55);
opacity: 0;
pointer-events: none;
transition: opacity 0.22s;
}
.sheet-backdrop.show {
opacity: 1;
pointer-events: auto;
}
/* ── desktop: controls become a static side column, sheet chrome gone ── */
@media (min-width: 900px) {
.rail {
padding: 14px 18px;
}
.col--chat {
min-height: 60vh;
h1 { font-size: 19px; }
.stage {
display: grid;
grid-template-columns: minmax(0, 1fr) 372px;
gap: 16px;
padding: 16px 18px 18px;
}
.chat-pane { display: flex; }
.controls-toggle { display: none; }
.controls-pane {
position: static;
max-height: none;
overflow: visible;
transform: none;
box-shadow: none;
border: none;
border-radius: 0;
padding: 0;
z-index: auto;
}
.sheet-grip,
.controls-head,
.sheet-backdrop { display: none; }
}
</style>

View file

@ -443,14 +443,16 @@
flex: 1;
resize: none;
max-height: 168px;
min-height: 44px;
min-height: 48px;
background: var(--bg-2);
color: var(--ink);
border: 1px solid var(--line-strong);
border-radius: var(--radius-sm);
padding: 11px 13px;
padding: 12px 13px;
font-family: var(--sans);
font-size: 14px;
/* 16px: anything smaller makes iOS Safari auto-zoom on focus (mobile is the
primary client) — the zoom then shifts the composer out of view. */
font-size: 16px;
line-height: 1.5;
outline: none;
transition: border-color 0.15s, box-shadow 0.15s;

View file

@ -55,6 +55,10 @@ html,
body {
margin: 0;
height: 100%;
/* The page itself never scrolls the chat stream scrolls internally. This
keeps the composer pinned and stops iOS rubber-banding the whole UI. */
overflow: hidden;
overscroll-behavior: none;
}
body {
@ -79,7 +83,11 @@ body {
}
#app {
height: 100%;
/* 100dvh (dynamic viewport height) NOT 100vh/100% so the composer at the
bottom is never hidden behind a mobile browser's address/tool bar. Mobile is
the primary client for this tool. 100vh is the fallback for old engines. */
height: 100vh;
height: 100dvh;
}
button {