From 98f1decd5eaebb19604b5b295edadd7a63dd7510 Mon Sep 17 00:00:00 2001 From: Adam Eivy Date: Wed, 2 Sep 2026 05:15:08 +0000 Subject: [PATCH 1/2] fix: clamp modal dialogs to the visible viewport so iOS Safari can't hide their buttons (#5665) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dialogs opened on a phone could render with their title and their Save/Cancel row cut off above and below the screen, with no way to scroll to them. On iOS Safari the retractable browser chrome makes `100vh` taller than the visible viewport, and every modal sized itself with its own raw `max-h-[NNvh]` — nine different values across 27 call sites. Because the modal overlay is sized to the *small* viewport and centres its panel, any panel taller than the overlay had the excess split top and bottom. On the long forms that also opt out of Esc and backdrop dismissal (agent memory editing, resuming an agent) that left no way out of the dialog at all. The shared Modal primitive now owns the clamp: every panel is bounded by the dynamic viewport (`max-h-dvh-cap`) minus the overlay's own padding, and gets a scroll region unless the call site declares its own overflow. All 27 call sites drop their hand-rolled height, so the nine divergent dialog heights collapse to one shared behaviour. A dialog that deliberately wants to be shorter than the screen now says so with `[--dvh-cap:NNdvh]`, which still tracks the visible viewport. Top-aligned dialogs additionally account for their 10dvh offset, and five non-modal full-height surfaces (goals tree, alcohol/nicotine logs, the shell provider launcher, the expanded agent output) move to the same dynamic-viewport sizing. A source-scanning test pins the migration so a new dialog cannot reintroduce the per-caller idiom. Claude-Session: https://claude.ai/code/session_01GMxEz43s3YCLaVZV9KmVwE --- client/src/components/FolderPicker.jsx | 2 +- client/src/components/IngredientPicker.jsx | 2 +- client/src/components/apps/DeployPanel.jsx | 2 +- client/src/components/apps/tabs/GitTab.jsx | 2 +- .../components/brain/ConversationViewer.jsx | 2 +- client/src/components/cos/tabs/AgentCard.jsx | 4 +- .../components/cos/tabs/MemoryEditModal.jsx | 2 +- .../components/cos/tabs/ResumeAgentModal.jsx | 2 +- .../digital-twin/tabs/DocumentsTab.jsx | 2 +- .../fableloom/LoomHostedSessionModal.jsx | 2 +- client/src/components/goals/GoalsTreeView.jsx | 2 +- .../components/imageGen/Flux2InstallModal.jsx | 2 +- .../imageGen/GalleryImagePicker.jsx | 2 +- .../install/RuntimeInstallModal.jsx | 2 +- .../loraTraining/ImportGalleryDialog.jsx | 2 +- .../components/meatspace/tabs/AlcoholTab.jsx | 2 +- .../components/meatspace/tabs/NicotineTab.jsx | 2 +- .../src/components/media/PromptFromMedia.jsx | 2 +- .../components/media/PromptRefineModal.jsx | 2 +- .../src/components/music/AlbumTrackPicker.jsx | 2 +- .../src/components/music/TrackRenderModal.jsx | 2 +- .../openworld/OpenWorldFastTravel.jsx | 2 +- .../openworld/OpenWorldPhotoOverlay.jsx | 2 +- .../manuscript/ManuscriptReadAloud.jsx | 4 +- .../shell/ShellProviderLauncher.jsx | 2 +- .../components/sprites/ForkSpriteModal.jsx | 2 +- .../sprites/SpriteReferencePicker.jsx | 2 +- client/src/components/ui/Modal.jsx | 45 +++++++++++++- client/src/components/ui/Modal.test.jsx | 50 +++++++++++++++ .../components/ui/modalPanelHeights.test.js | 61 +++++++++++++++++++ .../MoodBoardStyleSynthesis.jsx | 2 +- .../UniverseStyleReferences.jsx | 2 +- .../videoGen/GalleryVideoPicker.jsx | 2 +- client/src/pages/CatalogIngredient.jsx | 4 +- 34 files changed, 188 insertions(+), 36 deletions(-) create mode 100644 client/src/components/ui/modalPanelHeights.test.js diff --git a/client/src/components/FolderPicker.jsx b/client/src/components/FolderPicker.jsx index 3d15b5b2e1..571f6cac02 100644 --- a/client/src/components/FolderPicker.jsx +++ b/client/src/components/FolderPicker.jsx @@ -110,7 +110,7 @@ export default function FolderPicker({ value, onChange, defaultPath }) { size="md" usePortal ariaLabelledBy="folder-picker-title" - panelClassName="bg-port-card border border-port-border rounded-xl max-h-[80vh] flex flex-col shadow-2xl" + panelClassName="bg-port-card border border-port-border rounded-xl flex flex-col shadow-2xl" > {/* Header */}
diff --git a/client/src/components/IngredientPicker.jsx b/client/src/components/IngredientPicker.jsx index 3944c13bf8..21ddacbcf6 100644 --- a/client/src/components/IngredientPicker.jsx +++ b/client/src/components/IngredientPicker.jsx @@ -124,7 +124,7 @@ export default function IngredientPicker({ onClose={onClose} size="xl" closeOnBackdrop - panelClassName="bg-port-card border border-port-border rounded-xl max-h-[85vh] flex flex-col" + panelClassName="bg-port-card border border-port-border rounded-xl flex flex-col" ariaLabelledBy="ingredient-picker-title" >
diff --git a/client/src/components/apps/DeployPanel.jsx b/client/src/components/apps/DeployPanel.jsx index 493c2e3af7..10da57d70c 100644 --- a/client/src/components/apps/DeployPanel.jsx +++ b/client/src/components/apps/DeployPanel.jsx @@ -130,7 +130,7 @@ export default function DeployPanel({ appId, appName }) { onEsc={handleEsc} size="xl" backdropClassName="bg-black/60" - panelClassName="bg-port-bg border border-port-border rounded-xl shadow-2xl max-h-[80vh] flex flex-col" + panelClassName="bg-port-bg border border-port-border rounded-xl shadow-2xl flex flex-col" ariaLabelledBy="deploy-panel-title" >
diff --git a/client/src/components/apps/tabs/GitTab.jsx b/client/src/components/apps/tabs/GitTab.jsx index b818b8184f..ae0d398784 100644 --- a/client/src/components/apps/tabs/GitTab.jsx +++ b/client/src/components/apps/tabs/GitTab.jsx @@ -1014,7 +1014,7 @@ export default function GitTab({ appId, appName, repoPath }) { size="none" align="none" backdropClassName="bg-black/50" - panelClassName="bg-port-card border border-port-border rounded-xl w-3/4 max-h-[80vh] overflow-hidden" + panelClassName="bg-port-card border border-port-border rounded-xl w-3/4 overflow-hidden" ariaLabelledBy="git-diff-modal-title" >
diff --git a/client/src/components/brain/ConversationViewer.jsx b/client/src/components/brain/ConversationViewer.jsx index 8d6f34b07c..89a1d63db2 100644 --- a/client/src/components/brain/ConversationViewer.jsx +++ b/client/src/components/brain/ConversationViewer.jsx @@ -32,7 +32,7 @@ export default function ConversationViewer({ record, onClose }) { onClose={onClose} size="xl" ariaLabel={record.title} - panelClassName="bg-port-card border border-port-border rounded-lg max-h-[85vh] flex flex-col" + panelClassName="bg-port-card border border-port-border rounded-lg flex flex-col" >

{record.title}

diff --git a/client/src/components/cos/tabs/AgentCard.jsx b/client/src/components/cos/tabs/AgentCard.jsx index 0b8a2396c0..9f85cd1d05 100644 --- a/client/src/components/cos/tabs/AgentCard.jsx +++ b/client/src/components/cos/tabs/AgentCard.jsx @@ -963,7 +963,7 @@ export default function AgentCard({ agent, onPause, onKill, onDelete, onResume, {/* Expanded output view */} {expanded && ( -
+
{/* Pipeline stage tabs */} {pipelineStages && (
@@ -1054,7 +1054,7 @@ export default function AgentCard({ agent, onPause, onKill, onDelete, onResume, onClose={() => setPromptOpen(false)} size="2xl" usePortal - panelClassName="bg-port-card border border-port-border rounded-lg max-h-[80vh] flex flex-col" + panelClassName="bg-port-card border border-port-border rounded-lg flex flex-col" ariaLabel="Agent prompt" >
diff --git a/client/src/components/cos/tabs/MemoryEditModal.jsx b/client/src/components/cos/tabs/MemoryEditModal.jsx index 643342bcb2..a10b6065b6 100644 --- a/client/src/components/cos/tabs/MemoryEditModal.jsx +++ b/client/src/components/cos/tabs/MemoryEditModal.jsx @@ -104,7 +104,7 @@ export default function MemoryEditModal({ memory, apps, onSave, onClose }) { closeOnEsc={false} size="lg" backdropClassName="bg-black/50" - panelClassName="bg-port-card border border-port-border rounded-xl p-4 sm:p-6 max-h-[90vh] overflow-auto" + panelClassName="bg-port-card border border-port-border rounded-xl p-4 sm:p-6" ariaLabelledBy="memory-edit-title" >
diff --git a/client/src/components/cos/tabs/ResumeAgentModal.jsx b/client/src/components/cos/tabs/ResumeAgentModal.jsx index 37a7674aa4..93c3332d53 100644 --- a/client/src/components/cos/tabs/ResumeAgentModal.jsx +++ b/client/src/components/cos/tabs/ResumeAgentModal.jsx @@ -120,7 +120,7 @@ export default function ResumeAgentModal({ agent, taskType = 'user', providers, // applies the same flex centring without the default `p-4`. align="none" backdropClassName="bg-black/50" - panelClassName="bg-port-card border border-port-border rounded-xl p-6 max-h-[90vh] overflow-auto" + panelClassName="bg-port-card border border-port-border rounded-xl p-6" ariaLabelledBy="resume-agent-title" >
diff --git a/client/src/components/digital-twin/tabs/DocumentsTab.jsx b/client/src/components/digital-twin/tabs/DocumentsTab.jsx index 584bc76c1c..1cb7d2408e 100644 --- a/client/src/components/digital-twin/tabs/DocumentsTab.jsx +++ b/client/src/components/digital-twin/tabs/DocumentsTab.jsx @@ -323,7 +323,7 @@ export default function DocumentsTab({ onRefresh }) { closeOnBackdrop={false} closeOnEsc={false} ariaLabel="Create Soul Document" - panelClassName="bg-port-card rounded-lg border border-port-border p-4 sm:p-6 max-h-[90vh] overflow-y-auto" + panelClassName="bg-port-card rounded-lg border border-port-border p-4 sm:p-6" >

Create Soul Document

diff --git a/client/src/components/fableloom/LoomHostedSessionModal.jsx b/client/src/components/fableloom/LoomHostedSessionModal.jsx index 3483abab95..6e9d1a6349 100644 --- a/client/src/components/fableloom/LoomHostedSessionModal.jsx +++ b/client/src/components/fableloom/LoomHostedSessionModal.jsx @@ -126,7 +126,7 @@ export default function LoomHostedSessionModal({ onClose={onClose} usePortal backdropClassName="bg-black/60 backdrop-blur-sm" - panelClassName="bg-port-card border border-port-border rounded-xl shadow-2xl overflow-hidden flex flex-col max-h-[90vh]" + panelClassName="bg-port-card border border-port-border rounded-xl shadow-2xl overflow-hidden flex flex-col" ariaLabelledBy="hosted-two-device-play-title" > {/* Header */} diff --git a/client/src/components/goals/GoalsTreeView.jsx b/client/src/components/goals/GoalsTreeView.jsx index 51f215f492..d7f79caa67 100644 --- a/client/src/components/goals/GoalsTreeView.jsx +++ b/client/src/components/goals/GoalsTreeView.jsx @@ -267,7 +267,7 @@ function OrganizePanel({ suggestion, goals, onApply, onClose, applying }) { if (!suggestion) return null; return ( -
+
diff --git a/client/src/components/imageGen/Flux2InstallModal.jsx b/client/src/components/imageGen/Flux2InstallModal.jsx index abd0018b11..97beb53360 100644 --- a/client/src/components/imageGen/Flux2InstallModal.jsx +++ b/client/src/components/imageGen/Flux2InstallModal.jsx @@ -75,7 +75,7 @@ export default function Flux2InstallModal({ open, onClose, onComplete }) { // imperceptible (~16px) and not worth a custom override. backdropClassName="bg-black/70 backdrop-blur-sm" ariaLabelledBy="flux2-install-title" - panelClassName="bg-port-card rounded-xl border border-port-border shadow-2xl overflow-hidden flex flex-col max-h-[80vh]" + panelClassName="bg-port-card rounded-xl border border-port-border shadow-2xl overflow-hidden flex flex-col" > {/* Header */}
diff --git a/client/src/components/imageGen/GalleryImagePicker.jsx b/client/src/components/imageGen/GalleryImagePicker.jsx index be515c74be..f6e543f4ec 100644 --- a/client/src/components/imageGen/GalleryImagePicker.jsx +++ b/client/src/components/imageGen/GalleryImagePicker.jsx @@ -244,7 +244,7 @@ export default function GalleryImagePicker({ // overlay beneath the page header/cards (and beneath a host modal when the // picker is opened from inside one). usePortal - panelClassName="bg-port-card border border-port-border rounded-xl max-h-[85vh] flex flex-col" + panelClassName="bg-port-card border border-port-border rounded-xl flex flex-col" ariaLabel="Pick an image from your gallery" >
diff --git a/client/src/components/install/RuntimeInstallModal.jsx b/client/src/components/install/RuntimeInstallModal.jsx index 0416fda498..7f47578753 100644 --- a/client/src/components/install/RuntimeInstallModal.jsx +++ b/client/src/components/install/RuntimeInstallModal.jsx @@ -70,7 +70,7 @@ export default function RuntimeInstallModal({ closeOnEsc={false} backdropClassName="bg-black/70 backdrop-blur-sm" ariaLabelledBy="runtime-install-title" - panelClassName="bg-port-card rounded-xl border border-port-border shadow-2xl overflow-hidden flex flex-col max-h-[80vh]" + panelClassName="bg-port-card rounded-xl border border-port-border shadow-2xl overflow-hidden flex flex-col" >
diff --git a/client/src/components/loraTraining/ImportGalleryDialog.jsx b/client/src/components/loraTraining/ImportGalleryDialog.jsx index 7923459306..f90df7d9a4 100644 --- a/client/src/components/loraTraining/ImportGalleryDialog.jsx +++ b/client/src/components/loraTraining/ImportGalleryDialog.jsx @@ -81,7 +81,7 @@ export default function ImportGalleryDialog({ dataset, onClose, onImported }) { open onClose={onClose} size="3xl" - panelClassName="bg-port-card border border-port-border rounded-xl max-h-[85vh] flex flex-col" + panelClassName="bg-port-card border border-port-border rounded-xl flex flex-col" ariaLabel="Import images from your gallery" >
diff --git a/client/src/components/meatspace/tabs/AlcoholTab.jsx b/client/src/components/meatspace/tabs/AlcoholTab.jsx index 4c0cc868f9..7b2929d93c 100644 --- a/client/src/components/meatspace/tabs/AlcoholTab.jsx +++ b/client/src/components/meatspace/tabs/AlcoholTab.jsx @@ -550,7 +550,7 @@ export default function AlcoholTab() {

All Drink Entries ({allEntries.length} days)

-
+
diff --git a/client/src/components/meatspace/tabs/NicotineTab.jsx b/client/src/components/meatspace/tabs/NicotineTab.jsx index ebb0290339..98e5eff184 100644 --- a/client/src/components/meatspace/tabs/NicotineTab.jsx +++ b/client/src/components/meatspace/tabs/NicotineTab.jsx @@ -409,7 +409,7 @@ export default function NicotineTab() {

History ({allEntries.length} days)

-
+
diff --git a/client/src/components/media/PromptFromMedia.jsx b/client/src/components/media/PromptFromMedia.jsx index 0dc01a5164..5defca835a 100644 --- a/client/src/components/media/PromptFromMedia.jsx +++ b/client/src/components/media/PromptFromMedia.jsx @@ -398,7 +398,7 @@ export function PromptFromMediaModal({ item, open, onClose, kindDefault = 'both' zIndexClassName="z-[70]" backdropClassName="bg-black/80" ariaLabelledBy="prompt-from-media-title" - panelClassName="max-h-[90vh] overflow-hidden bg-port-card border border-port-border rounded-xl shadow-2xl flex flex-col" + panelClassName="overflow-hidden bg-port-card border border-port-border rounded-xl shadow-2xl flex flex-col" >
diff --git a/client/src/components/media/PromptRefineModal.jsx b/client/src/components/media/PromptRefineModal.jsx index 5cbf58db58..474529ae83 100644 --- a/client/src/components/media/PromptRefineModal.jsx +++ b/client/src/components/media/PromptRefineModal.jsx @@ -121,7 +121,7 @@ export default function PromptRefineModal({ item, open, onClose }) { zIndexClassName="z-[70]" backdropClassName="bg-black/80" ariaLabelledBy="prompt-refine-title" - panelClassName="max-h-[90vh] overflow-hidden bg-port-card border border-port-border rounded-xl shadow-2xl flex flex-col" + panelClassName="overflow-hidden bg-port-card border border-port-border rounded-xl shadow-2xl flex flex-col" >
diff --git a/client/src/components/music/AlbumTrackPicker.jsx b/client/src/components/music/AlbumTrackPicker.jsx index 03eeff03b3..0f891acc26 100644 --- a/client/src/components/music/AlbumTrackPicker.jsx +++ b/client/src/components/music/AlbumTrackPicker.jsx @@ -46,7 +46,7 @@ export default function AlbumTrackPicker({ open, tracks = [], onClose, onAdd }) open={open} onClose={onClose} size="lg" - panelClassName="bg-port-card border border-port-border rounded-xl max-h-[85vh] flex flex-col" + panelClassName="bg-port-card border border-port-border rounded-xl flex flex-col" ariaLabelledBy="album-track-picker-title" >
diff --git a/client/src/components/music/TrackRenderModal.jsx b/client/src/components/music/TrackRenderModal.jsx index b668534ba7..d2fd897400 100644 --- a/client/src/components/music/TrackRenderModal.jsx +++ b/client/src/components/music/TrackRenderModal.jsx @@ -34,7 +34,7 @@ export default function TrackRenderModal({ render, active = false, onClose, onSe size="lg" align="top" ariaLabelledBy="track-render-title" - panelClassName="bg-port-card rounded-xl border border-port-border shadow-2xl overflow-hidden flex flex-col max-h-[85vh]" + panelClassName="bg-port-card rounded-xl border border-port-border shadow-2xl overflow-hidden flex flex-col" >

diff --git a/client/src/components/openworld/OpenWorldFastTravel.jsx b/client/src/components/openworld/OpenWorldFastTravel.jsx index 7484d0d85e..8ffb8d700f 100644 --- a/client/src/components/openworld/OpenWorldFastTravel.jsx +++ b/client/src/components/openworld/OpenWorldFastTravel.jsx @@ -92,7 +92,7 @@ export default function OpenWorldFastTravel({ open, onClose, onTravel, activeReg usePortal ariaLabel="Village map" zIndexClassName="z-[120]" - panelClassName="w-full max-w-3xl max-h-[85vh] rounded-xl border port-media-overlay flex flex-col overflow-hidden" + panelClassName="w-full max-w-3xl rounded-xl border port-media-overlay flex flex-col overflow-hidden" >
VILLAGE MAP
diff --git a/client/src/components/openworld/OpenWorldPhotoOverlay.jsx b/client/src/components/openworld/OpenWorldPhotoOverlay.jsx index 04d4b2e32b..7215575ac8 100644 --- a/client/src/components/openworld/OpenWorldPhotoOverlay.jsx +++ b/client/src/components/openworld/OpenWorldPhotoOverlay.jsx @@ -167,7 +167,7 @@ export default function OpenWorldPhotoOverlay({ active, presetId, onPresetChange size="none" usePortal ariaLabel="OpenWorld postcard preview" - panelClassName="max-w-[80vw] max-h-[80vh] flex flex-col items-center gap-3" + panelClassName="max-w-[80vw] flex flex-col items-center gap-3" > OpenWorld postcard
diff --git a/client/src/components/pipeline/manuscript/ManuscriptReadAloud.jsx b/client/src/components/pipeline/manuscript/ManuscriptReadAloud.jsx index c7e9f82271..f474c91283 100644 --- a/client/src/components/pipeline/manuscript/ManuscriptReadAloud.jsx +++ b/client/src/components/pipeline/manuscript/ManuscriptReadAloud.jsx @@ -225,8 +225,8 @@ export default function ManuscriptReadAloud({ open, onClose, section }) { const progressPct = totalMs > 0 ? Math.min(100, (elapsedMs / totalMs) * 100) : 0; return ( - -
+ +

diff --git a/client/src/components/shell/ShellProviderLauncher.jsx b/client/src/components/shell/ShellProviderLauncher.jsx index b25be39248..1c4f389bb6 100644 --- a/client/src/components/shell/ShellProviderLauncher.jsx +++ b/client/src/components/shell/ShellProviderLauncher.jsx @@ -113,7 +113,7 @@ export default function ShellProviderLauncher({ providers, onLaunch, onOpen, loa {open && createPortal(
diff --git a/client/src/components/sprites/SpriteReferencePicker.jsx b/client/src/components/sprites/SpriteReferencePicker.jsx index b70c23bf72..07982e9087 100644 --- a/client/src/components/sprites/SpriteReferencePicker.jsx +++ b/client/src/components/sprites/SpriteReferencePicker.jsx @@ -43,7 +43,7 @@ export default function SpriteReferencePicker({ open, onClose, onSelect, exclude onClose={onClose} size="3xl" usePortal - panelClassName="bg-port-card border border-port-border rounded-xl max-h-[85vh] flex flex-col" + panelClassName="bg-port-card border border-port-border rounded-xl flex flex-col" ariaLabel="Pick a reference sprite" >
diff --git a/client/src/components/ui/Modal.jsx b/client/src/components/ui/Modal.jsx index 1cef5163a8..3b3eeec5b9 100644 --- a/client/src/components/ui/Modal.jsx +++ b/client/src/components/ui/Modal.jsx @@ -35,6 +35,25 @@ * usePortal LayoutEditor / KeyboardHelp — escape any * stacking-context ancestors. * + * Height: the panel is clamped to the *visible* viewport by Modal itself — + * `max-h-dvh-cap` (index.css) minus a per-align `--dvh-inset`. Call sites must + * NOT pass their own `max-h-[NNvh]` in `panelClassName`: the overlay is + * `fixed inset-0` (the small viewport on iOS Safari) and centres with + * `items-center`, so a panel taller than the overlay has its overflow split + * top and bottom — the dialog's title AND its Save/Cancel footer end up + * off-screen, which is unrecoverable on a modal that also opts out of Esc and + * backdrop dismissal. The clamp lives in the primitive for the same reason + * portaling does (`client/src/AGENTS.md`): so no call site has to remember it. + * `client/src/components/ui/modalPanelHeights.test.js` fails the build if the + * per-caller idiom comes back. The inset accounts for the align padding, + * including align='top' (`pt-[10dvh]`), so a top-aligned panel still ends + * above the bottom edge. A caller that wants a *shorter* panel sets the cap + * rather than a raw vh: `panelClassName="… [--dvh-cap:60dvh]"`. Modal also + * supplies `overflow-auto` unless `panelClassName` already declares an + * overflow utility, so a clamped panel is always scrollable — a panel that + * needs an un-portaled popover to escape its bounds declares + * `overflow-visible` and keeps the clamp. + * * Stacking: Modal uses a single module-scope bubble-phase `keydown` * listener that dispatches Esc only to the top-most open Modal — and only * the top-most. Every open Modal registers on the stack (regardless of its @@ -67,7 +86,7 @@ const SIZE_CLASSES = { // order — see the note next to the overlay
below. const ALIGN_CLASSES = { center: 'items-center justify-center p-4', - top: 'items-start justify-center pt-[10vh] px-4 pb-4', + top: 'items-start justify-center pt-[10dvh] px-4 pb-4', // No padding — for callers that historically had a bare overlay (no `p-*`) // and provide their own panel-internal padding instead. Used by // ResumeAgentModal where the pre-refactor overlay was @@ -75,6 +94,21 @@ const ALIGN_CLASSES = { none: 'items-center justify-center', }; +// Per-align `--dvh-inset` for the panel's unconditional `max-h-dvh-cap` clamp +// (see the "Height" section of the docblock). The inset is the vertical space +// the overlay's own padding already consumes, so the clamped panel never +// exceeds the *visible* dynamic viewport: +// center p-4 → 1rem top + 1rem bottom +// top pt-[10dvh] + pb-4 → 10dvh top + 1rem bottom +// none no padding → 0 +// These must be written as literal class strings so Tailwind's source scanner +// emits the arbitrary-property utilities. +const ALIGN_DVH_INSET = { + center: '[--dvh-inset:2rem]', + top: '[--dvh-inset:calc(10dvh_+_1rem)]', + none: '[--dvh-inset:0px]', +}; + // Module-scope stack of open Modal ids. A single bubble-phase keydown // listener on `window` dispatches Esc only to the top-most modal — every // other Modal listener (including the layer beneath this one) is blocked via @@ -271,6 +305,13 @@ export default function Modal({ const alignClass = ALIGN_CLASSES[align] || ALIGN_CLASSES.center; const sizeClass = SIZE_CLASSES[size] ?? SIZE_CLASSES.md; const widthClass = size === 'none' ? '' : `w-full ${sizeClass}`; + const insetClass = ALIGN_DVH_INSET[align] || ALIGN_DVH_INSET.center; + // Only supply the scroll behaviour when the caller hasn't declared its own + // overflow. Tailwind precedence follows CSS source order, not class-string + // order (see the overlay note below), so emitting `overflow-auto` alongside + // a caller's `overflow-hidden` would be a coin flip rather than an override. + const overflowClass = /(^|\s|:)overflow-/.test(panelClassName) ? '' : 'overflow-auto'; + const heightClass = `max-h-dvh-cap ${insetClass} ${overflowClass}`; const overlay = (
{ expect(screen.queryByRole('dialog')).toBeNull(); }); }); + +// The overlay is `fixed inset-0` — the *small* viewport under iOS Safari's +// retractable chrome — and centres with `items-center`, so an unclamped panel +// has its overflow split top and bottom and loses both its title and its +// footer buttons off-screen. Modal owns the clamp so no call site has to. +describe('Modal viewport height clamp', () => { + it('clamps the panel to the dynamic viewport with no panelClassName', () => { + render( {}} ariaLabel="x">

body

); + const dialog = screen.getByRole('dialog'); + expect(dialog).toHaveClass('max-h-dvh-cap'); + // center align pads the overlay `p-4`, so the panel must give that back. + expect(dialog).toHaveClass('[--dvh-inset:2rem]'); + expect(dialog).toHaveClass('overflow-auto'); + }); + + it("insets by align='top' offset so a top-aligned panel clears the bottom edge", () => { + render( {}} align="top" ariaLabel="x">

body

); + const dialog = screen.getByRole('dialog'); + expect(dialog).toHaveClass('max-h-dvh-cap'); + expect(dialog).toHaveClass('[--dvh-inset:calc(10dvh_+_1rem)]'); + expect(dialog.parentElement).toHaveClass('pt-[10dvh]'); + }); + + it('keeps the clamp and appends panelClassName after it', () => { + render( + {}} ariaLabel="x" panelClassName="bg-port-card [--dvh-cap:60dvh]"> +

body

+
+ ); + const dialog = screen.getByRole('dialog'); + const cls = dialog.className; + expect(cls.indexOf('max-h-dvh-cap')).toBeGreaterThan(-1); + expect(cls.indexOf('max-h-dvh-cap')).toBeLessThan(cls.indexOf('bg-port-card')); + // A caller shortening the panel sets the cap variable, not a raw vh. + expect(dialog).toHaveClass('[--dvh-cap:60dvh]'); + }); + + it("yields the scroll utility to a caller's own overflow declaration", () => { + render( + {}} ariaLabel="x" panelClassName="overflow-hidden flex flex-col"> +

body

+
+ ); + const dialog = screen.getByRole('dialog'); + expect(dialog).toHaveClass('max-h-dvh-cap'); + // Tailwind precedence follows CSS source order, so emitting both would be + // a coin flip rather than an override. + expect(dialog).not.toHaveClass('overflow-auto'); + }); +}); diff --git a/client/src/components/ui/modalPanelHeights.test.js b/client/src/components/ui/modalPanelHeights.test.js new file mode 100644 index 0000000000..07810c814c --- /dev/null +++ b/client/src/components/ui/modalPanelHeights.test.js @@ -0,0 +1,61 @@ +import { describe, it, expect } from 'vitest'; +import { readdirSync, readFileSync, statSync } from 'fs'; +import { dirname, join, relative } from 'path'; +import { fileURLToPath } from 'url'; + +/** + * Migration pin for issue #5665. + * + * `Modal.jsx` clamps its panel to the *visible* viewport (`max-h-dvh-cap` plus + * a per-align `--dvh-inset`). A call site that re-adds its own `max-h-[NNvh]` + * defeats that: the overlay is `fixed inset-0` — the small viewport under iOS + * Safari's retractable chrome — and centres with `items-center`, so a taller + * panel has its overflow split top and bottom and the dialog loses both its + * title and its Save/Cancel row off-screen. On the long forms that also set + * `closeOnEsc={false}` / `closeOnBackdrop={false}` there is then no way out. + * + * A caller that genuinely wants a *shorter* panel sets the cap instead: + * `panelClassName="… [--dvh-cap:50dvh]"`, which still resolves against the + * dynamic viewport. + */ + +const SRC_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..'); + +// panelClassName="…" / {`…`} / {'…'} — the three literal forms in the tree. +const PANEL_CLASS_RE = /panelClassName=(?:"([^"]*)"|\{`([^`]*)`\}|\{'([^']*)'\})/g; +const RAW_VH_RE = /max-h-\[[\d.]+vh\]/; + +function collectSourceFiles(dir, out = []) { + for (const entry of readdirSync(dir)) { + const full = join(dir, entry); + if (statSync(full).isDirectory()) { + collectSourceFiles(full, out); + } else if (/\.jsx?$/.test(entry) && !/\.(test|spec)\.jsx?$/.test(entry)) { + out.push(full); + } + } + return out; +} + +describe('Modal panel heights', () => { + it('has no call site passing a raw viewport-height clamp in panelClassName', () => { + const offenders = []; + for (const file of collectSourceFiles(SRC_ROOT)) { + const source = readFileSync(file, 'utf8'); + if (!source.includes('panelClassName')) continue; + for (const match of source.matchAll(PANEL_CLASS_RE)) { + const value = match[1] ?? match[2] ?? match[3] ?? ''; + if (RAW_VH_RE.test(value)) offenders.push(`${relative(SRC_ROOT, file)}: ${value}`); + } + } + expect(offenders).toEqual([]); + }); + + it('scans a representative set of Modal call sites (guards the regex itself)', () => { + // A collector that silently matched nothing would make the assertion above + // vacuously green, so pin the sweep's own reach. + const withPanelClass = collectSourceFiles(SRC_ROOT) + .filter((file) => readFileSync(file, 'utf8').includes('panelClassName=')); + expect(withPanelClass.length).toBeGreaterThan(20); + }); +}); diff --git a/client/src/components/universeBuilder/MoodBoardStyleSynthesis.jsx b/client/src/components/universeBuilder/MoodBoardStyleSynthesis.jsx index fc25f10120..b49a9c31e9 100644 --- a/client/src/components/universeBuilder/MoodBoardStyleSynthesis.jsx +++ b/client/src/components/universeBuilder/MoodBoardStyleSynthesis.jsx @@ -198,7 +198,7 @@ export default function MoodBoardStyleSynthesis({ size="2xl" closeOnBackdrop={!bodyBusy} usePortal - panelClassName="bg-port-card border border-port-border rounded-xl max-h-[90vh] overflow-y-auto" + panelClassName="bg-port-card border border-port-border rounded-xl" ariaLabel="Synthesize universe style from mood board" > {open ? ( diff --git a/client/src/components/universeBuilder/UniverseStyleReferences.jsx b/client/src/components/universeBuilder/UniverseStyleReferences.jsx index 175c1d99f5..78aa8589d5 100644 --- a/client/src/components/universeBuilder/UniverseStyleReferences.jsx +++ b/client/src/components/universeBuilder/UniverseStyleReferences.jsx @@ -146,7 +146,7 @@ export default function UniverseStyleReferences({ size="2xl" closeOnBackdrop={!analyzing && !persisting} usePortal - panelClassName="bg-port-card border border-port-border rounded-xl max-h-[90vh] overflow-y-auto" + panelClassName="bg-port-card border border-port-border rounded-xl" ariaLabel="Add universe art style reference" >
diff --git a/client/src/components/videoGen/GalleryVideoPicker.jsx b/client/src/components/videoGen/GalleryVideoPicker.jsx index 4db655b174..599a5cb930 100644 --- a/client/src/components/videoGen/GalleryVideoPicker.jsx +++ b/client/src/components/videoGen/GalleryVideoPicker.jsx @@ -120,7 +120,7 @@ export default function GalleryVideoPicker({ onClose={onClose} size="3xl" usePortal - panelClassName="bg-port-card border border-port-border rounded-xl max-h-[85vh] flex flex-col" + panelClassName="bg-port-card border border-port-border rounded-xl flex flex-col" ariaLabel="Pick a video from your gallery" >
diff --git a/client/src/pages/CatalogIngredient.jsx b/client/src/pages/CatalogIngredient.jsx index 4190bd5344..c62926dc09 100644 --- a/client/src/pages/CatalogIngredient.jsx +++ b/client/src/pages/CatalogIngredient.jsx @@ -1390,9 +1390,9 @@ function GalleryPickerModal({ onClose, onPick }) { return ( + panelClassName="bg-port-card border border-port-border rounded-lg overflow-hidden flex flex-col"> {/* Header + scroll area must be DIRECT flex children of the panel (a - fragment, not a wrapping
) so the panel's max-h-[80vh] flex + fragment, not a wrapping
) so the panel's clamped flex column constrains the scroll region's height — an intervening content-sized
would leave `overflow-y-auto` unbounded and clip long galleries. */} From 93f3a527729164a474ec9d0942a3aedd7c027af7 Mon Sep 17 00:00:00 2001 From: Adam Eivy Date: Wed, 2 Sep 2026 05:21:15 +0000 Subject: [PATCH 2/2] fix: keep the modal scroll default under a variant-only overflow, and harden the migration guard (#5665) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review follow-up on the viewport clamp. A dialog that declared only a responsive overflow (`lg:overflow-hidden`) had Modal's default scroll suppressed at every width, so on a phone the panel was clamped but could not be scrolled — the exact failure the clamp exists to prevent. Only an unprefixed overflow now stands in for the default. The guard that pins the migration also checked its own reach by counting files that mention the prop rather than attribute values it actually parsed, so a caller written with different JSX spacing could have slipped past while the check stayed green. It now parses braced expressions whole (ternaries and template literals included), counts what it parsed, and proves against a fixture that it sees an offender in each syntactic form. Claude-Session: https://claude.ai/code/session_01GMxEz43s3YCLaVZV9KmVwE --- client/src/components/ui/Modal.jsx | 15 ++-- client/src/components/ui/Modal.test.jsx | 13 ++++ .../components/ui/modalPanelHeights.test.js | 73 +++++++++++++++---- 3 files changed, 82 insertions(+), 19 deletions(-) diff --git a/client/src/components/ui/Modal.jsx b/client/src/components/ui/Modal.jsx index 3b3eeec5b9..87070f78be 100644 --- a/client/src/components/ui/Modal.jsx +++ b/client/src/components/ui/Modal.jsx @@ -306,11 +306,16 @@ export default function Modal({ const sizeClass = SIZE_CLASSES[size] ?? SIZE_CLASSES.md; const widthClass = size === 'none' ? '' : `w-full ${sizeClass}`; const insetClass = ALIGN_DVH_INSET[align] || ALIGN_DVH_INSET.center; - // Only supply the scroll behaviour when the caller hasn't declared its own - // overflow. Tailwind precedence follows CSS source order, not class-string - // order (see the overlay note below), so emitting `overflow-auto` alongside - // a caller's `overflow-hidden` would be a coin flip rather than an override. - const overflowClass = /(^|\s|:)overflow-/.test(panelClassName) ? '' : 'overflow-auto'; + // Only supply the scroll behaviour when the caller declares an UNPREFIXED + // overflow of its own. Tailwind precedence follows CSS source order, not + // class-string order (see the overlay note below), so emitting + // `overflow-auto` alongside a caller's base `overflow-hidden` would be a + // coin flip rather than an override. A variant-prefixed utility + // (`sm:overflow-hidden`) is deliberately NOT a match: it only applies inside + // its media query — which does outrank the base utility — so suppressing the + // default would leave the panel clamped but unscrollable below that + // breakpoint. + const overflowClass = /(^|\s)!?overflow-/.test(panelClassName) ? '' : 'overflow-auto'; const heightClass = `max-h-dvh-cap ${insetClass} ${overflowClass}`; const overlay = ( diff --git a/client/src/components/ui/Modal.test.jsx b/client/src/components/ui/Modal.test.jsx index 030db853a0..0f90cc9e5b 100644 --- a/client/src/components/ui/Modal.test.jsx +++ b/client/src/components/ui/Modal.test.jsx @@ -136,4 +136,17 @@ describe('Modal viewport height clamp', () => { // a coin flip rather than an override. expect(dialog).not.toHaveClass('overflow-auto'); }); + + it('still scrolls below a breakpoint when the caller only sets a variant overflow', () => { + render( + {}} ariaLabel="x" panelClassName="lg:overflow-hidden"> +

body

+
+ ); + const dialog = screen.getByRole('dialog'); + // `lg:overflow-hidden` applies only inside its media query — where it + // outranks the base utility anyway — so suppressing the default would + // leave the panel clamped but unscrollable on a phone. + expect(dialog).toHaveClass('overflow-auto'); + }); }); diff --git a/client/src/components/ui/modalPanelHeights.test.js b/client/src/components/ui/modalPanelHeights.test.js index 07810c814c..0e02420f8e 100644 --- a/client/src/components/ui/modalPanelHeights.test.js +++ b/client/src/components/ui/modalPanelHeights.test.js @@ -15,16 +15,48 @@ import { fileURLToPath } from 'url'; * `closeOnEsc={false}` / `closeOnBackdrop={false}` there is then no way out. * * A caller that genuinely wants a *shorter* panel sets the cap instead: - * `panelClassName="… [--dvh-cap:50dvh]"`, which still resolves against the + * `panelClassName="… [--dvh-cap:60dvh]"`, which still resolves against the * dynamic viewport. */ const SRC_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..'); -// panelClassName="…" / {`…`} / {'…'} — the three literal forms in the tree. -const PANEL_CLASS_RE = /panelClassName=(?:"([^"]*)"|\{`([^`]*)`\}|\{'([^']*)'\})/g; +const ATTRIBUTE = 'panelClassName='; const RAW_VH_RE = /max-h-\[[\d.]+vh\]/; +/** + * Every `panelClassName=` attribute value in `source`, as raw text. + * + * A quoted literal is taken verbatim; a `{…}` expression is taken whole (brace + * counting, so a ternary or template literal is captured entire rather than + * truncated at the first `}`). Capturing the expression rather than parsing it + * means a dynamic class string is still scanned for the banned idiom. + */ +function panelClassValues(source) { + const values = []; + let cursor = source.indexOf(ATTRIBUTE); + while (cursor !== -1) { + let i = cursor + ATTRIBUTE.length; + const opener = source[i]; + if (opener === '"' || opener === "'") { + const end = source.indexOf(opener, i + 1); + if (end !== -1) values.push(source.slice(i + 1, end)); + } else if (opener === '{') { + let depth = 0; + for (; i < source.length; i += 1) { + if (source[i] === '{') depth += 1; + else if (source[i] === '}') { + depth -= 1; + if (depth === 0) break; + } + } + values.push(source.slice(cursor + ATTRIBUTE.length + 1, i)); + } + cursor = source.indexOf(ATTRIBUTE, cursor + ATTRIBUTE.length); + } + return values; +} + function collectSourceFiles(dir, out = []) { for (const entry of readdirSync(dir)) { const full = join(dir, entry); @@ -38,24 +70,37 @@ function collectSourceFiles(dir, out = []) { } describe('Modal panel heights', () => { + it('extracts panelClassName values in every syntactic form a caller can use', () => { + // Proves the sweep below can actually see an offender — otherwise a + // collector that silently matched nothing would make it vacuously green. + // The banned classes are interpolated rather than written out so Tailwind's + // source scanner doesn't emit CSS for a fixture nothing renders. + const vh = (n) => `max-h-[${n}vh]`; + const fixture = ` + + + + + + `; + const offenders = panelClassValues(fixture).filter((v) => RAW_VH_RE.test(v)); + expect(offenders).toHaveLength(4); + }); + it('has no call site passing a raw viewport-height clamp in panelClassName', () => { const offenders = []; + let scanned = 0; for (const file of collectSourceFiles(SRC_ROOT)) { const source = readFileSync(file, 'utf8'); - if (!source.includes('panelClassName')) continue; - for (const match of source.matchAll(PANEL_CLASS_RE)) { - const value = match[1] ?? match[2] ?? match[3] ?? ''; + if (!source.includes(ATTRIBUTE)) continue; + for (const value of panelClassValues(source)) { + scanned += 1; if (RAW_VH_RE.test(value)) offenders.push(`${relative(SRC_ROOT, file)}: ${value}`); } } + // Reach check on the extracted values (not merely on files mentioning the + // prop), so a parser that stopped matching fails loudly here. + expect(scanned).toBeGreaterThan(20); expect(offenders).toEqual([]); }); - - it('scans a representative set of Modal call sites (guards the regex itself)', () => { - // A collector that silently matched nothing would make the assertion above - // vacuously green, so pin the sweep's own reach. - const withPanelClass = collectSourceFiles(SRC_ROOT) - .filter((file) => readFileSync(file, 'utf8').includes('panelClassName=')); - expect(withPanelClass.length).toBeGreaterThan(20); - }); });