Skip to content

Gate fixes: modal scroll lock + focus restore (PR #100 findings) - #103

Merged
sgardoll merged 5 commits into
milestone/stu-implement-updated-ui-uxfrom
sgardoll/gate-modal-fixes
Sep 28, 2026
Merged

sgardoll merged 5 commits into
milestone/stu-implement-updated-ui-uxfrom
sgardoll/gate-modal-fixes

Conversation

@sgardoll

@sgardoll sgardoll commented Sep 28, 2026 •

Copy link
Copy Markdown
Owner

Gate fixes for PR #100 review findings (base head 6dd8edb)

Branch sgardoll/gate-modal-fixes → milestone/stu-implement-updated-ui-ux.
Files: src/sharedControls.js, app.js (F3 gate only), new e2e/gate-modal-behavior.spec.js, e2e/stu391-tutorial-onboarding.spec.js. No index.html or other src changes.


F1 — "Repeated modal opens lock scrolling" (Greptile P2, src/sharedControls.js:46-52)

Opening the already active modal saves "hidden" as the page's previous overflow value. Closing it then restores "hidden", leaving the page unable to scroll. The walkthrough's blur and delayed Tab handlers can both open that same modal, so repeated opens should preserve the original value.

Fix. A repeated open of the already active modal is now idempotent: openModal keeps the return target and the overflow saved before the first open, and setBackgroundInert records each child's pre-modal state only once (the repeated call used to overwrite the saved state with the already-inert state, so the close never restored the background).

Fail → pass evidence (e2e/gate-modal-behavior.spec.js test 1: open walkthrough → repeat open → close):

$ git checkout HEAD -- src/sharedControls.js   # base shell
$ CI=1 CCC_TEST_PORT=3103 npx playwright test e2e/gate-modal-behavior.spec.js --retries=0
✘ ... a repeated open of the active modal keeps the page scroll lock and background state intact
    expect(await page.evaluate(() => document.body.style.overflow)).not.toBe("hidden");
    Expected: not "hidden"   Received: "hidden"        # close restored "hidden"
1 failed

$ cp <fixed> src/sharedControls.js
$ CI=1 CCC_TEST_PORT=3103 npx playwright test e2e/gate-modal-behavior.spec.js --retries=0
2 passed

The same test also asserts the background header is inert === false and aria-hidden === null after the final close (fails on base with inert === true, aria-hidden === "true").

F2 — "Replacement dialog can restore focus into hidden content" (Devin, src/sharedControls.js:40-43)

openModal saves focus after closing the previous dialog. Closing the replacement can return focus to a button inside the hidden dialog.

Fix. The return target is captured before the replaced dialog closes. If the active element (or explicit trigger) lives inside the replaced dialog, the replacement falls back to whatever opened that dialog — so a replacement's close never focuses content the replacement hid.

Fail → pass evidence (test 2: focus a body trigger → open walkthrough → focus settles inside it → openApiKeysModal() replaces it → close replacement):

$ git checkout HEAD -- src/sharedControls.js   # base shell
$ CI=1 CCC_TEST_PORT=3103 npx playwright test e2e/gate-modal-behavior.spec.js --retries=0
✘ ... a replacement dialog never returns focus into the dialog it hid
    expect(focused.insideWalkthrough).toBe(false);
    Expected: false   Received: true                   # focus restored into the hidden tour
1 failed

$ cp <fixed> src/sharedControls.js
2 passed   # activeElement is the original trigger, not the hidden tour

F3 — e2e/stu391-tutorial-onboarding.spec.js:260 CI flake (30s timeout in waitForWalkthroughOpen)

Root cause (refutes the F1 hypothesis; evidence below). The failing attempt is the first reopenWalkthrough after page.goto("/") (CI stack: waitForWalkthroughOpen ← reopenWalkthrough ← spec:271). CI log for run 36351586609:

Error: page.waitForFunction: Test timeout of 30000ms exceeded.
  at waitForWalkthroughOpen (.../stu391-tutorial-onboarding.spec.js:60:14)
  at reopenWalkthrough (.../stu391-tutorial-onboarding.spec.js:117:9)
  at (.../stu391-tutorial-onboarding.spec.js:271:5)

The spec's signedInContext() fixture is an out-of-runs free user (usage_count: 2, planLimits.free = 2). After the test clicks #wt-reopen and the tour opens, the async identity resolution runs updateUsageDisplay() → showPaywallExhausted(), which unconditionally closeModal(walkthroughModal)s the just-opened tour. The open is undone asynchronously and waitForWalkthroughOpen never settles. Under CPU contention this reproduces reliably; a stack trace captured in the page pins it exactly:

at closeModal (src/sharedControls.js:74:19)
at showPaywallExhausted (app.js:5902:25)
at updateUsageDisplay (app.js:6210:5)
at resolveIdentity (app.js:5725:7)

Reproduction harness (25 iterations, 12 CPU burners, same fixture), page state 400 ms after the click: opens: 1 (handler ran), closes: 0, openClass: false, aria-hidden: "true", transform: matrix(1,0,0,1,0,0) (openModal ran), hash: "" (no navigation). Repeated-open corruption is not involved.

Fix (app-side, head f3b5787; dismissWalkthrough gate). showPaywallExhausted now dismisses an open walkthrough only when the caller passes dismissWalkthrough: true. The exhausted surface still renders for every caller — passive usage rendering included — but only the run-attempt path may take a tour away:

  • canRunPipeline (app.js:5883) passes { openModal: true, dismissWalkthrough: true }, so an out-of-runs run still surfaces the paywall in place of the tour.
  • updateUsageDisplay (app.js:6219) calls showPaywallExhausted(count, limit) with no options, so the startup identity resolution and the post-run reconciliation surface the exhausted state without closing a tour the user opened.

reopenWalkthrough no longer waits for the usage decision (the earlier spec-side waitForUsageDecision wait was removed in f3b5787): the click can land before or after the decision, the race is exercised, and the tour survives either order.

Regression test (same spec). The identity response is gated until the tour is open, then answered with an exhausted free user (usage_count = PLAN_LIMITS.free). Assertions: the passive resolution renders #paywall-exhausted while the walkthrough stays .open; a subsequent runThinkingPipeline() closes the walkthrough, keeps #paywall-exhausted visible, and opens #pricing-modal.

CI on pushed head f3b5787

CI run 36393187510 — success on the first attempt, covering the full sequence: npm test, npm run build, npm run test:ui (including the F3 regression test), npm run test:accessibility, npm run test:deploy-guard, npm run test:proxies, bundle leak scan.

F1/F2 code is unchanged since c4783ce, where the local full sequence passed: npm test 354 passed; build ok; test:ui 208 passed (206 baseline + 2 new); accessibility 7 shells; deploy-guard 9/9; proxies 30/30; no bundle leaks.

Not implemented / left alone

  • The two pre-existing misplaced { timeout: 8000 } arguments in this spec (page.waitForFunction(fn, {timeout}) — the object lands in the arg slot, so the default 30s applies) are untouched: fixing the slot would shorten the effective timeout and is out of scope.
  • The only app.js change is the F3 dismissWalkthrough gate; index.html, other src modules, and scripts are untouched. dist/ and screenshots/ were restored after the UI suite rewrote them.

Uncertainty / judgement calls

  • dismissWalkthrough is deliberately opt-in: any future caller that wants an open tour taken away must pass it; passive renders never do.
  • The regression test gates the identity response and imports PLAN_LIMITS from src/plansSurface.js; if the fixture contract or that module moves, the test fails loudly.
  • Separately observed (not touched): an earlier CI run had a hard failure in e2e/stu385-composer-settings.spec.js ("a model chosen in the dialog is what the generation request carries", generatorModels === [] on all 3 attempts). It passes locally; likely another owner's concern.

PR #100 review findings on the shared modal shell:

F1 (Greptile P2): a repeated open of the already active modal — the
walkthrough's blur and delayed Tab handlers both do this — saved "hidden" as
the page's previous overflow and re-recorded the already-inert background.
The eventual close restored "hidden" and left the background inert and
aria-hidden, locking the page. A repeated open is now idempotent: it keeps
the return target and the overflow saved before the first open, and
setBackgroundInert records each child's pre-modal state once.

F2 (Devin): openModal captured the return target after closing the replaced
dialog, so a replacement opened from a control inside the previous dialog
restored focus into the dialog it hid on close. The return target is now
captured before the close and, when the active element lived inside the
replaced dialog, falls back to whatever opened that dialog.

Regression coverage: e2e/gate-modal-behavior.spec.js fails on base head
6dd8edb (close leaves overflow "hidden"; replacement close focuses a control
inside the hidden walkthrough) and passes with this fix.

F3 root cause (stu391 CI flake): the spec's signed-in fixture is an
out-of-runs free user (usage_count 2, free limit 2), so the async identity
resolution runs showPaywallExhausted, which closes an open walkthrough. When
that landed after the spec's reopen click the tour was closed asynchronously
and waitForWalkthroughOpen never settled (captured stack:
resolveIdentity → updateUsageDisplay → showPaywallExhausted → closeModal).
reopenWalkthrough now waits for the persisted usage decision before clicking.
The app-side race (passive usage rendering closing a user-opened modal) is
left untouched here; app.js is outside this change's ownership.
@sgardoll
sgardoll marked this pull request as ready for review September 28, 2026 07:10
@greptile-apps

greptile-apps Bot commented Sep 28, 2026

Copy link
Copy Markdown
Contributor

This PR does not match any of the 1 configured review trigger rule.

devin-ai-integration[bot]

This comment was marked as resolved.

showPaywallExhausted closed any open walkthrough whenever the exhausted
surface rendered, including the passive startup render that follows the
async identity response. A user who opened the tour before the usage
decision resolved lost it as the response landed. Gate the close on an
explicit dismissWalkthrough option passed only by canRunPipeline, so an
out-of-runs run attempt still surfaces the paywall in place of the tour.

The STU-391 regression test gates the identity response to pin the race:
the tour stays open for a passive resolution, while a run attempt still
closes it and opens the pricing modal. The stu391 waitForUsageDecision
helper is removed, restoring the flow that exercises the race itself.
…fixes

Resolve the openModal conflict in favour of the base side. Base's version
is a strict superset: it additionally handles modal.contains(target) so
focus cannot be stranded inside a programmatically reopened dialog, and it
gates the overflow save and setBackgroundInert behind `activeModal !==
modal`. The PR's block called setBackgroundInert unconditionally, which on
a repeated open re-recorded the already-inerted state and would have left
the background permanently inert after close.

With that gate in place, also revert the PR's setBackgroundInert guard.
setBackgroundInert has exactly one call site, inside the `activeModal !==
modal` branch, and restoreBackground() clears inertState whenever
activeModal is cleared, so the map is always empty on entry and the
`!inertState.has(child)` branch is unreachable.

Trim the now-stale clause in showPaywallExhausted that justified the
dismissWalkthrough opt-in via updateUsageDisplay: base's !hasActiveModal()
gate means updateUsageDisplay can no longer reach the dismissing path.

@devin-ai-integration devin-ai-integration Bot left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note

Newer findings are available below. Devin Review posted a newer report on this PR, in addition to the findings presented here.

Devin Review found 1 new potential issue.

Devin Review

Comment thread app.js Outdated
Comment on lines +5913 to +5915
if (options.dismissWalkthrough) {
const walkthroughModal = document.getElementById('walkthrough-modal')
if (walkthroughModal) closeModal(walkthroughModal, { restoreFocus: false })

@devin-ai-integration devin-ai-integration Bot Sep 28, 2026 •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Exhausted paywall stays hidden after tour

When quota resolves during an open tour, dismissWalkthrough never reaches the paywall because updateUsageDisplay skips active modals. Closing the tour does not refresh usage, leaving the exhausted paywall hidden.

Learn more

The usage display normally swaps the ready state for the exhausted paywall when a user reaches the run limit. updateUsageDisplay does not call showPaywallExhausted if any modal is active, even though this change makes the paywall renderer safe for an open walkthrough. It hides the paywall instead. closeWalkthroughModal only closes the dialog and does not rerun the usage display, so the exhausted surface remains hidden when the tour closes. The run gate still prevents a generation attempt, but the passive exhausted state is absent.

Example: A guest opens the tour with zero locally cached runs. Identity then reports two runs, the free-tier limit. The paywall remains hidden while the tour is open and after the guest closes it, although the new regression test expects it behind the tour.

Recommended fix: Adjust updateUsageDisplay to render exhausted usage while the walkthrough is open without dismissing it, or rerender on walkthrough close. Preserve the deferral for other active dialogs if swapping the stage beneath them is undesirable, and test the state both before and after closing the tour.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

…s guard

PR #103 added an opt-in `options.dismissWalkthrough` flag to
showPaywallExhausted: always render the paywall, and only tear down an open
tour when a caller passes the flag. The milestone base already carries a
different fix (37ffe47): `updateUsageDisplay` guards its call with
`!hasActiveModal()`, deferring the ENTIRE paywall while any dialog is open.
The two are mutually exclusive, and the test written against the opt-in
asserted a state base's guard makes unreachable — a passive exhausted
identity resolution surfacing `#paywall-exhausted` beside a still-open tour.

Base's mechanism wins: `showPaywallExhausted` returns to closing the
walkthrough unconditionally, so app.js is now identical to the milestone
base, and the opt-in and its comment are gone.

The test now pins base's actual contract: a passive startup identity
resolution for an exhausted free user defers the paywall entirely and leaves
the open tour alone; once the tour is closed, the next usage decision does
surface the paywall (deferral is a delay, not a suppression); and an active
out-of-runs `runThinkingPipeline()` still surfaces the paywall, closes the
walkthrough, and opens pricing. The decision's persisted metered count is
used as the "the decision landed" signal, so the negative assertion cannot
pass vacuously — removing `!hasActiveModal()` makes it fail.

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 0 new potential issues.

Devin Review

@sgardoll
sgardoll merged commit a2a9e82 into milestone/stu-implement-updated-ui-ux Sep 28, 2026
3 checks passed
sgardoll pushed a commit that referenced this pull request Sep 28, 2026
…u445-exact-html-fidelity

Re-sync STU-445 onto base f1c819d (merges #108 -> #104 -> #103).

Single conflict, in index.html, comment-only: the base adds a five-line
comment above <div class="pipeline-view"> documenting #104's
initPipelinePanelOrder focus-order contract. Resolved by taking the base
side's comment. The base's #104 CSS changes (dropping grid-template-areas
in favour of JS-driven region ordering) auto-merge and are preserved.

dist/ regenerated from the merged sources: app.js differs from both parents,
so the previous bundle was pre-merge. index-CD_p5Yhb.js -> index-C31N1DOp.js.
CSS is byte-identical (index-BQUorxWE.css), and a second build is a no-op.

npm test 388/388 green (the base contributes 3 tests over the prior 385).
No test changed, skipped, or weakened by this merge.
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.

1 participant