Skip to content

Commit 2c5f49e

Browse files
authored
Fix CI cross-platform issues, release log gate, and test dependencies (#384)
## Summary by Sourcery Implement admin step-up MFA with backend middleware, session tracking, and a shared frontend modal flow, introduce saver modes and data-saver-aware endpoints, add durable upload backups, and tighten UI layout, security, and CI workflows. New Features: - Add step-up MFA flow backed by session mfaVerifiedAt, dedicated auth endpoints, and a reusable frontend modal/provider so privileged admin actions can require recent 2FA verification. - Introduce Data Saver and Battery Saver modes with user preferences, global hooks, and a new settings tab, enabling lighter API responses and reduced animations for opted-in users. - Extend the StudyHub video player with watch-progress persistence, A-B loop controls, and a keyboard shortcut help overlay for study-focused playback. - Add a daily backup job that mirrors the uploads volume to Cloudflare R2 plus a restore script to recover user-uploaded media after volume loss. Bug Fixes: - Fix admin login MFA flag handling to fail closed on missing/errored flags and support a clearly logged emergency override, preventing silent MFA bypass. - Ensure saver-mode preferences are included in authenticated session payloads so global hooks honor server-side settings across devices. - Prevent upload path traversal issues on Windows by hardening managed filename validation against path separators. - Resolve spacing and layout inconsistencies (sticky offsets, grids, skeletons, sidebars, inputs) that caused visible jitter and clipping across key pages. Enhancements: - Add requireRecentMfa middleware and apply it to high-risk admin and payment routes, with comprehensive unit tests and an emergency bypass log trail. - Expose WebAuthn credential last-used timestamps and enrich MFA-related logging to support better security visibility. - Refine moderation and admin handlers with stricter ID validation, origin allowlisting, and safer pagination, aligning with documented security rules. - Migrate several bespoke modals (reports, legal enforcement, attachment preview, AI sheet preview, confirm dialog) onto the shared focus-trapped dialog primitive for consistent accessibility. - Standardize layout tokens for nav height and spacing, update sticky offsets and page scaffolds to use them, and improve mobile behavior via dvh usage and safe-area insets. - Adjust toast durations under battery saver and integrate data-saver awareness into feed and messaging flows to reduce bandwidth and background chatter. - Tighten R2 helper APIs to support per-bucket operations, streaming uploads with explicit content length, and configurable backup cadence and rate limits. Build: - Relax CI release-log enforcement for bot-authored and lockfile-only PRs while narrowing the code-change scope, keeping the human-facing log gate for functional changes. - Update production-mode test fixtures to auto-populate all required secrets and keep security-header tests resilient to new required env vars. CI: - Stop running Android and CodeQL workflows on every pull request, limiting them to main, tags, schedule, or manual runs to reduce unnecessary CI usage. Tests: - Add focused unit tests for step-up MFA middleware, recovery code primitives, WebAuthn routes, data-saver negotiation, and admin MFA fail-closed behavior. - Update existing tests and skips to reflect the new role-picker, AI permission, and prefetch mappings while preserving coverage where behavior is unchanged.
1 parent f67cdb5 commit 2c5f49e

15 files changed

Lines changed: 315 additions & 101 deletions

File tree

backend/src/modules/webauthn/webauthn.routes.js

Lines changed: 20 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ const express = require('express')
22
const requireAuth = require('../../middleware/auth')
33
const requireAdmin = require('../../middleware/requireAdmin')
44
const { captureError } = require('../../monitoring/sentry')
5-
const { signAuthToken, setAuthCookie } = require('../../lib/authTokens')
5+
const { issueAuthenticatedSession } = require('../auth/auth.service')
66
const {
77
generateRegistrationOptions,
88
verifyRegistration,
@@ -174,18 +174,29 @@ router.post('/authenticate/verify', webauthnLimiter, async (req, res) => {
174174
data: { counter: result.newCounter, lastUsedAt: new Date() },
175175
})
176176

177-
// Issue session
178-
const fullUser = await prisma.user.findUnique({
179-
where: { id: user.id },
180-
select: { id: true, username: true, role: true },
177+
// Issue session via the canonical login helper — mirrors
178+
// login.challenge.controller + login.recovery.controller. The previous
179+
// hand-rolled `signAuthToken + setAuthCookie` path bypassed Session
180+
// row creation, so the JWT carried no `jti` and requireAuth left
181+
// `req.sessionJti` undefined. With the wave-12.11 fail-closed
182+
// `requireRecentMfa` middleware, every passkey-authed admin then hit
183+
// `MFA_STEP_UP_REQUIRED { reason: 'no_session' }` on PATCH role /
184+
// trust-level / mfa, DELETE user, and the Stripe sync route, and the
185+
// step-up /verify endpoint 401'd because there was no session row to
186+
// refresh — admins were locked out of every gated route via passkey
187+
// login (wave-12.15 fix from a Codex P1 finding).
188+
//
189+
// Passkey is an explicit AAL2 factor per NIST 800-63B ("something you
190+
// have"), so passing `mfaVerified: true` stamps `Session.mfaVerifiedAt`
191+
// at creation and `requireRecentMfa` permits the next 15 minutes
192+
// without a separate step-up prompt.
193+
const authenticatedUser = await issueAuthenticatedSession(res, user.id, req, null, {
194+
mfaVerified: true,
181195
})
182196

183-
const token = signAuthToken(fullUser)
184-
setAuthCookie(res, token)
185-
186197
res.json({
187198
message: 'Login successful!',
188-
user: { id: fullUser.id, username: fullUser.username, role: fullUser.role },
199+
user: authenticatedUser,
189200
})
190201
} catch (error) {
191202
captureError(error, { route: req.originalUrl, method: req.method })

backend/test/unit/webauthn-routes.unit.test.js

Lines changed: 37 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -48,11 +48,19 @@ const mocks = vi.hoisted(() => {
4848
}
4949
next()
5050
}),
51-
authTokens: {
52-
signAuthToken: vi.fn(() => 'signed-token-xyz'),
53-
setAuthCookie: vi.fn((res) => res),
54-
getAuthTokenFromRequest: vi.fn(() => null),
55-
verifyAuthToken: vi.fn(),
51+
authService: {
52+
// wave-12.15 — passkey verify now delegates to the canonical
53+
// login helper so a real Session row is created with the JWT
54+
// carrying a `jti`. Mock returns the authenticated-user payload
55+
// shape that the helper produces (id, username, role, csrfToken,
56+
// counts, preferences, etc.) so the route handler's `res.json`
57+
// sees a realistic body.
58+
issueAuthenticatedSession: vi.fn(async () => ({
59+
id: 1,
60+
username: 'admin_user',
61+
role: 'admin',
62+
csrfToken: 'csrf-token-xyz',
63+
})),
5664
},
5765
sentry: {
5866
captureError: vi.fn(),
@@ -67,7 +75,7 @@ const mockTargets = new Map([
6775
[require.resolve('../../src/lib/prisma'), mocks.prisma],
6876
[require.resolve('../../src/middleware/auth'), mocks.requireAuth],
6977
[require.resolve('../../src/middleware/requireAdmin'), mocks.requireAdmin],
70-
[require.resolve('../../src/lib/authTokens'), mocks.authTokens],
78+
[require.resolve('../../src/modules/auth/auth.service'), mocks.authService],
7179
[require.resolve('../../src/monitoring/sentry'), mocks.sentry],
7280
[require.resolve('../../src/lib/rateLimiters'), mocks.rateLimiters],
7381
[require.resolve('../../src/lib/webauthn/webauthn'), mocks.webauthnLib],
@@ -301,10 +309,12 @@ describe('POST /api/webauthn/authenticate/verify', () => {
301309
username: 'admin_user',
302310
}
303311

304-
it('updates counter, issues session, and returns user on success', async () => {
305-
mocks.prisma.user.findUnique
306-
.mockResolvedValueOnce({ id: 1, username: 'admin_user', role: 'admin' })
307-
.mockResolvedValueOnce({ id: 1, username: 'admin_user', role: 'admin' })
312+
it('updates counter, issues session via the canonical helper, and returns user on success', async () => {
313+
mocks.prisma.user.findUnique.mockResolvedValueOnce({
314+
id: 1,
315+
username: 'admin_user',
316+
role: 'admin',
317+
})
308318
mocks.prisma.webAuthnCredential.findUnique.mockResolvedValue({
309319
id: 10,
310320
userId: 1,
@@ -319,15 +329,24 @@ describe('POST /api/webauthn/authenticate/verify', () => {
319329
message: expect.stringMatching(/successful/i),
320330
user: { id: 1, username: 'admin_user', role: 'admin' },
321331
})
322-
// wave-12.11 — verify now also stamps lastUsedAt for admin-portal
323-
// visibility. The Date is freshly constructed inside the handler
324-
// so we assert shape, not exact value.
332+
// wave-12.11 — verify also stamps lastUsedAt for admin-portal visibility.
325333
expect(mocks.prisma.webAuthnCredential.update).toHaveBeenCalledWith({
326334
where: { id: 10 },
327335
data: { counter: 5, lastUsedAt: expect.any(Date) },
328336
})
329-
expect(mocks.authTokens.signAuthToken).toHaveBeenCalledTimes(1)
330-
expect(mocks.authTokens.setAuthCookie).toHaveBeenCalledTimes(1)
337+
// wave-12.15 — must go through issueAuthenticatedSession (creates a
338+
// real Session row + JWT with `jti`) so the wave-12.11 fail-closed
339+
// requireRecentMfa middleware doesn't lock passkey-authed admins out
340+
// of step-up-gated routes. mfaVerified:true stamps Session.mfaVerifiedAt
341+
// because passkey is an AAL2 factor per NIST 800-63B.
342+
expect(mocks.authService.issueAuthenticatedSession).toHaveBeenCalledTimes(1)
343+
expect(mocks.authService.issueAuthenticatedSession).toHaveBeenCalledWith(
344+
expect.anything(), // res
345+
1, // userId
346+
expect.anything(), // req
347+
null, // preComputed risk
348+
{ mfaVerified: true },
349+
)
331350
})
332351

333352
it('returns 401 when the WebAuthn library reports a counter/replay failure', async () => {
@@ -347,7 +366,9 @@ describe('POST /api/webauthn/authenticate/verify', () => {
347366

348367
expect(res.status).toBe(401)
349368
expect(mocks.prisma.webAuthnCredential.update).not.toHaveBeenCalled()
350-
expect(mocks.authTokens.signAuthToken).not.toHaveBeenCalled()
369+
// wave-12.15 — failed auth must NOT create a session; the new
370+
// issueAuthenticatedSession path stays unreached on counter/replay.
371+
expect(mocks.authService.issueAuthenticatedSession).not.toHaveBeenCalled()
351372
})
352373

353374
it('returns 401 when the resolved user is not an admin', async () => {

docs/release-log.md

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,63 @@ internal log into this file when they describe user-visible behavior.
2828

2929
## v2.2.0 — public launch ship (2026-04-30)
3030

31+
### Wave-12.15 — Codex P1 + P2 fixes on wave-12.11 / 12.13 (2026-05-28)
32+
33+
Two real findings from a Codex review pass on `3b09f25a`. Both verified against actual code by our own audit loop (CLAUDE.md A21) before fixing.
34+
35+
**P1 (CRITICAL) — Passkey login bypassed `createSession`; admins locked out of step-up-MFA-gated routes.**
36+
37+
`POST /api/webauthn/authenticate/verify` was hand-rolling its own session emission (`signAuthToken(fullUser) + setAuthCookie(res, token)`) instead of going through `issueAuthenticatedSession`. The wave-12.11 `requireRecentMfa` middleware checks `req.sessionJti` (populated by `requireAuth` only when the JWT carries a `jti`, which is set only by `createSession`). Net effect:
38+
39+
- Passkey-authed admins → JWT carries no `jti``req.sessionJti` undefined.
40+
- Every step-up-gated admin route (`DELETE /api/admin/users/:id`, `PATCH /api/admin/users/:id/{role,trust-level,mfa}`, `POST /api/payments/admin/sync-stripe`) rejected with `MFA_STEP_UP_REQUIRED { reason: 'no_session' }`.
41+
- The step-up modal's `POST /api/auth/mfa/step-up/verify` then 401'd with `'No active session to refresh.'` because there was no Session row to update.
42+
- Net: passkey admins could not perform ANY of the 5 protected admin actions. They had to log out and re-login via email-OTP.
43+
44+
Fix: webauthn verify now calls `issueAuthenticatedSession(res, user.id, req, null, { mfaVerified: true })` — mirrors `login.challenge.controller` and `login.recovery.controller`. Passkey is an explicit AAL2 factor per NIST 800-63B ("something you have"), so `mfaVerified: true` is correct: the new Session row's `mfaVerifiedAt` is stamped at creation, and `requireRecentMfa` permits step-up-gated routes for the next 15 minutes without re-prompting.
45+
46+
Updated `backend/test/unit/webauthn-routes.unit.test.js` to mock the canonical helper (`mocks.authService.issueAuthenticatedSession`) and assert it's called with `{ mfaVerified: true }` on the success path AND NOT called on counter/replay-failure paths. 21/21 tests pass.
47+
48+
**P2 (HIGH) — Saver-mode Settings toggle silently did nothing on the current session.**
49+
50+
The wave-12.13 fix had `DataAndBatteryTab` seeding localStorage after save, but the consumers (`SaverModeInitializer`, `useFeedData`, `useMessagingData`) all read `useDataSaver({ serverMode: sessionUser?.preferences?.dataSaverMode })`. The hooks' precedence is `serverMode || readStoredMode()``serverMode` wins as long as session-context holds the old value. Since `usePreferences().save()` only updated its own local React state, `sessionUser` was stale until the next `/api/auth/me` round-trip. The body `[data-battery-saver]` attribute stayed off, Feed/Messages didn't switch to lite mode, and the user thought the toggle was broken.
51+
52+
Fix: `usePreferences().save()` now pulls `setSessionUser` from `useSession()` and pushes the persisted prefs back into session-context after the merge. Every consumer of `user?.preferences` re-derives synchronously on next render — no event plumbing, no `storage` listener (which only fires cross-tab anyway), no hook precedence change. Matches CLAUDE.md A4 ("hydrate UI from the response body's persisted value"). The `DataAndBatteryTab`'s explicit `setStored*` calls stay as belt-and-suspenders (idempotent — the hooks' `serverMode` effect would write the same value to localStorage).
53+
54+
**Files changed:** `backend/src/modules/webauthn/webauthn.routes.js`, `backend/test/unit/webauthn-routes.unit.test.js`, `frontend/studyhub-app/src/pages/settings/settingsState.js`.
55+
56+
Validation: backend lint clean, frontend lint 0 errors (91 warnings = baseline), frontend build green, webauthn 21/21 unit tests pass.
57+
58+
### Wave-12.14 — Spacing / chrome token pass + wave 12.11-12.13 production readiness verification (2026-05-28)
59+
60+
Founder asked for a "fix the spacing everywhere + confirm everything we shipped is production-ready" pass. Two parallel audits:
61+
62+
**Feature audit (wave 12.11-12.13) — clean.** All 11 features from `d1a3abe0` / `e9fc07e6` / `e80214b4` verified end-to-end: admin step-up MFA (`requireRecentMfa` on 5 routes + frontend interceptor + step-up modal + 12/12 unit tests), `WebAuthnCredential.lastUsedAt`, `serializeNote` allowlist (incl. wave-12.13 `revision` re-add), upload volume → R2 backup (`runWithHeartbeat`-wrapped, streamed-not-buffered, `R2_BUCKET_UPLOAD_BACKUP` REQUIRED_IN_PRODUCTION), Data Saver + Battery Saver modes (`useDataSaver` / `useBatterySaver` exports `setStored*` helpers, `SaverModeInitializer` mounted in `App.jsx`, server-side `dataSaverNegotiation` 3-signal contract, 11/11 unit tests), StudyHubPlayer (watch-progress persistence, A-B loop, `?` keyboard help overlay), feed lite mode (`?lite=1`), typing-indicator suppression, battery-saver JS gates in `animations.js` + `Toast.jsx`, session-payload preferences. CLAUDE.md A-rules A5/A6/A7/A8/A10/A11/A12/A13/A14/A16 all clean. 60/60 wave-12 unit tests pass. Nothing to finish.
63+
64+
**Spacing / chrome pass — fixed.** A spacing audit found ~50 visual drifts; the screenshot-visible one was `.feed-page__aside { top: 86px }` floating ~30px below the actual 56px navbar. Foundation tokens added in `index.css` `:root`: `--sh-nav-h: 56px` (single source of truth for sticky offsets) and `--sh-space-xs/sm/md/lg/xl/2xl/3xl` (CANONICAL 4/8/12/16/20/24/32 step aliases — mapped to existing `--space-*` so old call sites keep working). Then migrated the worst drifts:
65+
66+
- **Sticky offsets**`.feed-page__aside`, `.settings-nav`, `PageShell` sidebar, `AiPage` sidebar all now compute `top` from `var(--sh-nav-h)`. No more 74 / 80 / 86px guesses.
67+
- **Split-panel height**`.notes-split-panel` / `.messages-split-panel` switched from `calc(100vh - 80)` to `calc(100dvh - var(--sh-nav-h))` with `100vh` fallback so mobile Safari's URL bar doesn't clip the bottom of the panel.
68+
- **Grid gaps**`.app-three-col-grid` (22 → 20), `.app-two-col-grid` (20), `.profile-cockpit` (20) all on `--sh-space-xl`. Navigating Feed ↔ Sheets ↔ Profile no longer shifts the column gutter.
69+
- **Duplicate `.settings-layout`** — removed the index.css copy (which conflicted on `gap`); canonical definition lives in `responsive.css` with `gap: var(--sh-space-2xl)`.
70+
- **Sidebar rhythm**`.sh-sidebar-nav-link` padding `9px → 8px` (off-step). Phase 1 sectioned-nav section gap 14 → 16 (`--sh-space-lg`); label-to-link gap 6 → 8 (`--sh-space-sm`). Course-entry link padding `'6px 2px' → '10px 8px'` (WCAG 2.5.5 touch target + 8-step).
71+
- **Skeleton CLS**`SkeletonCard` padding `'20px 22px' → var(--card-pad)` (matches real `.sh-card`); `SkeletonFeed` gap 14 → 16; removed the per-card padding override. The shimmer ghost no longer visibly shrinks when content loads.
72+
- **AiBubble** — bubble + chat window `bottom`/`right` use `calc(N + env(safe-area-inset-*, 0px))` so the iPhone home-indicator strip is clear; chat `maxHeight` switched to `100dvh` for the same URL-bar reason.
73+
- **iOS auto-zoom fix**`@media (max-width: 767px) .sh-input { font-size: 16px }` floors the input font so iOS Safari doesn't auto-zoom and jolt the layout on focus. Desktop keeps `--type-sm`.
74+
- **FeedPage banners**`padding: '10px 14px'`, `'12px 14px'`, `'7px 16px'` all migrated to the 16/8-step.
75+
76+
**Code-reviewer pass — 3 real findings caught + fixed in-wave.** A code-reviewer subagent on the diff caught:
77+
78+
1. **CRITICAL**`AppSidebar.jsx` drawer-mode `<aside>` still had `top: 74` hardcoded (line 431). Tablet/phone traffic — most real users — would have hit a sticky drawer trigger sitting 18px below the navbar. Migrated to the token.
79+
2. **IMPORTANT**`AiPage.jsx` chat-panel `height: calc(100vh - ...)` should have been `100dvh` per the inline comment intent. CSS-in-JS can't do the dual-line `100vh / 100dvh` override pattern, so swapped to `100dvh` directly (95%+ browser support since Sep 2022).
80+
3. **IMPORTANT**`.settings-layout` responsive overrides at 1024px + 768px were still in index.css contradicting the "moved to responsive.css" comment, AND the 768 vs 767 breakpoint collision produced a 1-pixel window at exactly 768px where the gap dropped to 12px. Moved BOTH the tablet (768–1024px) and phone (≤767px) overrides into responsive.css with the canonical 767 phone breakpoint, plus migrated the `.settings-nav-btn` mobile overrides over so SettingsPage's phone view still renders correctly.
81+
82+
Bonus migration during the code-reviewer fix pass: also migrated 4 pre-existing hardcoded sticky offsets that didn't ship in this wave's original scope but use the same `top: 74/80` pattern — `index.css .sh-sidebar-sticky`, `MyCoursesPage.jsx`, `ScholarLists.css .scholar-saved__rail`, `ScholarPage.css .scholar-reader__sidecar`. All now compute from `var(--sh-nav-h)`.
83+
84+
Out of scope for this wave (documented in the audit, deferred): a shared `&lt;FormField&gt;` primitive to standardize per-tab form spacing in Settings; a shared `&lt;Modal&gt;` primitive to consolidate the 5 ConfirmDialog reimplementations; bulk migration of remaining ~30 `pageShell(top, bottom)` call sites to one default; bulk 100vh → 100dvh on 40 page roots. Those are structural changes that need founder sign-off on the primitive contract before landing.
85+
86+
Validation: backend lint clean (no changes), frontend lint 0 errors (warning count unchanged at 91 — same warnings as baseline, all pre-existing), frontend build green (1.02s). 12 files changed, ~197 insertions / 74 deletions.
87+
3188
### Wave-12.13 — Codex P1 + P2 fixes on wave-12.11 / 12.12 (2026-05-28)
3289

3390
Two real findings from a Codex review pass on `e9fc07e6`. Both vetted and fixed in our style.

frontend/studyhub-app/src/components/Skeleton.jsx

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -28,15 +28,20 @@ export function Skeleton({ width = '100%', height = 16, circle, size, borderRadi
2828
)
2929
}
3030

31-
/** Card-shaped skeleton with header row + content lines. */
31+
/** Card-shaped skeleton with header row + content lines.
32+
*
33+
* Padding matches `.sh-card` (--card-pad: clamp(16px, ..., 24px)) so the
34+
* loading ghost is the same width/height as the real card. Was '20px 22px'
35+
* which made the shimmer ~4px wider than the resolved content, producing a
36+
* visible CLS jolt the moment data loaded. */
3237
export function SkeletonCard({ style }) {
3338
return (
3439
<div
3540
style={{
3641
background: 'var(--sh-surface, #fff)',
3742
borderRadius: 16,
3843
border: '1px solid var(--sh-border, #e2e8f0)',
39-
padding: '20px 22px',
44+
padding: 'var(--card-pad)',
4045
...style,
4146
}}
4247
>
@@ -116,12 +121,14 @@ export function SkeletonSheetGrid({ count = 4 }) {
116121
)
117122
}
118123

119-
/** Feed skeleton with multiple post cards. */
124+
/** Feed skeleton with multiple post cards. Matches FeedPage main column
125+
* gap (--sh-space-lg = 16px) so the shimmer-to-resolved transition has no
126+
* visible step in the inter-card gutter. */
120127
export function SkeletonFeed({ count = 3 }) {
121128
return (
122-
<div style={{ display: 'grid', gap: 14 }}>
129+
<div style={{ display: 'grid', gap: 'var(--sh-space-lg)' }}>
123130
{Array.from({ length: count }).map((_, i) => (
124-
<SkeletonCard key={i} style={{ padding: '22px 24px' }} />
131+
<SkeletonCard key={i} />
125132
))}
126133
</div>
127134
)

frontend/studyhub-app/src/components/ai/AiBubble.jsx

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -179,8 +179,11 @@ function AiBubbleInner() {
179179
className={!isOpen && chat?.streaming ? 'sh-ai-bubble-streaming' : undefined}
180180
style={{
181181
position: 'fixed',
182-
bottom: 24,
183-
right: 24,
182+
// env(safe-area-inset-*) keeps the bubble clear of the iPhone
183+
// home-indicator strip (and any future curved-edge devices).
184+
// Falls back to 0px on platforms that don't expose insets.
185+
bottom: 'calc(24px + env(safe-area-inset-bottom, 0px))',
186+
right: 'calc(24px + env(safe-area-inset-right, 0px))',
184187
width: 56,
185188
height: 56,
186189
borderRadius: '50%',
@@ -232,11 +235,15 @@ function AiBubbleInner() {
232235
aria-label="Hub AI mini chat"
233236
style={{
234237
position: 'fixed',
235-
bottom: 88,
236-
right: 24,
238+
// Anchor above the bubble (which itself respects safe-area-inset-bottom).
239+
// 88 = bubble height (56) + bubble margin (24) + 8px gap.
240+
bottom: 'calc(88px + env(safe-area-inset-bottom, 0px))',
241+
right: 'calc(24px + env(safe-area-inset-right, 0px))',
237242
width: 'min(420px, calc(100vw - 48px))',
238243
height: 600,
239-
maxHeight: 'calc(100vh - 120px)',
244+
// 100dvh on mobile Safari so the chat doesn't get clipped behind
245+
// the dynamic URL bar.
246+
maxHeight: 'calc(100dvh - 120px - env(safe-area-inset-bottom, 0px))',
240247
background: 'var(--sh-surface)',
241248
borderRadius: 16,
242249
border: '1px solid var(--sh-border)',

0 commit comments

Comments
 (0)