fix: clamp modal dialogs to the visible viewport so iOS Safari can't hide their buttons (#5665) - #5809
Merged
Conversation
…hide their buttons (#5665) 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
… harden the migration guard (#5665) 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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
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 reach them. iOS Safari's retractable browser chrome makes
100vhtaller than the visible viewport, and every modal sized itself with its own rawmax-h-[NNvh]— nine different values across 27 call sites. Because the modal overlay isfixed inset-0(the small viewport) and centres its panel withitems-centerand no scroll of its own, a taller panel 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
Modalprimitive now owns the clamp, for the same reason portaling is unconditional there — so no call site has to remember it.client/src/components/ui/Modal.jsxclamps every panel with the existingmax-h-dvh-caputility, minus a per-align--dvh-insetthat gives back the overlay's own padding (center2rem,top10dvh + 1rem,none0).ALIGN_CLASSES.topmoves frompt-[10vh]topt-[10dvh]in the same edit.overflow-autounless the caller declares an unprefixed overflow of its own — Tailwind precedence follows CSS source order, not class-string order, so emitting both would be a coin flip rather than an override. A variant-prefixed utility (lg:overflow-hidden) deliberately does not count: it applies only inside its media query, where it outranks the base anyway, so treating it as a declaration would leave the panel clamped but unscrollable on a phone.max-h-[NNvh](and the now-redundantoverflow-auto/overflow-y-auto), so nine divergent dialog heights collapse to one shared behaviour.ManuscriptReadAloudkept its clamp on an inner wrapper rather than inpanelClassName; the flex column is hoisted onto the panel so the primitive's clamp constrains its scroll region.A dialog that deliberately wants to be shorter than the screen now says so with
[--dvh-cap:NNdvh], which still tracks the visible viewport instead of pinning tovh.Deliberately left alone as out of scope for the modal clamp:
TerminalCoSPanel'smax-h-[50vh] lg:max-h-none(a partial-height inner region with a responsive override that a utility swap would put at risk) and the<=42vhinner list caps, per the issue's "leave small caps alone" rule.Test plan
client/src/components/ui/Modal.test.jsx— newModal viewport height clampblock: the panel carriesmax-h-dvh-capwith nopanelClassName;align="top"insets by its own offset; a caller'spanelClassNameis appended after the clamp and can shorten the panel via--dvh-cap; the scroll default yields to a caller's ownoverflow-hiddenbut survives a variant-onlylg:overflow-hidden.client/src/components/ui/modalPanelHeights.test.js(new) — scans the client tree and fails if anypanelClassNamevalue carries a rawmax-h-[NNvh], pinning the migration. It parses braced expressions whole (ternaries and template literals included), counts what it parsed as a reach check, and proves against a fixture that it catches an offender in each syntactic form, so it cannot pass vacuously. Verified failing by reintroducing the idiom at a call site.cd client && npm test— 856 files / 10726 tests pass.cd client && npm run build— passes; the generated stylesheet emits every new arbitrary-property utility (--dvh-inset:2rem,--dvh-inset:calc(10dvh + 1rem),--dvh-inset:0px,--dvh-cap:60dvh|70dvh|80dvh) and no longer contains anymax-h-[NNvh]rule.Closes #5665
https://claude.ai/code/session_01GMxEz43s3YCLaVZV9KmVwE