Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/staged/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta
name="viewport"
content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no"
content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, interactive-widget=resizes-content"
/>
<title>Staged</title>
<!-- The window starts hidden and is shown by App.svelte after the
Expand Down
1 change: 1 addition & 0 deletions apps/staged/src/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
--bg-chrome: #18151d;
--bg-deepest: #020203;
--bg-elevated: #38333f;
--bg-alert-dialog: #38333f;
--bg-menu: #38333f;
--bg-hover: #342e3b;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@
data-slot="alert-dialog-content"
data-size={size}
class={cn(
'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 bg-card text-card-foreground ring-foreground/10 gap-6 rounded-xl p-6 ring-1 duration-100 data-[size=default]:max-w-xs data-[size=sm]:max-w-xs data-[size=default]:sm:max-w-lg group/alert-dialog-content fixed top-1/2 left-1/2 z-(--z-index-overlay) grid w-full -translate-x-1/2 -translate-y-1/2 outline-none',
'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 bg-[var(--bg-alert-dialog)] text-[var(--text-primary)] ring-foreground/10 gap-6 rounded-xl p-6 ring-1 duration-100 data-[size=default]:max-w-xs data-[size=sm]:max-w-xs data-[size=default]:sm:max-w-lg group/alert-dialog-content fixed top-1/2 left-1/2 z-(--z-index-overlay) grid w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 outline-none',
className
)}
{...restProps}
Expand Down
74 changes: 73 additions & 1 deletion apps/staged/src/lib/components/ui/dialog/dialog-content.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -2,24 +2,91 @@
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';
import { watchKeyboardInset } from '$lib/shared/keyboardInset.svelte';
import { getWindowSync } from '$lib/transport';

let {
ref = $bindable(null),
class: className,
style: styleProp,
portalProps,
children,
showCloseButton = true,
fullScreenOnMobile = true,
onpointerdown: onPointerDown,
...restProps
}: WithoutChildrenOrChild<DialogPrimitive.ContentProps> & {
portalProps?: WithoutChildrenOrChild<ComponentProps<typeof DialogPortal>>;
children: Snippet;
showCloseButton?: boolean;
fullScreenOnMobile?: boolean;
} = $props();

// 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. Tailwind v4
// translate utilities use the individual `translate` property, so reset that
// alongside `transform` to prevent desktop centering from shifting the mobile
// panel offscreen. 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:calc(100dvh - var(--keyboard-inset, 0px));max-height:none;transform:none;translate:none;border-radius:0;padding-top:env(safe-area-inset-top);padding-bottom:env(safe-area-inset-bottom);';

const contentStyle = $derived(
fullScreen ? `${styleProp ? `${styleProp};` : ''}${fullScreenStyle}` : styleProp
);

const dragRegionSelector =
'[data-dialog-drag-region], [data-slot="dialog-header"], .modal-header, .diagram-viewer-header';
const noDragSelector =
'button, a, input, select, textarea, summary, [role="button"], [role="link"], [role="textbox"], [contenteditable="true"], [data-dialog-no-drag]';

type DialogPointerEvent = PointerEvent & { currentTarget: EventTarget & HTMLDivElement };

function startFullScreenDrag(event: DialogPointerEvent) {
if (!fullScreen || event.button !== 0) return;

const contentElement = event.currentTarget;
const target = event.target as HTMLElement | null;
if (!target || !contentElement.contains(target)) return;
if (target.closest(noDragSelector)) return;

const dragRegion = target.closest(dragRegionSelector);
if (!dragRegion || !contentElement.contains(dragRegion)) return;

event.preventDefault();
void getWindowSync().startDragging();
}

function handlePointerDown(event: DialogPointerEvent) {
onPointerDown?.(event);
if (!event.defaultPrevented) {
startFullScreenDrag(event);
}
}
</script>

<DialogPortal {...portalProps}>
Expand All @@ -28,10 +95,15 @@
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 z-(--z-index-overlay) w-full outline-none',
fullScreen
? 'data-open:slide-in-from-bottom data-closed:slide-out-to-bottom'
: 'top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 data-closed:zoom-out-95 data-open:zoom-in-95',
className
)}
style={contentStyle}
onOpenAutoFocus={(e) => e.preventDefault()}
onpointerdown={handlePointerDown}
{...restProps}
>
{@render children?.()}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -171,7 +171,7 @@
<Button
variant="outline"
type="button"
class="gap-1.5 px-4 py-2 text-sm font-medium text-muted-foreground shadow-none hover:text-foreground max-[640px]:h-11 max-[640px]:justify-center"
class="gap-1.5 px-4 py-2 text-sm font-medium text-muted-foreground shadow-none hover:text-foreground max-[768px]:h-11 max-[768px]:justify-center"
onclick={openFilePicker}
>
<ImagePlus size={14} />
Expand Down
16 changes: 11 additions & 5 deletions apps/staged/src/lib/features/sessions/NewSessionModal.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,7 @@
);
let submitLabel = $derived(currentWillQueue ? 'Queue' : isProjectNote ? 'New' : 'Start');
const footerControlClass =
'h-9 gap-1.5 rounded-md border border-[var(--border-muted)] bg-[var(--bg-primary)] px-4 py-2 text-sm font-medium text-muted-foreground shadow-none transition-colors hover:bg-[var(--bg-hover)] hover:text-foreground max-[640px]:h-11 max-[640px]:justify-center';
'h-9 gap-1.5 rounded-md border border-[var(--border-muted)] bg-[var(--bg-primary)] px-4 py-2 text-sm font-medium text-muted-foreground shadow-none transition-colors hover:bg-[var(--bg-hover)] hover:text-foreground max-[768px]:h-11 max-[768px]:justify-center';

function selectPromptContent(el: HTMLElement, selection: 'all' | 'last-line') {
const sel = window.getSelection();
Expand Down Expand Up @@ -501,7 +501,7 @@
<Button
variant="ghost"
size="icon"
class="size-7 shrink-0 text-muted-foreground hover:bg-[var(--bg-hover)] hover:text-foreground max-[640px]:size-10 [&_svg]:!size-[18px]"
class="size-7 shrink-0 text-muted-foreground hover:bg-[var(--bg-hover)] hover:text-foreground max-[768px]:size-10 [&_svg]:!size-[18px]"
title={viewport.showShortcutHints ? 'Close (Esc)' : 'Close'}
aria-label="Close"
onclick={handleClose}
Expand Down Expand Up @@ -553,7 +553,7 @@
<Button
type="button"
variant="outline"
class="gap-1.5 px-4 py-2 text-sm font-medium text-muted-foreground hover:text-foreground max-[640px]:h-11 max-[640px]:flex-1 max-[640px]:justify-center"
class="gap-1.5 px-4 py-2 text-sm font-medium text-muted-foreground hover:text-foreground max-[768px]:h-11 max-[768px]:flex-1 max-[768px]:justify-center"
onclick={handleClose}
disabled={starting}
>
Expand All @@ -562,7 +562,7 @@
<Button
type="submit"
variant="default"
class="gap-1.5 px-4 py-2 text-sm font-semibold shadow-none hover:bg-[var(--ui-accent-hover)] max-[640px]:h-11 max-[640px]:flex-1 max-[640px]:justify-center"
class="appearance-none gap-1.5 bg-[var(--ui-accent)] px-4 py-2 text-sm font-semibold shadow-none hover:bg-[var(--ui-accent-hover)] disabled:bg-[var(--ui-accent)] max-[768px]:h-11 max-[768px]:flex-1 max-[768px]:justify-center"
title={submitDisabledReason ?? undefined}
disabled={!canSubmit}
>
Expand Down Expand Up @@ -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;
}

Expand All @@ -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 {
Expand All @@ -821,7 +827,7 @@
gap: 8px;
}

@media (max-width: 640px) {
@media (max-width: 768px) {
.modal-header {
padding: 12px 16px;
}
Expand Down
3 changes: 3 additions & 0 deletions apps/staged/src/lib/features/sessions/SessionModal.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
51 changes: 51 additions & 0 deletions apps/staged/src/lib/shared/keyboardInset.svelte.ts
Original file line number Diff line number Diff line change
@@ -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');
}
};
}
6 changes: 6 additions & 0 deletions apps/staged/src/lib/theme.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ export interface Theme {
chrome: string; // Unified chrome background (header, sidebar, spine)
deepest: string; // Deepest level (tab bar) - pure black/white
elevated: string; // Floating elements (cards, dialogs)
alertDialog: string; // Compact confirmation surfaces
menu: string; // Menu/popover surfaces (context menu, dropdown, select, popover)
hover: string; // Hover states
};
Expand Down Expand Up @@ -440,6 +441,10 @@ export function createAdaptiveTheme(
// without going gray, with the ring/shadow the shadcn primitives apply
// carrying the rest of the lift.
elevated: isDark ? elevate(0.08) : adjust(primaryBg, -0.04),
// Alert dialogs are compact decision prompts rather than full interface
// surfaces. Keep them crisp white in light themes, with the equivalent
// lifted surface in dark themes.
alertDialog: isDark ? elevate(0.08) : '#ffffff',
// Menu/popover surfaces (context menu, dropdown, select, popover) sit on
// top of content like other floating surfaces, but unlike cards/dialogs
// they read best as a crisp panel rather than a tinted one. Dark themes
Expand Down Expand Up @@ -567,6 +572,7 @@ export function themeToVarMap(t: Theme): Record<string, string> {
'--bg-chrome': t.bg.chrome,
'--bg-deepest': t.bg.deepest,
'--bg-elevated': t.bg.elevated,
'--bg-alert-dialog': t.bg.alertDialog,
'--bg-menu': t.bg.menu,
'--bg-hover': t.bg.hover,

Expand Down