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..87070f78be 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,18 @@ 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 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 = (
{ 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'); + }); + + 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 new file mode 100644 index 0000000000..0e02420f8e --- /dev/null +++ b/client/src/components/ui/modalPanelHeights.test.js @@ -0,0 +1,106 @@ +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:60dvh]"`, which still resolves against the + * dynamic viewport. + */ + +const SRC_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..'); + +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); + 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('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(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([]); + }); +}); 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. */}