Skip to content

Fix #4: keep quibble usable when a modal dialog is open - #6

Merged
river0825 merged 6 commits into
mainfrom
fix/issue-4-modal-clickable
Jun 18, 2026
Merged

river0825 merged 6 commits into
mainfrom
fix/issue-4-modal-clickable

Conversation

@river0825

Copy link
Copy Markdown
Contributor

Problem

Closes #4. When a modal popup dialog is open, quibble's "Element" button and its hover/ring overlays were unreachable — users could not click or select items.

Root cause

A modal <dialog> (showModal()) and fullscreen elements render in the browser top layer, which paints above all normal-DOM content regardless of z-index, and its backdrop covers the whole viewport. quibble's bar and overlays lived on <body>, so once a modal opened:

  • the "Element" button was unreachable behind the dialog backdrop (verified: elementFromPoint over the button returned the DIALOG), and
  • the hover box / selection ring / composer were painted behind the dialog.

I verified that the Popover API does not beat a modal dialog, so the only reliable way to sit above one is to live inside the top-layer element.

Fix

  • All quibble surfaces (bar, bubble, composer, panel, rings, hover) now sit in a single qb-root container (display:contents, so fixed positioning is unaffected).
  • While a modal is open, the container is moved into the topmost :modal element; otherwise it lives on <body>.
  • A MutationObserver tracks dialogs opening/closing (open attribute) and being added/removed, so the UI is also rescued if a framework unmounts a dialog with the container still inside it.
  • Overlay-based modals (plain high z-index divs, no top layer) are handled separately by raising quibble's own z-indexes to just below the max int.

Verification (real browser, Chrome)

Scenario Before After
Native modal <dialog> — click "Element" ❌ unreachable ✅ reachable
Pick / comment item inside modal dialog ❌ hidden ✅ hover + ring + composer on top
Dialog closed → tool back on page — ✅ rehomed to <body>
Framework unmounts open dialog UI destroyed ✅ rescued, count intact
High-z overlay modal (regression) ring/hover could hide ✅ z-index wins, still works
Normal page (regression) ✅ ✅ bar fixed bottom-right, picking works

Zero-dependency, no API changes. Graceful degradation on browsers without :modal (falls back to <body> + the z-index bump).

🤖 Generated with Claude Code

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Code Review

This pull request introduces a top-layer host container (qbRoot) to dynamically rehome quibble's UI elements inside modal <dialog> elements or fullscreen elements, ensuring they render above browser top-layer components. It also increases the z-index values of quibble's elements to sit just below the maximum integer. The review feedback highlights several important edge cases to improve robustness, including handling early script execution when document.body is null, listening to the fullscreenchange event, preventing click propagation to avoid closing parent modals, and adding a safety guard for null hosts in rehome().

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment thread quibble.js Outdated
Comment on lines 535 to 537
rehome();
if (document.readyState === "complete") setTimeout(reapply, 80);
else window.addEventListener("load", function () { setTimeout(reapply, 80); });

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

high

If the script is loaded in the <head> of the document, document.body will be null during the initial synchronous execution. In this case, the initial rehome() call does nothing, and qbRoot remains detached. Since reapply() does not call rehome(), the quibble UI (qbRoot and the floating feedback bar) may never be appended to the DOM if no subsequent mutations trigger the MutationObserver.

To ensure the UI is always attached once the DOM is ready, we should call rehome() inside the deferred boot/reapply sequence.

  rehome();
  function boot() { rehome(); reapply(); }
  if (document.readyState === "complete") setTimeout(boot, 80);
  else window.addEventListener("load", function () { setTimeout(boot, 80); });

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 88759a2. boot() now re-runs rehome() once the DOM is ready, so a script loaded in <head> (where document.body is null on first run) still attaches qbRoot — quibble.js:544-549. Verified with the new demo/head-load.html: the floating bar appears even though the script tag is in <head>.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Note: after rebasing this branch onto main, the commit SHA changed from 88759a2 to 1d9a5ad, and the line moved to quibble.js:728-733. Fix unchanged.

Comment thread quibble.js Outdated
Comment on lines +192 to +196
/* React to dialogs opening/closing (the `open` attribute) and to dialogs being
added/removed (e.g. a framework unmounting one with qbRoot still inside it). */
try {
new MutationObserver(rehomeSoon).observe(document.documentElement, { subtree: true, childList: true, attributes: true, attributeFilter: ["open"] });
} catch (e) {}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

While the :modal pseudo-class matches fullscreen elements, entering or exiting fullscreen mode does not trigger the MutationObserver (as it doesn't change the open attribute or mutate the DOM tree structure). To support fullscreen elements seamlessly as described, we should also listen to the fullscreenchange event.

Suggested change
/* React to dialogs opening/closing (the `open` attribute) and to dialogs being
added/removed (e.g. a framework unmounting one with qbRoot still inside it). */
try {
new MutationObserver(rehomeSoon).observe(document.documentElement, { subtree: true, childList: true, attributes: true, attributeFilter: ["open"] });
} catch (e) {}
/* React to dialogs opening/closing (the `open` attribute), dialogs being
added/removed (e.g. a framework unmounting one with qbRoot still inside it),
and fullscreen state changes. */
try {
new MutationObserver(rehomeSoon).observe(document.documentElement, { subtree: true, childList: true, attributes: true, attributeFilter: ["open"] });
} catch (e) {}
document.addEventListener("fullscreenchange", rehomeSoon);

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 88759a2 — added document.addEventListener("fullscreenchange", rehomeSoon) at quibble.js:205, since entering/exiting fullscreen mutates neither the DOM nor the open attribute. Verified: clicking the fullscreen button in demo/index.html rehomes qbRoot into the fullscreen element (fullscreenElement and qbRoot's parent both become the hero).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Note: post-rebase the resolving commit is 1d9a5ad and the line is now quibble.js:262 (fullscreenchange listener). Fix unchanged.

Comment thread quibble.js
Comment on lines +175 to +176
var qbRoot = document.createElement("div");
qbRoot.className = "qb-root";

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

When qbRoot is hosted inside a modal dialog, clicking on quibble's UI elements (like the panel or buttons) might trigger the modal's "click outside to close" handlers, because quibble's elements are not part of the modal's inner content container. Stopping propagation of click events on qbRoot prevents this issue.

  var qbRoot = document.createElement("div");
  qbRoot.className = "qb-root";
  qbRoot.addEventListener("click", function (e) { e.stopPropagation(); });

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 88759a2 — qbRoot now stops click propagation (quibble.js:180), so a click on quibble's own chrome can't reach a parent modal's click-outside-to-close handler. Verified with the light-dismiss dialog in demo/index.html: clicking the Feedback bar/panel no longer dismisses the modal.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Note: post-rebase the resolving commit is 1d9a5ad and the line is now quibble.js:237 (qbRoot click stopPropagation). Fix unchanged.

Comment thread quibble.js
Comment on lines +182 to +185
function rehome() {
var host = topLayerHost() || document.body;
if (qbRoot.parentNode !== host) { host.appendChild(qbRoot); layoutRings(); }
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

Note that when qbRoot is moved inside a modal <dialog> (or any other :modal element), if that element or any of its ancestors has a CSS transform, perspective, or filter property, it will establish a new containing block for all descendants with position: fixed.

This will cause quibble's fixed-position elements (like rings, hover overlays, bubble, and composer) to be positioned relative to that transformed element instead of the viewport, leading to misalignment.

While a full workaround (traversing ancestors to compute containing block offsets) might be too complex for this lightweight tool, it is a known limitation worth documenting or keeping in mind if users report alignment issues with animated or transformed modals.

Additionally, we can simplify the host resolution and add a safety guard to prevent errors if host is null during early boot.

Suggested change
function rehome() {
var host = topLayerHost() || document.body;
if (qbRoot.parentNode !== host) { host.appendChild(qbRoot); layoutRings(); }
}
function rehome() {
var host = topLayerHost();
if (host && qbRoot.parentNode !== host) { host.appendChild(qbRoot); layoutRings(); }
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 88759a2 — rehome() now uses var host = topLayerHost(); with a host && null guard so it safely no-ops during early boot (quibble.js:186-191). The transform/perspective/filter containing-block caveat is documented as an accepted limitation in the comment directly above it.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Note: post-rebase the resolving commit is 1d9a5ad and the lines are now quibble.js:243-248 (topLayerHost() null-guard). Fix unchanged.

river0825 pushed a commit that referenced this pull request Jun 18, 2026
- rehome() in deferred boot() so UI attaches when script loads in <head>
  (document.body was null during the synchronous boot)
- listen for fullscreenchange so the UI rehomes on fullscreen enter/exit
  (it mutates neither the DOM tree nor the dialog `open` attribute)
- stopPropagation on qbRoot clicks so quibble's chrome can't trigger a
  parent modal's click-outside-to-close handler
- null-guard topLayerHost() in rehome(); document the transformed-ancestor
  fixed-positioning limitation

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@river0825

Copy link
Copy Markdown
Contributor Author

Addressed all four review comments in 88759a2:

  1. head-load (high) — rehome() now runs inside the deferred boot() alongside reapply(), so the UI attaches even when the script is loaded in <head> (where document.body was null during the synchronous boot).
  2. fullscreenchange (medium) — added document.addEventListener("fullscreenchange", rehomeSoon), since entering/exiting fullscreen mutates neither the DOM tree nor the open attribute.
  3. modal click-through (medium) — qbRoot now stops click propagation so quibble's chrome can't trigger a parent modal's click-outside-to-close handler. Verified the picking listener is capture-phase, so it still fires.
  4. rehome null-guard (medium) — simplified to var host = topLayerHost(); if (host && ...) and documented the transformed-ancestor fixed-positioning limitation inline.

node --check and the sample-JSON validation both pass.

river0825 pushed a commit that referenced this pull request Jun 18, 2026
Extends the demo into a manual test harness for the issue #4 fixes
reviewed on PR #6:
- click-outside-to-close dialog verifies qbRoot's stopPropagation keeps
  quibble's own chrome from dismissing the modal
- a fullscreen button verifies quibble rehomes on fullscreenchange
- head-load.html loads quibble in <head> (document.body null at boot) to
  verify the deferred boot() still attaches the UI

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
riverlin-onr and others added 6 commits June 18, 2026 13:30
A modal <dialog> (showModal) and fullscreen elements render in the browser
"top layer", which paints above all normal-DOM content regardless of z-index.
quibble's bar and overlays lived on <body>, so once a modal opened the
"Element" button was unreachable behind the dialog backdrop and the hover/ring
overlays were hidden — the item could not be clicked or selected (issue #4).

A popover does not beat a modal dialog (verified), so the only reliable fix is
to live inside the top-layer element. All quibble surfaces now sit in a single
`qb-root` container that is moved into the topmost open modal (`:modal`) while
one is open, and back to <body> otherwise. A MutationObserver tracks dialogs
opening/closing and being added/removed, so the UI is also rescued if a
framework unmounts a dialog with the container still inside it.

Overlay-based modals (plain high z-index divs, no top layer) are handled
separately by raising quibble's own z-indexes to just below the max int.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- rehome() in deferred boot() so UI attaches when script loads in <head>
  (document.body was null during the synchronous boot)
- listen for fullscreenchange so the UI rehomes on fullscreen enter/exit
  (it mutates neither the DOM tree nor the dialog `open` attribute)
- stopPropagation on qbRoot clicks so quibble's chrome can't trigger a
  parent modal's click-outside-to-close handler
- null-guard topLayerHost() in rehome(); document the transformed-ancestor
  fixed-positioning limitation

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds a dropdown on the GitHub Pages page that loads quibble from any
published version via ?ver=, defaulting to the moving @v1 tag. The list
is fetched live from the jsDelivr API so RC pre-releases appear
automatically. Switching reloads the page (quibble loads once per page).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds a Tailwind-styled <dialog> opened via a button, so the demo can
exercise quibble's behavior inside a top-layer modal (issue #4) without
disturbing the existing sample-page content.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Extends the demo into a manual test harness for the issue #4 fixes
reviewed on PR #6:
- click-outside-to-close dialog verifies qbRoot's stopPropagation keeps
  quibble's own chrome from dismissing the modal
- a fullscreen button verifies quibble rehomes on fullscreenchange
- head-load.html loads quibble in <head> (document.body null at boot) to
  verify the deferred boot() still attaches the UI

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@river0825
river0825 force-pushed the fix/issue-4-modal-clickable branch from c17270c to b4156fc Compare June 18, 2026 05:33
@river0825
river0825 merged commit b043ab5 into main Jun 18, 2026
1 check passed
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.

Item is not clickable when a popup dialog is open

2 participants