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. */}