From 408b268a650914085f8fb9018ffb05b354766c45 Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Thu, 14 May 2026 09:25:40 -0300 Subject: [PATCH 1/4] fix(painter-dom): honor appearance:hidden on inline SDTs (SD-3110) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ECMA-376 §17.5.2.6 (w15:appearance val="hidden") means the SDT is present in the document for anchoring but visually transparent. Two prior leaks made hidden SDTs anything but: 1. The renderer painted full chrome (padding/border/hover/selected outline) regardless of appearance, leaving a visible bracket around the wrapped span. 2. The alias label was stamped into the DOM as a child whose textContent included the alias. That text leaked into copy-paste (selecting and copying the wrapped phrase pulled in 'Inline content' / 'Harvey citation' / whatever the SDT's alias was) AND into screen-reader output. The data was already correct end-to-end on the converter side: import parses w15:appearance into the node attrs (extractAppearance in handle-structured-content-node.js), and the Document API surfaces it. The gap was that StructuredContentMetadata in @superdoc/contracts didn't carry the field, so the pm-adapter -> renderer bridge stripped it. Four-file fix: - contracts: add appearance?: StructuredContentAppearance to StructuredContentMetadata. - style-engine: read attrs.appearance in normalizeStructuredContentMetadata, validating against the three spec values (boundingBox | tags | hidden); unknown values are dropped rather than poisoning rendering. - painter-dom renderer: createInlineSdtWrapper now stamps data-appearance="hidden" on the wrapper AND skips appending the alias entirely when hidden. - painter-dom styles: CSS rule keyed off [data-appearance='hidden'] zeroes padding/border/border-radius and neutralizes hover and selected states. Tests: - style-engine: appearance carries through, unknown values are dropped, omitted attr stays undefined. - painter-dom: render a hidden inline SDT and assert (a) data-appearance='hidden' is on the wrapper, (b) no .superdoc-structured-content-inline__label child exists, (c) wrapper.textContent equals exactly the wrapped phrase with the alias text nowhere in it. Verified: - @superdoc/painter-dom: 1071/1071 - @superdoc/style-engine: 132/132 - @superdoc/contracts: 232/232 - @superdoc/pm-adapter: 1838/1838 --- packages/layout-engine/contracts/src/index.ts | 14 ++++ .../painters/dom/src/index.test.ts | 81 +++++++++++++++++++ .../painters/dom/src/renderer.ts | 17 ++++ .../layout-engine/painters/dom/src/styles.ts | 19 +++++ .../style-engine/src/index.test.ts | 29 +++++++ .../layout-engine/style-engine/src/index.ts | 10 ++- 6 files changed, 169 insertions(+), 1 deletion(-) diff --git a/packages/layout-engine/contracts/src/index.ts b/packages/layout-engine/contracts/src/index.ts index f4af770e78..b344430ee4 100644 --- a/packages/layout-engine/contracts/src/index.ts +++ b/packages/layout-engine/contracts/src/index.ts @@ -121,6 +121,18 @@ export type FieldAnnotationMetadata = { export type StructuredContentLockMode = 'unlocked' | 'sdtLocked' | 'contentLocked' | 'sdtContentLocked'; +/** + * Visual chrome / labelling behavior of an SDT, mirroring + * `` (ECMA-376 §17.5.2.6 / OOXML 2010+). + * + * - `'boundingBox'` (default): visible chrome around the SDT content. + * - `'tags'`: tags-only mode (start/end markers). + * - `'hidden'`: no chrome at all; the SDT exists in the document but is + * visually transparent. The alias label MUST NOT leak into the rendered + * DOM textContent (a11y / copy-paste behavior). + */ +export type StructuredContentAppearance = 'boundingBox' | 'tags' | 'hidden'; + export type StructuredContentMetadata = { type: 'structuredContent'; scope: 'inline' | 'block'; @@ -128,6 +140,8 @@ export type StructuredContentMetadata = { tag?: string | null; alias?: string | null; lockMode?: StructuredContentLockMode; + /** Appearance from the SDT's `` element, when present. */ + appearance?: StructuredContentAppearance; sdtPr?: unknown; }; diff --git a/packages/layout-engine/painters/dom/src/index.test.ts b/packages/layout-engine/painters/dom/src/index.test.ts index 9c7c3cb49f..c65a754024 100644 --- a/packages/layout-engine/painters/dom/src/index.test.ts +++ b/packages/layout-engine/painters/dom/src/index.test.ts @@ -2694,6 +2694,87 @@ describe('DomPainter', () => { expect(wrapper.textContent).toContain('controlled text'); }); + it('omits chrome and alias label when inline SDT appearance is hidden (SD-3110)', () => { + // ECMA-376 `` should render the + // SDT transparently: no padding/border/label, and the alias text + // MUST NOT appear in DOM textContent (copy-paste / screen reader + // leak otherwise). + const block: FlowBlock = { + kind: 'paragraph', + id: 'inline-sc-hidden', + runs: [ + { text: 'See ', fontFamily: 'Arial', fontSize: 16, pmStart: 0, pmEnd: 4 }, + { + text: 'Alpha Corp v. SEC', + fontFamily: 'Arial', + fontSize: 16, + pmStart: 4, + pmEnd: 21, + sdt: { + type: 'structuredContent', + scope: 'inline', + id: 'sc-hidden-1', + tag: 'citation', + alias: 'Harvey citation', + appearance: 'hidden', + }, + }, + { text: ' today.', fontFamily: 'Arial', fontSize: 16, pmStart: 21, pmEnd: 28 }, + ], + attrs: {}, + }; + + const measure: Measure = { + kind: 'paragraph', + lines: [ + { fromRun: 0, fromChar: 0, toRun: 2, toChar: 7, width: 200, ascent: 12, descent: 4, lineHeight: 20 }, + ], + totalHeight: 20, + }; + + const layout: Layout = { + pageSize: { w: 612, h: 792 }, + pages: [ + { + number: 1, + fragments: [ + { + kind: 'para', + blockId: 'inline-sc-hidden', + fromLine: 0, + toLine: 1, + x: 30, + y: 40, + width: 552, + pmStart: 0, + pmEnd: 28, + }, + ], + }, + ], + }; + + const painter = createTestPainter({ blocks: [block], measures: [measure] }); + painter.paint(layout, mount); + + const wrapper = mount.querySelector( + '.superdoc-structured-content-inline[data-sdt-id="sc-hidden-1"]', + ) as HTMLElement | null; + expect(wrapper).toBeTruthy(); + if (!wrapper) return; + + // data-appearance="hidden" is the hook CSS uses to drop chrome. + expect(wrapper.dataset.appearance).toBe('hidden'); + + // No alias label child — must not be in the DOM at all. + expect(wrapper.querySelector('.superdoc-structured-content-inline__label')).toBeNull(); + + // textContent of the wrapper must equal exactly the wrapped phrase, + // with no alias text leaked in. + expect(wrapper.textContent).toBe('Alpha Corp v. SEC'); + expect(wrapper.textContent).not.toContain('Harvey citation'); + }); + it('keeps inline SDT wrapper font-size in sync when run font-size changes', () => { const block: FlowBlock = { kind: 'paragraph', diff --git a/packages/layout-engine/painters/dom/src/renderer.ts b/packages/layout-engine/painters/dom/src/renderer.ts index a9903dde5e..52a71baa86 100644 --- a/packages/layout-engine/painters/dom/src/renderer.ts +++ b/packages/layout-engine/painters/dom/src/renderer.ts @@ -7308,12 +7308,29 @@ export class DomPainter { /** * Create an inline SDT wrapper `` with className, layoutEpoch, dataset, and label. * Shared by both the geometry and run-based rendering paths. + * + * When the SDT's `appearance` is `'hidden'` (matching ECMA-376 + * ``), the wrapper is rendered + * transparently: chrome is suppressed via `data-appearance="hidden"` + * (see styles.ts) and the alias label is omitted entirely. Without the + * latter, the alias text leaks into the rendered DOM `textContent` + * (copy-paste includes it) and screen readers announce it. */ private createInlineSdtWrapper(sdt: SdtMetadata): HTMLElement { const wrapper = this.doc!.createElement('span'); wrapper.className = DOM_CLASS_NAMES.INLINE_SDT_WRAPPER; wrapper.dataset.layoutEpoch = String(this.layoutEpoch); this.applySdtDataset(wrapper, sdt); + + const appearance = + sdt.type === 'structuredContent' ? (sdt as { appearance?: string }).appearance : undefined; + if (appearance === 'hidden') { + wrapper.dataset.appearance = 'hidden'; + // No alias label and no chrome: see CSS rule keyed off + // `[data-appearance="hidden"]`. + return wrapper; + } + const alias = (sdt as { alias?: string })?.alias || 'Inline content'; const labelEl = this.doc!.createElement('span'); labelEl.className = `${DOM_CLASS_NAMES.INLINE_SDT_WRAPPER}__label`; diff --git a/packages/layout-engine/painters/dom/src/styles.ts b/packages/layout-engine/painters/dom/src/styles.ts index 15340458bf..ebb012ca85 100644 --- a/packages/layout-engine/painters/dom/src/styles.ts +++ b/packages/layout-engine/painters/dom/src/styles.ts @@ -642,6 +642,25 @@ const SDT_CONTAINER_STYLES = ` display: none; } +/* Hidden appearance per ECMA-376 (w15:appearance val="hidden"). SDT + * exists in the document for anchoring but is visually transparent: no + * padding, no border, no hover background, no selected outline. The + * alias label is not emitted into the DOM at all (see renderer.ts), so + * there is nothing to hide from copy-paste or screen readers. */ +.superdoc-structured-content-inline[data-appearance='hidden'] { + padding: 0; + border: none; + border-radius: 0; +} +.superdoc-structured-content-inline[data-appearance='hidden']:hover { + background-color: transparent; + border: none; +} +.superdoc-structured-content-inline[data-appearance='hidden'].ProseMirror-selectednode { + border-color: transparent; + background-color: transparent; +} + /* Hover highlight for SDT containers. * Hover adds background highlight and z-index boost. * Block SDTs use .sdt-group-hover class (event delegation for multi-fragment coordination). diff --git a/packages/layout-engine/style-engine/src/index.test.ts b/packages/layout-engine/style-engine/src/index.test.ts index 1188a62646..e5fbd933a9 100644 --- a/packages/layout-engine/style-engine/src/index.test.ts +++ b/packages/layout-engine/style-engine/src/index.test.ts @@ -87,6 +87,35 @@ describe('resolveSdtMetadata', () => { }); }); + it('carries appearance through for inline structured content (SD-3110)', () => { + const metadata = resolveSdtMetadata({ + nodeType: 'structuredContent', + attrs: { id: '7', tag: 'citation', alias: 'Harvey citation', appearance: 'hidden' }, + }); + expect(metadata).toMatchObject({ + type: 'structuredContent', + scope: 'inline', + appearance: 'hidden', + }); + }); + + it('drops unknown appearance values rather than letting them flow to the renderer', () => { + const metadata = resolveSdtMetadata({ + nodeType: 'structuredContent', + attrs: { id: '8', tag: 'x', appearance: 'malformed' }, + }); + expect(metadata).toMatchObject({ type: 'structuredContent', scope: 'inline' }); + expect((metadata as { appearance?: string }).appearance).toBeUndefined(); + }); + + it('omits appearance when the source attr is missing', () => { + const metadata = resolveSdtMetadata({ + nodeType: 'structuredContent', + attrs: { id: '9', tag: 'x' }, + }); + expect((metadata as { appearance?: string }).appearance).toBeUndefined(); + }); + it('normalizes document section metadata', () => { const metadata = resolveSdtMetadata({ nodeType: 'documentSection', diff --git a/packages/layout-engine/style-engine/src/index.ts b/packages/layout-engine/style-engine/src/index.ts index 419d2424ba..7fa06ce00e 100644 --- a/packages/layout-engine/style-engine/src/index.ts +++ b/packages/layout-engine/style-engine/src/index.ts @@ -241,7 +241,7 @@ function normalizeStructuredContentMetadata( nodeType: 'structuredContent' | 'structuredContentBlock', attrs: Record, ): StructuredContentMetadata { - return { + const metadata: StructuredContentMetadata = { type: 'structuredContent', scope: nodeType === 'structuredContentBlock' ? 'block' : 'inline', id: toNullableString(attrs.id), @@ -250,6 +250,14 @@ function normalizeStructuredContentMetadata( lockMode: attrs.lockMode as StructuredContentMetadata['lockMode'], sdtPr: attrs.sdtPr, }; + // `appearance` comes from the SDT's element on import. + // Only the three spec-defined values flow through; anything else is + // discarded so a bad value doesn't poison rendering decisions. + const rawAppearance = toOptionalString(attrs.appearance); + if (rawAppearance === 'boundingBox' || rawAppearance === 'tags' || rawAppearance === 'hidden') { + metadata.appearance = rawAppearance; + } + return metadata; } function normalizeDocumentSectionMetadata(attrs: Record): DocumentSectionMetadata { From 52fff7b2141411e16190f3a2fd2a579bc479c5f7 Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Thu, 14 May 2026 15:11:15 -0300 Subject: [PATCH 2/4] test(behavior): cover hidden-appearance inline SDT (SD-3110) End-to-end coverage for the painter-dom fix in #3293. Fixture is a 5-paragraph DOCX with inline SDTs across the appearance matrix: hidden, boundingBox, default (omitted), and two adjacent hidden. Five assertions, one per claim the PR makes plus a copy-paste smoke test: - data-appearance="hidden" stamped on hidden wrappers, absent on others - no __label child inside hidden wrappers; present on others - hidden wrappers omit the alias canary from textContent - no hidden-alias canary appears in the painted layout root - selection.toString() over a hidden wrapper returns only the wrapped phrase Visual coverage follow-up: drop a slim variant in tests/visual/test-data/ via pnpm docs:upload (corpus is R2-backed, not in-tree). --- ...d-3110-inline-sdt-appearance-variants.docx | Bin 0 -> 10416 bytes .../tests/sdt/inline-sdt-appearance.spec.ts | 107 ++++++++++++++++++ 2 files changed, 107 insertions(+) create mode 100644 tests/behavior/tests/sdt/fixtures/sd-3110-inline-sdt-appearance-variants.docx create mode 100644 tests/behavior/tests/sdt/inline-sdt-appearance.spec.ts diff --git a/tests/behavior/tests/sdt/fixtures/sd-3110-inline-sdt-appearance-variants.docx b/tests/behavior/tests/sdt/fixtures/sd-3110-inline-sdt-appearance-variants.docx new file mode 100644 index 0000000000000000000000000000000000000000..b340deb8091e132d9c9526f2980c615f916f69e6 GIT binary patch literal 10416 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 k { + test.beforeEach(async ({ superdoc }) => { + await superdoc.loadDocument(DOC_PATH); + await superdoc.waitForStable(); + }); + + test('hidden wrappers carry data-appearance="hidden" and visible ones do not', async ({ superdoc }) => { + const attrs = await superdoc.page.evaluate((sel) => { + return Array.from(document.querySelectorAll(sel)).map((el) => ({ + sdtId: (el as HTMLElement).dataset.sdtId ?? null, + appearance: (el as HTMLElement).dataset.appearance ?? null, + })); + }, INLINE_SDT); + + const byId = new Map(attrs.map((a) => [a.sdtId, a.appearance])); + for (const id of HIDDEN_IDS) expect(byId.get(id)).toBe('hidden'); + for (const id of VISIBLE_IDS) expect(byId.get(id)).toBeNull(); + }); + + test('hidden wrappers have no alias label child; visible wrappers do', async ({ superdoc }) => { + const labelPresence = await superdoc.page.evaluate( + ({ sel, labelSel }) => { + return Array.from(document.querySelectorAll(sel)).map((el) => ({ + sdtId: (el as HTMLElement).dataset.sdtId ?? null, + hasLabel: !!el.querySelector(labelSel), + })); + }, + { sel: INLINE_SDT, labelSel: INLINE_LABEL }, + ); + + const byId = new Map(labelPresence.map((a) => [a.sdtId, a.hasLabel])); + for (const id of HIDDEN_IDS) expect(byId.get(id)).toBe(false); + for (const id of VISIBLE_IDS) expect(byId.get(id)).toBe(true); + }); + + test('hidden wrappers omit the alias canary from textContent', async ({ superdoc }) => { + const textByIdRaw = await superdoc.page.evaluate((sel) => { + return Array.from(document.querySelectorAll(sel)).map((el) => ({ + sdtId: (el as HTMLElement).dataset.sdtId ?? null, + text: el.textContent ?? '', + })); + }, INLINE_SDT); + const textById = new Map(textByIdRaw.map((a) => [a.sdtId, a.text])); + + expect(textById.get('1001')).toBe('Alpha Corp v. SEC'); + expect(textById.get('1004')).toBe('first hidden span'); + expect(textById.get('1005')).toBe('second hidden span'); + + // Visible wrappers still surface the alias as a label — that's the + // pre-existing boundingBox/default behavior. + expect(textById.get('1002')).toContain('VISIBLE_ALIAS_FOR_COMPARISON'); + expect(textById.get('1003')).toContain('DEFAULT_APPEARANCE_ALIAS'); + }); + + test('no hidden-SDT alias canary appears anywhere in the painted layout', async ({ superdoc }) => { + const layoutText = await superdoc.page.evaluate(() => { + // .presentation-editor__pages is the painter-dom root; selection, + // copy, and visual reads operate on it. + const root = + document.querySelector('.presentation-editor__pages') ?? + document.querySelector('.superdoc-layout'); + return root?.textContent ?? ''; + }); + + for (const canary of HIDDEN_ALIAS_CANARIES) { + expect(layoutText).not.toContain(canary); + } + }); + + test('selecting a hidden wrapper copies only the wrapped phrase', async ({ superdoc }) => { + const selectionText = await superdoc.page.evaluate(() => { + const wrapper = document.querySelector('[data-sdt-id="1001"]'); + if (!wrapper) return null; + const range = document.createRange(); + range.selectNodeContents(wrapper); + const sel = window.getSelection(); + sel?.removeAllRanges(); + sel?.addRange(range); + return sel?.toString() ?? null; + }); + + expect(selectionText).toBe('Alpha Corp v. SEC'); + expect(selectionText).not.toContain('HIDDEN_ALIAS_LEAK_CANARY'); + }); +}); From 754b02d21645df5561425055bfca1cacd59d9600 Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Thu, 14 May 2026 15:25:30 -0300 Subject: [PATCH 3/4] fix(painter-dom): keep hidden inline SDTs out of lock-hover styling (SD-3110) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Caught in review: the lock-hover rule .superdoc-structured-content-inline[data-lock-mode]:hover:not(.ProseMirror-selectednode) has (0,4,0) specificity, one higher than the hidden-appearance rule's (0,3,0). Hovering a hidden inline SDT therefore re-introduced the blue background and z-index 9999999 boost the rule meant to suppress. Exclude data-appearance="hidden" from the inline branch of the lock-hover rule. Block-level branch is untouched; block hidden isn't a render path yet. Adds a behavior regression assertion: hover a hidden wrapper and verify the computed backgroundColor doesn't pick up the lock-hover blue and z-index doesn't jump to 9999999. 18/18 behavior cases × 3 browsers green; painter-dom unit tests still 1071/1071. --- .../layout-engine/painters/dom/src/styles.ts | 10 ++++-- .../tests/sdt/inline-sdt-appearance.spec.ts | 31 +++++++++++++++++++ 2 files changed, 39 insertions(+), 2 deletions(-) diff --git a/packages/layout-engine/painters/dom/src/styles.ts b/packages/layout-engine/painters/dom/src/styles.ts index ebb012ca85..0b85cb907d 100644 --- a/packages/layout-engine/painters/dom/src/styles.ts +++ b/packages/layout-engine/painters/dom/src/styles.ts @@ -665,9 +665,15 @@ const SDT_CONTAINER_STYLES = ` * Hover adds background highlight and z-index boost. * Block SDTs use .sdt-group-hover class (event delegation for multi-fragment coordination). * Inline SDTs use :hover (single element, no coordination needed). - * Hover is suppressed when the node is selected (SD-1584). */ + * Hover is suppressed when the node is selected (SD-1584). + * + * Inline SDTs with appearance=hidden are excluded: this selector has + * higher specificity than the hidden-appearance rule above (its + * :not(.ProseMirror-selectednode) adds a class to the specificity tuple), + * so without the exclusion it would re-introduce the lock-hover blue + * background on hover and contradict the "visually transparent" intent. */ .superdoc-structured-content-block[data-lock-mode].sdt-group-hover:not(.ProseMirror-selectednode), -.superdoc-structured-content-inline[data-lock-mode]:hover:not(.ProseMirror-selectednode) { +.superdoc-structured-content-inline[data-lock-mode]:hover:not(.ProseMirror-selectednode):not([data-appearance='hidden']) { background-color: var(--sd-content-controls-lock-hover-bg, rgba(98, 155, 231, 0.08)); z-index: 9999999; } diff --git a/tests/behavior/tests/sdt/inline-sdt-appearance.spec.ts b/tests/behavior/tests/sdt/inline-sdt-appearance.spec.ts index ca8700830c..bfed3b0803 100644 --- a/tests/behavior/tests/sdt/inline-sdt-appearance.spec.ts +++ b/tests/behavior/tests/sdt/inline-sdt-appearance.spec.ts @@ -89,6 +89,37 @@ test.describe('inline SDT appearance=hidden (SD-3110)', () => { } }); + test('hovering a hidden wrapper does not paint the lock-hover background or boost z-index', async ({ superdoc }) => { + // Regression guard for the CSS specificity bug caught in PR review: + // the lock-hover rule + // .superdoc-structured-content-inline[data-lock-mode]:hover:not(.ProseMirror-selectednode) + // has (0,4,0) specificity vs (0,3,0) for the hidden-appearance hover + // rule, so without an explicit :not([data-appearance='hidden']) it + // re-introduces the lock-hover blue background + z-index 9999999 on + // hover, contradicting "visually transparent". + // Painter may emit more than one wrapper for the same SDT when the run + // is split across lines/fragments — each fragment carries the same + // data-sdt-id. Scope to the painter class and take `.first()`: the + // CSS specificity bug is per-element, so a single wrapper is enough. + const wrapper = superdoc.page + .locator('.superdoc-structured-content-inline[data-sdt-id="1001"]') + .first(); + await wrapper.hover(); + await superdoc.waitForStable(); + + const styles = await wrapper.evaluate((el) => { + const cs = getComputedStyle(el); + return { backgroundColor: cs.backgroundColor, zIndex: cs.zIndex }; + }); + + // Default backgrounds on most browsers are transparent / rgba(0, 0, 0, 0); + // the regression value is rgba(98, 155, 231, 0.08). + expect(styles.backgroundColor).not.toContain('98, 155, 231'); + // The lock-hover rule sets z-index 9999999 on top of any default — if + // it slipped through, the hidden wrapper would jump above siblings. + expect(styles.zIndex).not.toBe('9999999'); + }); + test('selecting a hidden wrapper copies only the wrapped phrase', async ({ superdoc }) => { const selectionText = await superdoc.page.evaluate(() => { const wrapper = document.querySelector('[data-sdt-id="1001"]'); From 265751e73f1a09e553a7b6ccdd5fe5d09554bbfb Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Thu, 14 May 2026 16:08:23 -0300 Subject: [PATCH 4/4] fix(painter-dom): restore viewing-mode hover suppression on inline SDTs (SD-3110) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous fix added a second chained :not([data-appearance='hidden']) to the inline lock-hover rule, which bumped its specificity from (0,4,0) to (0,5,0). The viewing-mode suppression rule below sits at (0,4,0), so it lost the cascade and the lock-hover blue re-appeared on hover in viewing mode — regressing the SD-2232 behavior test "inline SDT hover does not show background in viewing mode". Collapse the two predicates into a single :not(a, b). Comma-list :not() takes the max specificity of its arguments, not the sum, so the selector stays at (0,4,0), viewing-mode suppression wins again, and the hidden-appearance exclusion is preserved. Verified: 22/22 SDT behavior cases on chromium, 44/44 on firefox+webkit; painter-dom unit tests still 1071/1071. --- packages/layout-engine/painters/dom/src/styles.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/layout-engine/painters/dom/src/styles.ts b/packages/layout-engine/painters/dom/src/styles.ts index 0b85cb907d..cd916debff 100644 --- a/packages/layout-engine/painters/dom/src/styles.ts +++ b/packages/layout-engine/painters/dom/src/styles.ts @@ -667,13 +667,13 @@ const SDT_CONTAINER_STYLES = ` * Inline SDTs use :hover (single element, no coordination needed). * Hover is suppressed when the node is selected (SD-1584). * - * Inline SDTs with appearance=hidden are excluded: this selector has - * higher specificity than the hidden-appearance rule above (its - * :not(.ProseMirror-selectednode) adds a class to the specificity tuple), - * so without the exclusion it would re-introduce the lock-hover blue - * background on hover and contradict the "visually transparent" intent. */ + * Inline SDTs with appearance=hidden are excluded via the same :not() + * that handles selection. Both predicates live in one :not(a, b) so the + * selector keeps (0,4,0) specificity. A second chained :not() would push + * it to (0,5,0) and beat the viewing-mode suppression rule below, which + * also sits at (0,4,0). */ .superdoc-structured-content-block[data-lock-mode].sdt-group-hover:not(.ProseMirror-selectednode), -.superdoc-structured-content-inline[data-lock-mode]:hover:not(.ProseMirror-selectednode):not([data-appearance='hidden']) { +.superdoc-structured-content-inline[data-lock-mode]:hover:not(.ProseMirror-selectednode, [data-appearance='hidden']) { background-color: var(--sd-content-controls-lock-hover-bg, rgba(98, 155, 231, 0.08)); z-index: 9999999; }