From db1a363c348cdf28be42eed032bdb9502d3acc18 Mon Sep 17 00:00:00 2001 From: Nick Bernal Date: Thu, 19 Mar 2026 22:00:30 -0700 Subject: [PATCH] fix: isolate document surface and toolbar/ruler stacking contexts --- .../layout-engine/painters/dom/src/styles.ts | 4 + .../src/components/SuperEditor.vue | 3 + .../src/components/toolbar/Toolbar.vue | 4 +- .../presentation-editor/PresentationEditor.ts | 1 + .../src/assets/styles/helpers/variables.css | 5 +- .../tests/sdt/structured-content.spec.ts | 88 +++++++++++++++++++ 6 files changed, 103 insertions(+), 2 deletions(-) diff --git a/packages/layout-engine/painters/dom/src/styles.ts b/packages/layout-engine/painters/dom/src/styles.ts index 05ad697d52..285f4f1083 100644 --- a/packages/layout-engine/painters/dom/src/styles.ts +++ b/packages/layout-engine/painters/dom/src/styles.ts @@ -32,6 +32,9 @@ export const containerStyles: Partial = { padding: '0', // gap is set dynamically by renderer based on pageGap option (default: 24px) overflowY: 'auto', + // Contain child z-indices (SDT labels, hover states) so they cannot escape + // above sibling UI surfaces like the toolbar or ruler. (SD-2015) + isolation: 'isolate', }; export const containerStylesHorizontal: Partial = { @@ -44,6 +47,7 @@ export const containerStylesHorizontal: Partial = { // gap is set dynamically by renderer based on pageGap option (default: 20px for horizontal) overflowX: 'auto', minHeight: '100%', + isolation: 'isolate', }; export const spreadStyles: Partial = { diff --git a/packages/super-editor/src/components/SuperEditor.vue b/packages/super-editor/src/components/SuperEditor.vue index a6c8e2ad9a..772f117440 100644 --- a/packages/super-editor/src/components/SuperEditor.vue +++ b/packages/super-editor/src/components/SuperEditor.vue @@ -1305,6 +1305,9 @@ onBeforeUnmount(() => { justify-content: center; width: 100%; box-sizing: border-box; + position: relative; + z-index: var(--sd-ui-ruler-z-index, 10); + background: var(--sd-ui-ruler-bg, var(--sd-ui-bg, #ffffff)); } .ruler { diff --git a/packages/super-editor/src/components/toolbar/Toolbar.vue b/packages/super-editor/src/components/toolbar/Toolbar.vue index 918ba93e9e..d2a03efca6 100644 --- a/packages/super-editor/src/components/toolbar/Toolbar.vue +++ b/packages/super-editor/src/components/toolbar/Toolbar.vue @@ -140,10 +140,12 @@ const handleToolbarMousedown = (e) => { display: flex; width: 100%; justify-content: space-between; - background: var(--sd-ui-toolbar-bg, transparent); + background: var(--sd-ui-toolbar-bg, var(--sd-ui-bg, #ffffff)); padding: var(--sd-ui-toolbar-padding-y, 4px) var(--sd-ui-toolbar-padding-x, 16px); box-sizing: border-box; font-family: var(--sd-ui-font-family, Arial, Helvetica, sans-serif); + position: relative; + z-index: var(--sd-ui-toolbar-z-index, 10); } @media (max-width: 1280px) { diff --git a/packages/super-editor/src/core/presentation-editor/PresentationEditor.ts b/packages/super-editor/src/core/presentation-editor/PresentationEditor.ts index 564962be2b..8c38e03cee 100644 --- a/packages/super-editor/src/core/presentation-editor/PresentationEditor.ts +++ b/packages/super-editor/src/core/presentation-editor/PresentationEditor.ts @@ -455,6 +455,7 @@ export class PresentationEditor extends EventEmitter { // This prevents screen readers from encountering duplicate or non-semantic visual elements. this.#viewportHost.setAttribute('aria-hidden', 'true'); this.#viewportHost.style.position = 'relative'; + this.#viewportHost.style.isolation = 'isolate'; this.#viewportHost.style.width = '100%'; // Set min-height to at least one page so the viewport is clickable before layout renders const pageHeight = this.#layoutOptions.pageSize?.h ?? DEFAULT_PAGE_SIZE.h; diff --git a/packages/superdoc/src/assets/styles/helpers/variables.css b/packages/superdoc/src/assets/styles/helpers/variables.css index df9bac3657..e22b17b668 100644 --- a/packages/superdoc/src/assets/styles/helpers/variables.css +++ b/packages/superdoc/src/assets/styles/helpers/variables.css @@ -99,10 +99,13 @@ --sd-ui-toolbar-padding-y: 4px; --sd-ui-toolbar-item-gap: 2px; --sd-ui-toolbar-item-padding: 5px; - --sd-ui-toolbar-bg: transparent; + --sd-ui-toolbar-bg: var(--sd-ui-bg); --sd-ui-toolbar-button-text: var(--sd-ui-text); --sd-ui-toolbar-button-hover-bg: var(--sd-ui-hover-bg); --sd-ui-toolbar-button-active-bg: var(--sd-ui-active-bg); + --sd-ui-toolbar-z-index: 10; + --sd-ui-ruler-bg: var(--sd-ui-bg); + --sd-ui-ruler-z-index: 10; /* UI: context menu — cascades from semantic tier */ --sd-ui-menu-bg: var(--sd-ui-bg); diff --git a/tests/behavior/tests/sdt/structured-content.spec.ts b/tests/behavior/tests/sdt/structured-content.spec.ts index b02fc38881..f1662320ae 100644 --- a/tests/behavior/tests/sdt/structured-content.spec.ts +++ b/tests/behavior/tests/sdt/structured-content.spec.ts @@ -272,3 +272,91 @@ test.describe('viewing mode hides SDT affordances', () => { await superdoc.snapshot('inline SDT viewing mode'); }); }); + +// ========================================================================== +// Stacking Context Regression (SD-2015) +// ========================================================================== + +test.describe('SD-2015: SDT labels must not paint above the toolbar', () => { + test('layout container isolates SDT z-indices from the toolbar', async ({ superdoc }) => { + // Insert an SDT so the relevant DOM nodes exist. + await insertBlockSdt(superdoc.page, 'Stacking Test', 'SDT content'); + await superdoc.waitForStable(); + + // Verify the stacking-context fix: the layout container must have + // isolation: isolate, which scopes all child z-indices (including the + // z-index: 9999999 hover boost) so they cannot escape above the toolbar. + const layoutIsolation = await superdoc.page.evaluate(() => { + const layout = document.querySelector('.superdoc-layout'); + return layout ? getComputedStyle(layout).isolation : 'not-found'; + }); + expect(layoutIsolation).toBe('isolate'); + + // Verify the toolbar establishes its own stacking context above the + // document surface. + const toolbarStyles = await superdoc.page.evaluate(() => { + const toolbar = document.querySelector('.superdoc-toolbar'); + if (!toolbar) return null; + const cs = getComputedStyle(toolbar); + return { position: cs.position, zIndex: cs.zIndex }; + }); + expect(toolbarStyles).not.toBeNull(); + expect(toolbarStyles!.position).toBe('relative'); + expect(Number(toolbarStyles!.zIndex)).toBeGreaterThan(0); + + // Activate the hover boost on the SDT, then force the SDT element to + // overlay the toolbar via CSS. This creates the exact stacking conflict + // from the bug (z-index: 9999999 inside the document vs toolbar z-index) + // without depending on the harness scroll setup. + const hitTag = await superdoc.page.evaluate( + ({ sdtSel, labelSel }) => { + const sdt = document.querySelector(sdtSel) as HTMLElement | null; + const label = document.querySelector(labelSel) as HTMLElement | null; + const probe = document.querySelector('.superdoc-toolbar [data-item="btn-undo"]') as HTMLElement | null; + if (!sdt || !probe) throw new Error('SDT or toolbar probe not found'); + + // Activate the hover boost (z-index: 9999999). + sdt.classList.remove('ProseMirror-selectednode'); + sdt.classList.add('sdt-hover'); + if (label) label.style.display = 'inline-flex'; + + // Confirm the boost is active. + const boost = getComputedStyle(sdt).zIndex; + if (Number(boost) < 9999999) return `z-index-not-active:${boost}`; + + // Force the SDT to overlap the toolbar probe via fixed positioning. + const probeRect = probe.getBoundingClientRect(); + const origPosition = sdt.style.position; + const origZIndex = sdt.style.zIndex; + const origTop = sdt.style.top; + const origLeft = sdt.style.left; + sdt.style.position = 'fixed'; + sdt.style.top = `${probeRect.top}px`; + sdt.style.left = `${probeRect.left}px`; + sdt.style.zIndex = '9999999'; + + // Hit-test: the toolbar should still win because isolation: isolate + // on the layout container scopes the SDT's z-index. + const x = probeRect.left + probeRect.width / 2; + const y = probeRect.top + probeRect.height / 2; + const hit = document.elementFromPoint(x, y); + + // Restore the SDT's original styles. + sdt.style.position = origPosition; + sdt.style.zIndex = origZIndex; + sdt.style.top = origTop; + sdt.style.left = origLeft; + + if (!hit) return 'null'; + if (hit.closest('.superdoc-toolbar')) return 'toolbar'; + if (hit.closest(sdtSel)) return 'sdt'; + return hit.tagName.toLowerCase(); + }, + { sdtSel: BLOCK_SDT, labelSel: BLOCK_LABEL }, + ); + + expect(hitTag).toBe('toolbar'); + + await superdoc.snapshot('SD-2015 toolbar above SDT'); + }); +});