From 5d0a72399376b9ef66c79122c9e446d8275c7fe4 Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Mon, 29 Jun 2026 17:24:52 +1000 Subject: [PATCH 1/8] feat(staged): render dialogs full-screen on mobile On narrow viewports (viewport.isMobile, 768px), Dialog.Content now renders as an edge-to-edge full-screen panel instead of a centered card. Geometry (position, inset, sizing, transform, radius, 100dvh height) is applied via inline style so it cleanly overrides per-modal sizing classes without specificity fights, while the structural classes callers pass (flex/p-0/ gap-0/overflow-hidden/drag-over border) are preserved. Safe-area padding keeps the header/footer clear of notches and home indicators, and the entrance animation slides up from the bottom on mobile while keeping zoom on desktop. dialog-content now calls watchViewport() on mount so viewport.isMobile stays live even if the host screen never subscribed, and a new fullScreenOnMobile prop (default true) lets a dialog opt out. The default applies to NewSessionModal, SessionModal, NewProjectModal, AddRepoModal, RenameBranchDialog, NoteModal, ActionOutputModal and ImageViewerModal with no per-modal changes. DiffModal builds its own modal (no dialog primitives) and the alert-dialog family uses a separate content component, so confirmations stay centered and DiffModal is unaffected. Co-Authored-By: Claude Opus 4.8 Signed-off-by: Matt Toohey --- .../ui/dialog/dialog-content.svelte | 22 ++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/apps/staged/src/lib/components/ui/dialog/dialog-content.svelte b/apps/staged/src/lib/components/ui/dialog/dialog-content.svelte index 9e95d05af..f5f30adf1 100644 --- a/apps/staged/src/lib/components/ui/dialog/dialog-content.svelte +++ b/apps/staged/src/lib/components/ui/dialog/dialog-content.svelte @@ -2,11 +2,13 @@ import { Dialog as DialogPrimitive } from 'bits-ui'; import DialogPortal from './dialog-portal.svelte'; import type { Snippet } from 'svelte'; + import { onMount } from 'svelte'; import * as Dialog from './index.js'; import { cn, type WithoutChildrenOrChild } from '$lib/components/utils.js'; import type { ComponentProps } from 'svelte'; import { Button } from '$lib/components/ui/button/index.js'; import XIcon from '@lucide/svelte/icons/x'; + import { viewport, watchViewport } from '$lib/shared/viewport.svelte'; let { ref = $bindable(null), @@ -14,12 +16,26 @@ portalProps, children, showCloseButton = true, + fullScreenOnMobile = true, ...restProps }: WithoutChildrenOrChild & { portalProps?: WithoutChildrenOrChild>; children: Snippet; showCloseButton?: boolean; + fullScreenOnMobile?: boolean; } = $props(); + + // Keep `viewport.isMobile` live even when the host screen never subscribed. + onMount(() => watchViewport()); + + const fullScreen = $derived(fullScreenOnMobile && viewport.isMobile); + + // Edge-to-edge geometry applied as inline style so it cleanly overrides any + // per-modal sizing classes the caller passes (e.g. `sm:max-w-[580px]`, + // `max-h-[calc(100vh-16vh)]`) without specificity fights. Safe-area padding + // keeps the header/footer clear of notches and home indicators. + const fullScreenStyle = + 'position:fixed;inset:0;top:0;left:0;width:100%;max-width:none;height:100dvh;max-height:none;transform:none;border-radius:0;padding-top:env(safe-area-inset-top);padding-bottom:env(safe-area-inset-bottom);'; @@ -28,9 +44,13 @@ bind:ref data-slot="dialog-content" class={cn( - 'bg-card text-card-foreground data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 ring-foreground/10 grid max-w-[calc(100%-2rem)] gap-6 rounded-xl p-6 text-sm ring-1 duration-100 sm:max-w-md fixed top-1/2 left-1/2 z-(--z-index-overlay) w-full -translate-x-1/2 -translate-y-1/2 outline-none', + 'bg-card text-card-foreground data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 ring-foreground/10 grid max-w-[calc(100%-2rem)] gap-6 rounded-xl p-6 text-sm ring-1 duration-100 sm:max-w-md fixed top-1/2 left-1/2 z-(--z-index-overlay) w-full -translate-x-1/2 -translate-y-1/2 outline-none', + fullScreen + ? 'data-open:slide-in-from-bottom data-closed:slide-out-to-bottom' + : 'data-closed:zoom-out-95 data-open:zoom-in-95', className )} + style={fullScreen ? fullScreenStyle : undefined} onOpenAutoFocus={(e) => e.preventDefault()} {...restProps} > From 2db5df00a7cf931958223715878cd0ce83a1e440 Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Mon, 29 Jun 2026 17:30:00 +1000 Subject: [PATCH 2/8] feat(staged): shrink full-screen dialogs above the on-screen keyboard Part 2 of the full-screen mobile dialog work. When the soft keyboard opens, a 100dvh full-screen dialog does not shrink (the default interactive-widget=resizes-visual), so the keyboard overlays the action footer and hides the buttons. This pins the footer to the keyboard's top edge with the body scrolling above it. New src/lib/shared/keyboardInset.svelte.ts mirrors viewport.svelte.ts: it tracks window.visualViewport resize/scroll, computes the keyboard height as max(0, innerHeight - vv.height - vv.offsetTop), and publishes it both as reactive `keyboard.inset` state and as a `--keyboard-inset` CSS custom property on :root. watchKeyboardInset() wires the listeners and returns an unsubscribe that tears them down and resets the inset. When window.visualViewport is undefined (older/desktop webviews) the inset stays 0 and behaviour is unchanged. dialog-content.svelte's mobile/full-screen branch now sets height:calc(100dvh - var(--keyboard-inset, 0px)) and calls watchKeyboardInset() on mount alongside watchViewport(), cleaning up both. Existing safe-area padding is kept, so the footer clears the home indicator while sitting on the keyboard's top edge. No structural changes to the modals: NewSessionModal and SessionModal already use a header / scrollable-body / pinned-footer flex column. The prompt editor and transcript scrollers gain overscroll-behavior:contain so momentum doesn't chain to the page behind the dialog, and NewSessionModal's form-actions footer is marked flex-shrink:0 so it stays pinned. Co-Authored-By: Claude Opus 4.8 Signed-off-by: Matt Toohey --- .../ui/dialog/dialog-content.svelte | 20 ++++++-- .../features/sessions/NewSessionModal.svelte | 6 +++ .../lib/features/sessions/SessionModal.svelte | 3 ++ .../src/lib/shared/keyboardInset.svelte.ts | 51 +++++++++++++++++++ 4 files changed, 76 insertions(+), 4 deletions(-) create mode 100644 apps/staged/src/lib/shared/keyboardInset.svelte.ts diff --git a/apps/staged/src/lib/components/ui/dialog/dialog-content.svelte b/apps/staged/src/lib/components/ui/dialog/dialog-content.svelte index f5f30adf1..5a90af215 100644 --- a/apps/staged/src/lib/components/ui/dialog/dialog-content.svelte +++ b/apps/staged/src/lib/components/ui/dialog/dialog-content.svelte @@ -9,6 +9,7 @@ import { Button } from '$lib/components/ui/button/index.js'; import XIcon from '@lucide/svelte/icons/x'; import { viewport, watchViewport } from '$lib/shared/viewport.svelte'; + import { watchKeyboardInset } from '$lib/shared/keyboardInset.svelte'; let { ref = $bindable(null), @@ -25,17 +26,28 @@ fullScreenOnMobile?: boolean; } = $props(); - // Keep `viewport.isMobile` live even when the host screen never subscribed. - onMount(() => watchViewport()); + // Keep `viewport.isMobile` live even when the host screen never subscribed, + // and keep `--keyboard-inset` updated so the full-screen height shrinks to + // the space above the on-screen keyboard. + onMount(() => { + const unwatchViewport = watchViewport(); + const unwatchKeyboard = watchKeyboardInset(); + return () => { + unwatchViewport(); + unwatchKeyboard(); + }; + }); const fullScreen = $derived(fullScreenOnMobile && viewport.isMobile); // Edge-to-edge geometry applied as inline style so it cleanly overrides any // per-modal sizing classes the caller passes (e.g. `sm:max-w-[580px]`, // `max-h-[calc(100vh-16vh)]`) without specificity fights. Safe-area padding - // keeps the header/footer clear of notches and home indicators. + // keeps the header/footer clear of notches and home indicators. The height + // subtracts `--keyboard-inset` so the dialog shrinks to the space above the + // on-screen keyboard, pinning its footer to the keyboard's top edge. const fullScreenStyle = - 'position:fixed;inset:0;top:0;left:0;width:100%;max-width:none;height:100dvh;max-height:none;transform:none;border-radius:0;padding-top:env(safe-area-inset-top);padding-bottom:env(safe-area-inset-bottom);'; + 'position:fixed;inset:0;top:0;left:0;width:100%;max-width:none;height:calc(100dvh - var(--keyboard-inset, 0px));max-height:none;transform:none;border-radius:0;padding-top:env(safe-area-inset-top);padding-bottom:env(safe-area-inset-bottom);'; diff --git a/apps/staged/src/lib/features/sessions/NewSessionModal.svelte b/apps/staged/src/lib/features/sessions/NewSessionModal.svelte index 04395e772..03d357d4f 100644 --- a/apps/staged/src/lib/features/sessions/NewSessionModal.svelte +++ b/apps/staged/src/lib/features/sessions/NewSessionModal.svelte @@ -792,6 +792,9 @@ line-height: 1.5; flex: 1; overflow-y: auto; + /* Keep scroll momentum inside the prompt editor so it doesn't chain to the + page behind the keyboard-shrunk full-screen dialog. */ + overscroll-behavior: contain; transition: border-color 0.15s; } @@ -807,6 +810,9 @@ justify-content: space-between; gap: 8px; margin-top: 4px; + /* Stay pinned at the bottom of the keyboard-shrunk dialog while the prompt + editor above is the only scroller. */ + flex-shrink: 0; } .form-actions-left { diff --git a/apps/staged/src/lib/features/sessions/SessionModal.svelte b/apps/staged/src/lib/features/sessions/SessionModal.svelte index e22868720..9a5fefabf 100644 --- a/apps/staged/src/lib/features/sessions/SessionModal.svelte +++ b/apps/staged/src/lib/features/sessions/SessionModal.svelte @@ -2018,6 +2018,9 @@ flex: 1; overflow-x: hidden; overflow-y: auto; + /* Keep scroll momentum inside the transcript so it doesn't chain to the + page behind the keyboard-shrunk full-screen dialog. */ + overscroll-behavior: contain; padding: 16px; min-height: 0; background: var(--bg-primary); diff --git a/apps/staged/src/lib/shared/keyboardInset.svelte.ts b/apps/staged/src/lib/shared/keyboardInset.svelte.ts new file mode 100644 index 000000000..ade65f34e --- /dev/null +++ b/apps/staged/src/lib/shared/keyboardInset.svelte.ts @@ -0,0 +1,51 @@ +// keyboardInset.svelte.ts — track the on-screen keyboard height. +// +// Exposes the height of the on-screen keyboard both as reactive state +// (`keyboard.inset`) and as a CSS custom property (`--keyboard-inset` on +// `:root`), so full-screen layouts can shrink to the space above the keyboard +// and pin their footer to its top edge. Mirrors the watch/unsubscribe pattern +// of viewport.svelte.ts. +// +// `window.visualViewport` is the source of truth (supported on iOS WebKit and +// Android). On older/desktop webviews it is undefined — the inset stays 0 and +// behaviour is unchanged. + +export const keyboard = $state({ inset: 0 }); + +let trackedViewport: VisualViewport | null = null; +let subscriberCount = 0; + +function syncKeyboardInset() { + const vv = trackedViewport; + if (!vv) return; + // The keyboard occupies the gap between the layout viewport's bottom and the + // visual viewport's bottom (its height plus however far it has scrolled down). + const inset = Math.max(0, window.innerHeight - vv.height - vv.offsetTop); + keyboard.inset = inset; + document.documentElement.style.setProperty('--keyboard-inset', `${inset}px`); +} + +export function watchKeyboardInset(): () => void { + if (typeof window === 'undefined' || !window.visualViewport) return () => {}; + + subscriberCount += 1; + + if (!trackedViewport) { + trackedViewport = window.visualViewport; + trackedViewport.addEventListener('resize', syncKeyboardInset); + trackedViewport.addEventListener('scroll', syncKeyboardInset); + } + syncKeyboardInset(); + + return () => { + subscriberCount = Math.max(0, subscriberCount - 1); + if (subscriberCount === 0) { + trackedViewport?.removeEventListener('resize', syncKeyboardInset); + trackedViewport?.removeEventListener('scroll', syncKeyboardInset); + trackedViewport = null; + // Drop the inset so a stale keyboard height can't linger on `:root`. + keyboard.inset = 0; + document.documentElement.style.setProperty('--keyboard-inset', '0px'); + } + }; +} From 76a8d341a0070aef3bf753940ab51b2e7190f03d Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Mon, 29 Jun 2026 17:32:17 +1000 Subject: [PATCH 3/8] feat(staged): add interactive-widget=resizes-content to viewport meta Step 4 of the full-screen mobile dialog work: a Chromium-only enhancement layered on top of the Part 2 visualViewport solution (089bd50e). The viewport meta's content gains `interactive-widget=resizes-content`, appended after the existing tokens (including user-scalable=no), which are left intact. On Chromium-based engines this shrinks the layout viewport when the soft keyboard opens, so a 100dvh full-screen dialog fits above the keyboard without relying on the JS-driven --keyboard-inset fallback. iOS Safari/WKWebView ignore the token and continue to use the Part 2 keyboardInset mechanism, which is unchanged. Purely additive and harmless: no behavioural change on engines that don't support the token, and no code changes elsewhere. Co-Authored-By: Claude Opus 4.8 Signed-off-by: Matt Toohey --- apps/staged/index.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/staged/index.html b/apps/staged/index.html index fd1d03a7d..b2d8cb8b7 100644 --- a/apps/staged/index.html +++ b/apps/staged/index.html @@ -10,7 +10,7 @@ Staged