Skip to content

fix: clamp modal dialogs to the visible viewport so iOS Safari can't hide their buttons (#5665) - #5809

Merged
atomantic merged 2 commits into
mainfrom
claim/issue-5665
Sep 2, 2026
Merged

fix: clamp modal dialogs to the visible viewport so iOS Safari can't hide their buttons (#5665)#5809
atomantic merged 2 commits into
mainfrom
claim/issue-5665

Conversation

@atomantic

Copy link
Copy Markdown
Owner

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 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 fixed inset-0 (the small viewport) and centres its panel with items-center and 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 Modal primitive 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.jsx clamps every panel with the existing max-h-dvh-cap utility, minus a per-align --dvh-inset that gives back the overlay's own padding (center 2rem, top 10dvh + 1rem, none 0). ALIGN_CLASSES.top moves from pt-[10vh] to pt-[10dvh] in the same edit.
  • The panel also gets overflow-auto unless 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.
  • All 27 call sites drop their hand-rolled max-h-[NNvh] (and the now-redundant overflow-auto / overflow-y-auto), so nine divergent dialog heights collapse to one shared behaviour. ManuscriptReadAloud kept its clamp on an inner wrapper rather than in panelClassName; the flex column is hoisted onto the panel so the primitive's clamp constrains its scroll region.
  • Five non-modal full-height surfaces move to the same dynamic-viewport sizing with an explicit cap: the goals tree popover, the alcohol and nicotine log tables, the shell provider launcher, and the expanded agent output.

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 to vh.

Deliberately left alone as out of scope for the modal clamp: TerminalCoSPanel's max-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 <=42vh inner list caps, per the issue's "leave small caps alone" rule.

Test plan

  • client/src/components/ui/Modal.test.jsx — new Modal viewport height clamp block: the panel carries max-h-dvh-cap with no panelClassName; align="top" insets by its own offset; a caller's panelClassName is appended after the clamp and can shorten the panel via --dvh-cap; the scroll default yields to a caller's own overflow-hidden but survives a variant-only lg:overflow-hidden.
  • client/src/components/ui/modalPanelHeights.test.js (new) — scans the client tree and fails if any panelClassName value carries a raw max-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 any max-h-[NNvh] rule.

Closes #5665

https://claude.ai/code/session_01GMxEz43s3YCLaVZV9KmVwE

…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
@atomantic
atomantic merged commit ad4c07c into main Sep 2, 2026
7 checks passed
@atomantic
atomantic deleted the claim/issue-5665 branch September 2, 2026 05:38
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Every modal hard-codes its own max-h in vh, so dialogs clip their header and action buttons under iOS Safari chrome

1 participant