From 576b56e5ffc179e61229efabf8cbc9f6f3c26ad0 Mon Sep 17 00:00:00 2001 From: Artem Nistuley Date: Mon, 25 May 2026 14:27:48 +0300 Subject: [PATCH 1/6] feat: add modules.contentControls.chrome --- apps/docs/editor/superdoc/configuration.mdx | 28 +++ .../painters/dom/src/index.test.ts | 182 ++++++++++++++++++ .../layout-engine/painters/dom/src/index.ts | 2 + .../src/paragraph/renderParagraphContent.ts | 14 +- .../src/paragraph/renderParagraphFragment.ts | 3 + .../painters/dom/src/renderer.ts | 8 + .../painters/dom/src/runs/types.ts | 1 + .../painters/dom/src/sdt/container.ts | 4 + .../painters/dom/src/sdt/inline.ts | 3 + .../painters/dom/src/styles.test.ts | 10 + .../layout-engine/painters/dom/src/styles.ts | 40 ++++ .../painters/dom/src/table/renderTableCell.ts | 12 ++ .../dom/src/table/renderTableFragment.ts | 25 ++- .../painters/dom/src/table/renderTableRow.ts | 4 + .../painters/dom/src/virtualization.test.ts | 62 +++++- .../presentation-editor/PresentationEditor.ts | 2 + .../v1/core/presentation-editor/types.ts | 2 + packages/superdoc/src/SuperDoc.vue | 1 + packages/superdoc/src/core/types/index.ts | 5 + 19 files changed, 400 insertions(+), 8 deletions(-) diff --git a/apps/docs/editor/superdoc/configuration.mdx b/apps/docs/editor/superdoc/configuration.mdx index d0dfdfb729..48a70ffd1a 100644 --- a/apps/docs/editor/superdoc/configuration.mdx +++ b/apps/docs/editor/superdoc/configuration.mdx @@ -357,6 +357,34 @@ See [Surfaces](/editor/superdoc/surfaces) for the full API and examples. **Deprecated**: Use `modules.contextMenu` instead. See the [Context Menu module](/editor/built-in-ui/context-menu) for configuration options. +### Content controls module + + + Content-control rendering configuration. + + + + Built-in content-control chrome mode. + - `'default'`: Word-like built-in chrome (labels, borders, hover, selected outline) + - `'none'`: disables built-in chrome while keeping SDT data/geometry and `data-sdt-*` wrappers + + + + +Use this when you want to keep content controls in the document but disable SuperDoc’s built-in field visuals. + +```javascript +new SuperDoc({ + selector: '#editor', + document: 'contract.docx', + modules: { + contentControls: { + chrome: 'none', + }, + }, +}); +``` + ## Spell check Provider-based spell check for the layout-engine editor surface. The configuration key is `proofing` because the provider contract is designed to support spelling, grammar, and style. The current UI renders spelling only. diff --git a/packages/layout-engine/painters/dom/src/index.test.ts b/packages/layout-engine/painters/dom/src/index.test.ts index e0406c63ce..6f30e95332 100644 --- a/packages/layout-engine/painters/dom/src/index.test.ts +++ b/packages/layout-engine/painters/dom/src/index.test.ts @@ -2503,6 +2503,62 @@ describe('DomPainter', () => { expect(fragment.dataset.sdtSectionLocked).toBe('true'); }); + it('keeps documentSection tooltip when contentControlsChrome is none', () => { + const sectionBlock: FlowBlock = { + kind: 'paragraph', + id: 'section-para-tooltip', + runs: [{ text: 'Confidential terms', fontFamily: 'Arial', fontSize: 16, pmStart: 0, pmEnd: 18 }], + attrs: { + sdt: { + type: 'documentSection', + id: 'section-2', + title: 'Locked Section', + description: 'Confidential clause', + sectionType: 'locked', + isLocked: true, + }, + }, + }; + + const sectionMeasure: Measure = { + kind: 'paragraph', + lines: [{ fromRun: 0, fromChar: 0, toRun: 0, toChar: 18, width: 120, ascent: 12, descent: 4, lineHeight: 20 }], + totalHeight: 20, + }; + + const sectionLayout: Layout = { + pageSize: { w: 400, h: 500 }, + pages: [ + { + number: 1, + fragments: [ + { + kind: 'para', + blockId: 'section-para-tooltip', + fromLine: 0, + toLine: 1, + x: 20, + y: 30, + width: 320, + pmStart: 0, + pmEnd: 18, + }, + ], + }, + ], + }; + + const painter = createTestPainter({ + blocks: [sectionBlock], + measures: [sectionMeasure], + contentControlsChrome: 'none', + }); + painter.paint(sectionLayout, mount); + + expect(mount.classList.contains('superdoc-cc-chrome-none')).toBe(true); + expect(mount.querySelector('.superdoc-document-section__tooltip')).toBeTruthy(); + }); + it('annotates fragments with both primary SDT and container SDT metadata', () => { // Test case: TOC paragraph inside a documentSection // Should have docPart metadata as primary (data-sdt-*) and section as container (data-sdt-container-*) @@ -2712,6 +2768,74 @@ describe('DomPainter', () => { expect(wrapper.textContent).toContain('controlled text'); }); + it('omits inline content-control label when contentControlsChrome is none', () => { + const inlineScBlock: FlowBlock = { + kind: 'paragraph', + id: 'inline-sc-no-chrome', + runs: [ + { text: 'Before ', fontFamily: 'Arial', fontSize: 16, pmStart: 0, pmEnd: 7 }, + { + text: 'controlled', + fontFamily: 'Arial', + fontSize: 16, + pmStart: 7, + pmEnd: 17, + sdt: { + type: 'structuredContent', + scope: 'inline', + id: 'sc-inline-none', + tag: 'dropdown', + alias: 'Test Dropdown', + }, + }, + { text: ' after', fontFamily: 'Arial', fontSize: 16, pmStart: 17, pmEnd: 23 }, + ], + attrs: {}, + }; + + const inlineScMeasure: Measure = { + kind: 'paragraph', + lines: [{ fromRun: 0, fromChar: 0, toRun: 2, toChar: 6, width: 200, ascent: 12, descent: 4, lineHeight: 20 }], + totalHeight: 20, + }; + + const inlineScLayout: Layout = { + pageSize: { w: 612, h: 792 }, + pages: [ + { + number: 1, + fragments: [ + { + kind: 'para', + blockId: 'inline-sc-no-chrome', + fromLine: 0, + toLine: 1, + x: 30, + y: 40, + width: 552, + pmStart: 0, + pmEnd: 23, + }, + ], + }, + ], + }; + + const painter = createTestPainter({ + blocks: [inlineScBlock], + measures: [inlineScMeasure], + contentControlsChrome: 'none', + }); + painter.paint(inlineScLayout, mount); + + const wrapper = mount.querySelector( + '.superdoc-structured-content-inline[data-sdt-id="sc-inline-none"]', + ) as HTMLElement | null; + expect(wrapper).toBeTruthy(); + if (!wrapper) return; + expect(wrapper.querySelector('.superdoc-structured-content-inline__label')).toBeNull(); + }); + 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 @@ -12780,6 +12904,64 @@ describe('applyRunDataAttributes', () => { expect(fragment.dataset.sdtContainerEnd).toBe('true'); }); + it('omits block structured-content label when contentControlsChrome is none', () => { + const blockSdtBlock: FlowBlock = { + kind: 'paragraph', + id: 'block-sdt-none', + runs: [{ text: 'Content in block SDT', fontFamily: 'Arial', fontSize: 16, pmStart: 0, pmEnd: 20 }], + attrs: { + sdt: { + type: 'structuredContent', + scope: 'block', + id: 'scb-block-none', + tag: '{"fieldType":"signer"}', + alias: 'Block Content Control', + }, + }, + }; + + const blockSdtMeasure: Measure = { + kind: 'paragraph', + lines: [ + { fromRun: 0, fromChar: 0, toRun: 0, toChar: 20, width: 180, ascent: 12, descent: 4, lineHeight: 20 }, + ], + totalHeight: 20, + }; + + const blockSdtLayout: Layout = { + pageSize: { w: 400, h: 500 }, + pages: [ + { + number: 1, + fragments: [ + { + kind: 'para', + blockId: 'block-sdt-none', + fromLine: 0, + toLine: 1, + x: 20, + y: 30, + width: 320, + pmStart: 0, + pmEnd: 20, + }, + ], + }, + ], + }; + + const painter = createTestPainter({ + blocks: [blockSdtBlock], + measures: [blockSdtMeasure], + contentControlsChrome: 'none', + }); + painter.paint(blockSdtLayout, mount); + + const fragment = mount.querySelector('.superdoc-fragment') as HTMLElement; + expect(fragment.classList.contains('superdoc-structured-content-block')).toBe(true); + expect(fragment.querySelector('.superdoc-structured-content__label')).toBeFalsy(); + }); + it('updates block SDT boundaries when appending a new fragment during patch rendering', () => { const sdtMetadata = { type: 'structuredContent' as const, diff --git a/packages/layout-engine/painters/dom/src/index.ts b/packages/layout-engine/painters/dom/src/index.ts index e649ce5f41..3588e05cd8 100644 --- a/packages/layout-engine/painters/dom/src/index.ts +++ b/packages/layout-engine/painters/dom/src/index.ts @@ -102,6 +102,8 @@ export type DomPainterOptions = { onPaintSnapshot?: (snapshot: PaintSnapshot) => void; /** Render nonprinting formatting marks such as spaces, tabs, and paragraph marks. */ showFormattingMarks?: boolean; + /** Built-in SDT chrome rendering mode. */ + contentControlsChrome?: 'default' | 'none'; }; export type DomPainterHandle = { diff --git a/packages/layout-engine/painters/dom/src/paragraph/renderParagraphContent.ts b/packages/layout-engine/painters/dom/src/paragraph/renderParagraphContent.ts index 71ca0b8ba2..edc6c7145e 100644 --- a/packages/layout-engine/painters/dom/src/paragraph/renderParagraphContent.ts +++ b/packages/layout-engine/painters/dom/src/paragraph/renderParagraphContent.ts @@ -99,6 +99,7 @@ export type RenderParagraphContentParams = { convertFinalParagraphMark?: boolean; lineTopOffset?: number; sourceAnchor?: SourceAnchor; + contentControlsChrome?: 'default' | 'none'; }; export type RenderParagraphContentResult = { @@ -131,6 +132,7 @@ export const renderParagraphContent = (params: RenderParagraphContentParams): Re onSdtContainerChrome, applySdtDataset, applyContainerSdtDataset, + contentControlsChrome, renderDropCap, lineTopOffset = 0, } = params; @@ -155,7 +157,17 @@ export const renderParagraphContent = (params: RenderParagraphContentParams): Re ancestorContainerSdts, }); if (applySdtChrome) { - if (applySdtContainerChrome(doc, frameEl, block.attrs?.sdt, block.attrs?.containerSdt, sdtBoundary)) { + if ( + applySdtContainerChrome( + doc, + frameEl, + block.attrs?.sdt, + block.attrs?.containerSdt, + sdtBoundary, + undefined, + contentControlsChrome, + ) + ) { onSdtContainerChrome?.(); } } diff --git a/packages/layout-engine/painters/dom/src/paragraph/renderParagraphFragment.ts b/packages/layout-engine/painters/dom/src/paragraph/renderParagraphFragment.ts index 3b89dcce38..d4857f5c7a 100644 --- a/packages/layout-engine/painters/dom/src/paragraph/renderParagraphFragment.ts +++ b/packages/layout-engine/painters/dom/src/paragraph/renderParagraphFragment.ts @@ -32,6 +32,7 @@ type RenderParagraphFragmentParams = { options?: { sourceAnchor?: ResolvedFragmentItem['sourceAnchor']; wrapperEl?: HTMLElement }, ) => void; createErrorPlaceholder: (blockId: string, error: unknown) => HTMLElement; + contentControlsChrome?: 'default' | 'none'; }; const isMinimalWordLayout = (value: unknown): value is MinimalWordLayout => isMinimalWordLayoutShared(value); @@ -51,6 +52,7 @@ export const renderParagraphFragment = (params: RenderParagraphFragmentParams): renderLine, captureLineSnapshot, createErrorPlaceholder, + contentControlsChrome, } = params; try { @@ -132,6 +134,7 @@ export const renderParagraphFragment = (params: RenderParagraphFragmentParams): }); }, sourceAnchor: resolvedItem?.sourceAnchor, + contentControlsChrome, }); return fragmentEl; diff --git a/packages/layout-engine/painters/dom/src/renderer.ts b/packages/layout-engine/painters/dom/src/renderer.ts index 09b0348e3c..910e320008 100644 --- a/packages/layout-engine/painters/dom/src/renderer.ts +++ b/packages/layout-engine/painters/dom/src/renderer.ts @@ -241,6 +241,8 @@ type PainterOptions = { onPaintSnapshot?: (snapshot: PaintSnapshot) => void; /** Render nonprinting formatting marks such as spaces, tabs, and paragraph marks. */ showFormattingMarks?: boolean; + /** Built-in SDT chrome rendering mode. */ + contentControlsChrome?: 'default' | 'none'; }; type FragmentDomState = { @@ -833,6 +835,7 @@ export class DomPainter { /** Resolved layout for the next-gen paint pipeline. */ private resolvedLayout: ResolvedLayout | null = null; private showFormattingMarks = false; + private contentControlsChrome: 'default' | 'none' = 'default'; constructor(options: PainterOptions = {}) { this.options = options; @@ -841,6 +844,7 @@ export class DomPainter { this.headerProvider = options.headerProvider; this.footerProvider = options.footerProvider; this.showFormattingMarks = options.showFormattingMarks === true; + this.contentControlsChrome = options.contentControlsChrome ?? 'default'; // Initialize page gap (defaults: 24px vertical, 20px horizontal) const defaultGap = this.layoutMode === 'horizontal' ? 20 : 24; @@ -885,6 +889,7 @@ export class DomPainter { private applyFormattingMarksClass(mount: HTMLElement | null = this.mount): void { mount?.classList.toggle('superdoc-show-formatting-marks', this.showFormattingMarks); + mount?.classList.toggle('superdoc-cc-chrome-none', this.contentControlsChrome === 'none'); } private invalidateRenderedContent(): void { @@ -2576,6 +2581,7 @@ export class DomPainter { sourceAnchor: options?.sourceAnchor, }); }, + contentControlsChrome: this.contentControlsChrome, createErrorPlaceholder: this.createErrorPlaceholder.bind(this), }); } @@ -3732,6 +3738,7 @@ export class DomPainter { measure: tableRenderData.measure, cellSpacingPx: tableRenderData.cellSpacingPx, effectiveColumnWidths: tableRenderData.effectiveColumnWidths, + chrome: this.contentControlsChrome, sdtBoundary, renderLine: renderLineForTableCell, captureLineSnapshot: (lineEl, lineContext, options) => { @@ -3806,6 +3813,7 @@ export class DomPainter { doc: this.doc, layoutEpoch: this.layoutEpoch, showFormattingMarks: this.showFormattingMarks, + contentControlsChrome: this.contentControlsChrome, pendingTooltips: this.pendingTooltips, getNextLinkId: () => `superdoc-link-${++this.linkIdCounter}`, applySdtDataset, diff --git a/packages/layout-engine/painters/dom/src/runs/types.ts b/packages/layout-engine/painters/dom/src/runs/types.ts index aff98713af..c28d93c6bb 100644 --- a/packages/layout-engine/painters/dom/src/runs/types.ts +++ b/packages/layout-engine/painters/dom/src/runs/types.ts @@ -25,6 +25,7 @@ export type RunRenderContext = { doc: Document; layoutEpoch: number; showFormattingMarks: boolean; + contentControlsChrome: 'default' | 'none'; pendingTooltips: WeakMap; getNextLinkId: () => string; applySdtDataset: (el: HTMLElement | null, metadata?: SdtMetadata | null) => void; diff --git a/packages/layout-engine/painters/dom/src/sdt/container.ts b/packages/layout-engine/painters/dom/src/sdt/container.ts index e601b909e1..d90faf52e5 100644 --- a/packages/layout-engine/painters/dom/src/sdt/container.ts +++ b/packages/layout-engine/painters/dom/src/sdt/container.ts @@ -113,6 +113,7 @@ export function applySdtContainerChrome( containerSdt?: SdtMetadata | null | undefined, boundaryOptions?: SdtBoundaryOptions, options?: SdtAncestorOptions, + chrome?: 'default' | 'none', ): boolean { if (!shouldRenderSdtContainerChrome(sdt, containerSdt, options)) return false; @@ -143,6 +144,9 @@ export function applySdtContainerChrome( const shouldShowLabel = boundaryOptions?.showLabel ?? isStart; if (shouldShowLabel) { + if (chrome === 'none' && isStructuredContentMetadata(metadata)) { + return true; + } const labelEl = doc.createElement('div'); labelEl.className = config.labelClassName; const labelText = doc.createElement('span'); diff --git a/packages/layout-engine/painters/dom/src/sdt/inline.ts b/packages/layout-engine/painters/dom/src/sdt/inline.ts index 871a81fbbc..2a15c93387 100644 --- a/packages/layout-engine/painters/dom/src/sdt/inline.ts +++ b/packages/layout-engine/painters/dom/src/sdt/inline.ts @@ -22,6 +22,9 @@ export const createInlineSdtWrapper = (sdt: SdtMetadata, context: RunRenderConte wrapper.dataset.appearance = 'hidden'; return wrapper; } + if (context.contentControlsChrome === 'none') { + return wrapper; + } const alias = (sdt as { alias?: string })?.alias || 'Inline content'; const labelEl = context.doc.createElement('span'); diff --git a/packages/layout-engine/painters/dom/src/styles.test.ts b/packages/layout-engine/painters/dom/src/styles.test.ts index 55a313d8c4..eb34700c6e 100644 --- a/packages/layout-engine/painters/dom/src/styles.test.ts +++ b/packages/layout-engine/painters/dom/src/styles.test.ts @@ -49,6 +49,16 @@ describe('ensureSdtContainerStyles', () => { expect(cssText).toContain(".superdoc-structured-content-inline[data-appearance='hidden'] {"); expect(cssText).toContain('background-color: transparent;'); }); + + it('includes global content-controls chrome-none suppression selectors', () => { + ensureSdtContainerStyles(document); + const styleEl = document.querySelector('[data-superdoc-sdt-container-styles="true"]'); + const cssText = styleEl?.textContent ?? ''; + + expect(cssText).toContain('.superdoc-cc-chrome-none .superdoc-structured-content-inline'); + expect(cssText).toContain('.superdoc-cc-chrome-none .superdoc-structured-content-block'); + expect(cssText).toContain('.superdoc-cc-chrome-none .superdoc-structured-content-block.sdt-group-hover'); + }); }); describe('ensureTrackChangeStyles', () => { diff --git a/packages/layout-engine/painters/dom/src/styles.ts b/packages/layout-engine/painters/dom/src/styles.ts index dfd1e37e32..108e03e522 100644 --- a/packages/layout-engine/painters/dom/src/styles.ts +++ b/packages/layout-engine/painters/dom/src/styles.ts @@ -690,6 +690,33 @@ const SDT_CONTAINER_STYLES = ` background-color: transparent; } +/* Global content-control chrome opt-out: preserve SDT wrappers/datasets while + * suppressing all built-in visual chrome. Label elements are not emitted by + * renderer/helpers when this class is present (DOM non-emission), and these + * rules neutralize border/padding/hover/selection visuals. */ +.superdoc-cc-chrome-none .superdoc-structured-content-inline, +.superdoc-cc-chrome-none .superdoc-structured-content-block { + border: none; + padding: 0; + border-radius: 0; + background: none; +} + +.superdoc-cc-chrome-none .superdoc-structured-content-inline:hover, +.superdoc-cc-chrome-none .superdoc-structured-content-block:hover, +.superdoc-cc-chrome-none .superdoc-structured-content-block.sdt-group-hover, +.superdoc-cc-chrome-none .superdoc-structured-content-block[data-lock-mode].sdt-group-hover, +.superdoc-cc-chrome-none .superdoc-structured-content-inline[data-lock-mode]:hover { + border: none; + background: none; +} + +.superdoc-cc-chrome-none .superdoc-structured-content-inline.ProseMirror-selectednode, +.superdoc-cc-chrome-none .superdoc-structured-content-block.ProseMirror-selectednode { + border-color: transparent; + background: none; +} + /* 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). @@ -707,6 +734,19 @@ const SDT_CONTAINER_STYLES = ` z-index: 9999999; } +/* Keep lock-hover highlight disabled when chrome is globally suppressed. + * Declared after the base lock-hover rule so cascade order is deterministic. */ +.superdoc-cc-chrome-none .superdoc-structured-content-block[data-lock-mode].sdt-group-hover:not( + .ProseMirror-selectednode + ), +.superdoc-cc-chrome-none .superdoc-structured-content-inline[data-lock-mode]:hover:not( + .ProseMirror-selectednode, + [data-appearance='hidden'] + ) { + background-color: transparent; + z-index: auto; +} + /* Viewing mode: remove structured content affordances */ .presentation-editor--viewing .superdoc-structured-content-block, .presentation-editor--viewing .superdoc-structured-content-inline { diff --git a/packages/layout-engine/painters/dom/src/table/renderTableCell.ts b/packages/layout-engine/painters/dom/src/table/renderTableCell.ts index 4fc7a4485d..9818fe3e45 100644 --- a/packages/layout-engine/painters/dom/src/table/renderTableCell.ts +++ b/packages/layout-engine/painters/dom/src/table/renderTableCell.ts @@ -224,6 +224,8 @@ type EmbeddedTableRenderParams = { renderDrawingContent?: (block: DrawingBlock) => HTMLElement; /** Function to apply SDT metadata as data attributes */ applySdtDataset: (el: HTMLElement | null, metadata?: SdtMetadata | null) => void; + /** Built-in SDT chrome rendering mode. */ + chrome?: 'default' | 'none'; /** Starting row index for partial rendering (inclusive, default 0) */ fromRow?: number; /** Ending row index for partial rendering (exclusive, default all rows) */ @@ -285,6 +287,7 @@ const renderEmbeddedTable = ( captureLineSnapshot, renderDrawingContent, applySdtDataset, + chrome, fromRow: paramFromRow, toRow: paramToRow, partialRow: paramPartialRow, @@ -343,6 +346,7 @@ const renderEmbeddedTable = ( renderDrawingContent, applyFragmentFrame, applySdtDataset, + chrome, applyStyles: applyInlineStyles, sdtBoundary, ancestorContainerKey, @@ -378,6 +382,7 @@ function renderPartialEmbeddedTable(params: { captureLineSnapshot?: EmbeddedTableRenderParams['captureLineSnapshot']; renderDrawingContent?: EmbeddedTableRenderParams['renderDrawingContent']; applySdtDataset: EmbeddedTableRenderParams['applySdtDataset']; + chrome?: EmbeddedTableRenderParams['chrome']; sdtBoundary?: SdtBoundaryOptions; ancestorContainerKey?: string | null; ancestorContainerSdt?: SdtMetadata | null; @@ -398,6 +403,7 @@ function renderPartialEmbeddedTable(params: { captureLineSnapshot, renderDrawingContent, applySdtDataset, + chrome, sdtBoundary, ancestorContainerKey, ancestorContainerSdt, @@ -505,6 +511,7 @@ function renderPartialEmbeddedTable(params: { captureLineSnapshot, renderDrawingContent, applySdtDataset, + chrome, fromRow: embeddedFromRow, toRow: embeddedToRow, partialRow: partialRowInfo, @@ -575,6 +582,8 @@ type TableCellRenderDependencies = { context: FragmentRenderContext; /** Function to apply SDT metadata as data attributes */ applySdtDataset: (el: HTMLElement | null, metadata?: SdtMetadata | null) => void; + /** Built-in SDT chrome rendering mode. */ + chrome?: 'default' | 'none'; /** Ancestor SDT container key for suppressing duplicate container styling in cells */ ancestorContainerKey?: string | null; /** Ancestor SDT metadata for suppressing duplicate id-less container styling in cells */ @@ -675,6 +684,7 @@ export const renderTableCell = (deps: TableCellRenderDependencies): TableCellRen renderDrawingContent, context, applySdtDataset, + chrome, ancestorContainerKey, ancestorContainerSdt, ancestorContainerKeys, @@ -812,6 +822,7 @@ export const renderTableCell = (deps: TableCellRenderDependencies): TableCellRen captureLineSnapshot, renderDrawingContent, applySdtDataset, + chrome, sdtBoundary: sdtBoundaries[i], ancestorContainerKey, ancestorContainerSdt, @@ -1015,6 +1026,7 @@ export const renderTableCell = (deps: TableCellRenderDependencies): TableCellRen cellEl.style.overflow = 'visible'; onSdtContainerChrome?.(); }, + contentControlsChrome: chrome, applySdtDataset, renderLine: ({ block, line, lineIndex, isLastLine, resolvedListTextStartPx }) => renderLine(block, line, { ...context, section: 'body' }, lineIndex, isLastLine, resolvedListTextStartPx), diff --git a/packages/layout-engine/painters/dom/src/table/renderTableFragment.ts b/packages/layout-engine/painters/dom/src/table/renderTableFragment.ts index d4e8fd9a2b..4a0faf4db7 100644 --- a/packages/layout-engine/painters/dom/src/table/renderTableFragment.ts +++ b/packages/layout-engine/painters/dom/src/table/renderTableFragment.ts @@ -58,6 +58,8 @@ export type TableRenderDependencies = { ancestorContainerSdts?: SdtAncestorOptions['ancestorContainerSdts']; /** Receives notification when this table fragment or descendants render SDT container chrome */ onSdtContainerChrome?: () => void; + /** Built-in SDT chrome rendering mode. */ + chrome?: 'default' | 'none'; /** Function to render a line of paragraph content */ renderLine: ( block: ParagraphBlock, @@ -158,6 +160,7 @@ export const renderTableFragment = (deps: TableRenderDependencies): HTMLElement measure, cellSpacingPx, effectiveColumnWidths, + chrome, context, sdtBoundary, ancestorContainerKey, @@ -225,12 +228,20 @@ export const renderTableFragment = (deps: TableRenderDependencies): HTMLElement // Apply SDT container styling (document sections, structured content blocks) if ( - applySdtContainerChrome(doc, container, block.attrs?.sdt, block.attrs?.containerSdt, sdtBoundary, { - ancestorContainerKey, - ancestorContainerSdt, - ancestorContainerKeys, - ancestorContainerSdts, - }) + applySdtContainerChrome( + doc, + container, + block.attrs?.sdt, + block.attrs?.containerSdt, + sdtBoundary, + { + ancestorContainerKey, + ancestorContainerSdt, + ancestorContainerKeys, + ancestorContainerSdts, + }, + chrome, + ) ) { onSdtContainerChrome?.(); } @@ -433,6 +444,7 @@ export const renderTableFragment = (deps: TableRenderDependencies): HTMLElement ancestorContainerKeys: nextAncestorContainerKeys, ancestorContainerSdts: nextAncestorContainerSdts, onSdtContainerChrome, + chrome, // Headers are always rendered as-is (no border suppression) continuesFromPrev: false, continuesOnNext: false, @@ -599,6 +611,7 @@ export const renderTableFragment = (deps: TableRenderDependencies): HTMLElement ancestorContainerKeys: nextAncestorContainerKeys, ancestorContainerSdts: nextAncestorContainerSdts, onSdtContainerChrome, + chrome, // Draw top border if table continues from previous fragment (MS Word behavior) continuesFromPrev: isFirstRenderedBodyRow && fragment.continuesFromPrev === true, // Draw bottom border if table continues on next fragment (MS Word behavior) diff --git a/packages/layout-engine/painters/dom/src/table/renderTableRow.ts b/packages/layout-engine/painters/dom/src/table/renderTableRow.ts index 15701ffb05..d3b2a117f2 100644 --- a/packages/layout-engine/painters/dom/src/table/renderTableRow.ts +++ b/packages/layout-engine/painters/dom/src/table/renderTableRow.ts @@ -208,6 +208,8 @@ type TableRowRenderDependencies = { * Applied to cell x positions and row y advancement. */ cellSpacingPx?: number; + /** Built-in SDT chrome rendering mode. */ + chrome?: 'default' | 'none'; }; /** @@ -272,6 +274,7 @@ export const renderTableRow = (deps: TableRowRenderDependencies): void => { continuesOnNext, partialRow, cellSpacingPx = 0, + chrome, } = deps; const totalCols = columnWidths.length; @@ -449,6 +452,7 @@ export const renderTableRow = (deps: TableRowRenderDependencies): void => { tableIndent, isRtl, cellWidth: computedCellWidth > 0 ? computedCellWidth : undefined, + chrome, }); container.appendChild(cellElement); diff --git a/packages/layout-engine/painters/dom/src/virtualization.test.ts b/packages/layout-engine/painters/dom/src/virtualization.test.ts index 092a3bf315..c7e508087e 100644 --- a/packages/layout-engine/painters/dom/src/virtualization.test.ts +++ b/packages/layout-engine/painters/dom/src/virtualization.test.ts @@ -222,7 +222,6 @@ describe('DomPainter virtualization (vertical)', () => { mount.scrollTop = 3 * (500 + 72); mount.dispatchEvent(new Event('scroll')); - expect(mount.querySelector('.superdoc-page[data-page-index="0"]')).toBeNull(); mount.scrollTop = 0; mount.dispatchEvent(new Event('scroll')); @@ -233,6 +232,67 @@ describe('DomPainter virtualization (vertical)', () => { expect(remountedLabel).toBeTruthy(); }); + it('keeps content-control labels suppressed after remount when chrome is none', () => { + const sdtBlock: FlowBlock = { + kind: 'paragraph', + id: 'virtual-sdt-block-none', + runs: [{ text: 'SDT content', fontFamily: 'Arial', fontSize: 16, pmStart: 0, pmEnd: 11 }], + attrs: { + sdt: { + type: 'structuredContent', + scope: 'block', + id: 'scb-virtual-none', + alias: 'Virtual Label', + tag: 'virtual', + }, + }, + }; + + const sdtMeasure: Measure = { + kind: 'paragraph', + lines: [{ fromRun: 0, fromChar: 0, toRun: 0, toChar: 11, width: 90, ascent: 12, descent: 4, lineHeight: 20 }], + totalHeight: 20, + }; + + const sdtLayout: Layout = { + pageSize: { w: 400, h: 500 }, + pages: Array.from({ length: 6 }, (_, i) => ({ + number: i + 1, + fragments: [ + { + kind: 'para', + blockId: 'virtual-sdt-block-none', + fromLine: 0, + toLine: 1, + x: 24, + y: 24, + width: 220, + pmStart: 0, + pmEnd: 11, + }, + ], + })), + }; + + const painter = createTestPainter({ + blocks: [sdtBlock], + measures: [sdtMeasure], + contentControlsChrome: 'none', + virtualization: { enabled: true, window: 1, overscan: 0, gap: 72, paddingTop: 0 }, + }); + + painter.paint(sdtLayout, mount); + expect(mount.classList.contains('superdoc-cc-chrome-none')).toBe(true); + expect(mount.querySelector('.superdoc-page[data-page-index="0"] .superdoc-structured-content__label')).toBeNull(); + + mount.scrollTop = 3 * (500 + 72); + mount.dispatchEvent(new Event('scroll')); + + mount.scrollTop = 0; + mount.dispatchEvent(new Event('scroll')); + expect(mount.querySelector('.superdoc-page[data-page-index="0"] .superdoc-structured-content__label')).toBeNull(); + }); + it('handles window size larger than total pages', () => { const painter = createTestPainter({ blocks: [block], diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/PresentationEditor.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/PresentationEditor.ts index a8f54c81fd..6ab017a84c 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/PresentationEditor.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/PresentationEditor.ts @@ -648,6 +648,7 @@ export class PresentationEditor extends EventEmitter { presence: validatedPresence, showBookmarks: options.layoutEngineOptions?.showBookmarks ?? false, showFormattingMarks: options.layoutEngineOptions?.showFormattingMarks ?? false, + contentControlsChrome: options.layoutEngineOptions?.contentControlsChrome, }; this.#trackedChangesOverrides = options.layoutEngineOptions?.trackedChanges; @@ -6467,6 +6468,7 @@ export class PresentationEditor extends EventEmitter { ruler: this.#layoutOptions.ruler, pageGap: this.#layoutState.layout?.pageGap ?? effectiveGap, showFormattingMarks: this.#layoutOptions.showFormattingMarks ?? false, + contentControlsChrome: this.#layoutOptions.contentControlsChrome ?? 'default', }); // Pass the current zoom so virtualization accounts for the CSS transform scale diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/types.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/types.ts index 58c490452d..4de357c208 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/types.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/types.ts @@ -177,6 +177,8 @@ export type LayoutEngineOptions = { showBookmarks?: boolean; /** Render nonprinting formatting marks such as spaces, tabs, and paragraph marks. */ showFormattingMarks?: boolean; + /** Built-in SDT chrome rendering mode. */ + contentControlsChrome?: 'default' | 'none'; }; export type PresentationEditorOptions = ConstructorParameters[0] & { diff --git a/packages/superdoc/src/SuperDoc.vue b/packages/superdoc/src/SuperDoc.vue index f70bcaf6cb..3a1460cd4a 100644 --- a/packages/superdoc/src/SuperDoc.vue +++ b/packages/superdoc/src/SuperDoc.vue @@ -834,6 +834,7 @@ const editorOptions = (doc) => { zoom: (activeZoom.value ?? 100) / 100, emitCommentPositionsInViewing: isViewingMode() && shouldRenderCommentsInViewing.value, enableCommentsInViewing: isViewingCommentsVisible.value, + contentControlsChrome: proxy.$superdoc.config.modules?.contentControls?.chrome, } : undefined, permissionResolver: (payload = {}) => diff --git a/packages/superdoc/src/core/types/index.ts b/packages/superdoc/src/core/types/index.ts index 0b6c087108..0122acd521 100644 --- a/packages/superdoc/src/core/types/index.ts +++ b/packages/superdoc/src/core/types/index.ts @@ -1087,6 +1087,11 @@ export interface CanPerformPermissionParams { /** Modules registered with the SuperDoc instance. */ export interface Modules { + /** Content controls module configuration. */ + contentControls?: { + /** Built-in SDT chrome rendering mode. */ + chrome?: 'default' | 'none'; + } & Record; /** * Comments module configuration (false to disable). The named fields below * are typed for IDE help; the runtime spreads the entire object through the From 831b887891064bdbedf084680d58bae7fac3a5ef Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Thu, 28 May 2026 18:21:58 -0300 Subject: [PATCH 2/6] demo(contract-templates): turn off built-in SDT chrome and paint host visuals Set modules.contentControls.chrome: 'none' so the demo shows the intended SD-3159 use case: built-in SDT chrome off, host owns the visuals. Smart-field pills and clause cards are now host-painted under .superdoc-cc-chrome-none (scoped so they sit above the painter's reset), and the painter-label CSS is dropped since no label element is emitted. The contextual field chip is unchanged and still anchors via getRect, which keeps working because wrappers and data-sdt-* datasets are kept. --- demos/contract-templates/src/field-chip.ts | 10 ++--- demos/contract-templates/src/main.ts | 6 ++- demos/contract-templates/src/style.css | 43 ++++++++++------------ 3 files changed, 29 insertions(+), 30 deletions(-) diff --git a/demos/contract-templates/src/field-chip.ts b/demos/contract-templates/src/field-chip.ts index a272d083a4..965167d295 100644 --- a/demos/contract-templates/src/field-chip.ts +++ b/demos/contract-templates/src/field-chip.ts @@ -14,11 +14,11 @@ * the Clauses tab. Linked-occurrence highlights, field-details popovers, * and clause badges are deliberate follow-ups (SD-3155 umbrella). * - * The chip renders ALONGSIDE SuperDoc's built-in SDT chrome (blue - * label, border, hover background) by design: this demo demonstrates - * the API's ability to add contextual UI on top of the document, not - * to replace the editor's default visuals. Suppressing the built-in - * chrome is filed as SD-3159 (`modules.contentControls.chrome: 'default' | 'none'`). + * This demo runs with SuperDoc's built-in SDT chrome turned off + * (`modules.contentControls.chrome: 'none'`, SD-3159), so the chip is the + * smart field's active-state UI rather than an addition on top of the + * built-in blue label/border. The wrappers and data-sdt-* datasets are + * still emitted, which is what `observe`/`getRect`/`get` rely on. */ import type { SuperDocUI } from 'superdoc/ui'; diff --git a/demos/contract-templates/src/main.ts b/demos/contract-templates/src/main.ts index 5842f201f4..090deed3eb 100644 --- a/demos/contract-templates/src/main.ts +++ b/demos/contract-templates/src/main.ts @@ -216,7 +216,11 @@ const superdoc = new SuperDoc({ selector: '#editor', documentMode: 'editing', document: '/nda-template.docx', - modules: { comments: false }, + // Disable SuperDoc's built-in SDT chrome (border, label, hover/selection + // highlight). The wrappers and data-sdt-* datasets are preserved, so the + // contextual field chip (field-chip.ts) and the document API still work; + // this demo paints its own SDT visuals in style.css instead. + modules: { comments: false, contentControls: { chrome: 'none' } }, telemetry: { enabled: false }, onReady: ({ superdoc: sd }) => void initialize(sd as DemoSuperDoc), }); diff --git a/demos/contract-templates/src/style.css b/demos/contract-templates/src/style.css index f47b9ce68e..410759e4bd 100644 --- a/demos/contract-templates/src/style.css +++ b/demos/contract-templates/src/style.css @@ -238,42 +238,37 @@ input:focus { } /* ----------------------------------------------------------------------- - In-editor SDT chrome (visible DomPainter classes only) - The painter renders block SDTs with `position: absolute` and explicit - coordinates, so the rules below color the existing 1px border slot and - add background only. They never change padding, margin, or box size. + Host-owned SDT styling. + This demo turns off SuperDoc's built-in content-control chrome + (`modules.contentControls.chrome: 'none'` in main.ts) and paints its + own. The painter adds `.superdoc-cc-chrome-none` to the mount and resets + border/padding/radius/background on the SDT wrappers; scoping under that + class keeps these rules above the reset in specificity and cascade, and + restores the box properties the reset strips. No painter label element + exists under chrome-none, so there is nothing to style for it. ----------------------------------------------------------------------- */ /* Inline smart fields: blue pill */ -.superdoc-structured-content-inline[data-sdt-tag*='smartField'] { - border-color: var(--demo-accent); +.superdoc-cc-chrome-none .superdoc-structured-content-inline[data-sdt-tag*='smartField'] { + padding: 1px 4px; + border: 1px solid var(--demo-accent); + border-radius: 4px; background-color: var(--demo-accent-soft); } -/* Block clauses: visible bordered card with soft background */ -.superdoc-structured-content-block[data-sdt-tag*='reusableSection'] { - border-color: var(--demo-border); - background-color: var(--demo-bg); -} -.superdoc-structured-content-block[data-sdt-tag*='reusableSection'][data-lock-mode='unlocked'] { +/* Block clauses: bordered card with soft background */ +.superdoc-cc-chrome-none .superdoc-structured-content-block[data-sdt-tag*='reusableSection'] { + border: 1px solid var(--demo-border); + border-radius: 4px; background-color: var(--demo-bg); } -/* Existing painter-provided label gets a typographic tune */ -.superdoc-structured-content-block__label { - font-size: var(--sd-font-size-200, 11px); - text-transform: uppercase; - letter-spacing: 0.05em; - color: var(--demo-text-muted); - font-weight: 600; -} - /* * Contextual smart-field chip (SD-3157). Floats over the active smart- * field SDT showing field label + live value. Wired in field-chip.ts - * against `ui.contentControls.observe` + `getRect`. Renders alongside - * SuperDoc's built-in SDT chrome by design — the chip demonstrates - * additive contextual UI, not a chrome replacement (that's SD-3159). + * against `ui.contentControls.observe` + `getRect`. With built-in chrome + * off (SD-3159), the chip is the smart field's active-state affordance: + * custom UI anchored to the SDT via the public geometry API. */ .sd-field-chip { display: inline-flex; From ea387af87ea1bddf2dc2af52abdbf78bf1cbb238 Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Thu, 28 May 2026 18:29:15 -0300 Subject: [PATCH 3/6] test(painter-dom): assert chrome-none hover suppression and cascade order Cover the :hover ::before/::after suppression selectors and add an order assertion: the chrome-none block must be declared after the lock-hover ::before rule. Those hover selectors are equal specificity to the rules they override, so source order is what makes them win; the assertion guards against a future reorder silently re-leaking hover chrome under contentControlsChrome=none. --- .../painters/dom/src/styles.test.ts | 24 ++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/packages/layout-engine/painters/dom/src/styles.test.ts b/packages/layout-engine/painters/dom/src/styles.test.ts index ba28de7568..6f5de39ed2 100644 --- a/packages/layout-engine/painters/dom/src/styles.test.ts +++ b/packages/layout-engine/painters/dom/src/styles.test.ts @@ -254,7 +254,10 @@ describe('ensureSdtContainerStyles', () => { expect(cssText).toContain( '.superdoc-cc-chrome-none .superdoc-structured-content-block.ProseMirror-selectednode::after', ); - // Hover/group-hover border and background. + // Direct hover (single-fragment) border and background. + expect(cssText).toContain('.superdoc-cc-chrome-none .superdoc-structured-content-block:hover::after'); + expect(cssText).toContain('.superdoc-cc-chrome-none .superdoc-structured-content-block:hover::before'); + // Group hover (multi-fragment, JS-coordinated) border and background. expect(cssText).toContain('.superdoc-cc-chrome-none .superdoc-structured-content-block.sdt-group-hover::after'); expect(cssText).toContain('.superdoc-cc-chrome-none .superdoc-structured-content-block.sdt-group-hover::before'); // Lock-hover background lives on ::before; must be suppressed too. @@ -262,6 +265,25 @@ describe('ensureSdtContainerStyles', () => { '.superdoc-cc-chrome-none .superdoc-structured-content-block[data-lock-mode].sdt-group-hover::before', ); }); + + it('declares chrome-none block pseudo suppression after the chrome-showing rules', () => { + // The hover/group-hover suppression selectors are the same specificity as + // the rules they override, so source order is load-bearing: the chrome-none + // block must come after the last chrome-showing block pseudo rule (the + // lock-hover ::before background) or hover chrome leaks under chrome-none. + ensureSdtContainerStyles(document); + const styleEl = document.querySelector('[data-superdoc-sdt-container-styles="true"]'); + const cssText = styleEl?.textContent ?? ''; + + const lastChromeShowing = cssText.indexOf( + '.superdoc-structured-content-block[data-lock-mode].sdt-group-hover:not(.ProseMirror-selectednode)::before', + ); + const chromeNoneSuppression = cssText.indexOf( + '.superdoc-cc-chrome-none .superdoc-structured-content-block.sdt-group-hover::before', + ); + expect(lastChromeShowing).toBeGreaterThan(-1); + expect(chromeNoneSuppression).toBeGreaterThan(lastChromeShowing); + }); }); describe('ensureTrackChangeStyles', () => { From 0d3001831bc14c3640055ede9f0ec64df3a858bf Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Thu, 28 May 2026 19:11:11 -0300 Subject: [PATCH 4/6] test: cover contentControlsChrome plumbing; clarify chrome-none comment Close review-feedback coverage gaps for modules.contentControls.chrome: - SuperDoc.test.js: assert SuperDoc.vue forwards modules.contentControls.chrome into the PresentationEditor layout options (and stays undefined when unset). This is the public consumer path; DomPainter unit tests exercised the option but not the wiring. - consumer-typecheck: pin that modules.contentControls.chrome: 'none' compiles for a consumer across the tsconfig matrix. - table tests: assert chrome: 'none' suppresses the label but keeps the wrapper for a table-level SDT and a nested-table SDT in a cell; the threading through fragment/row/cell had no coverage. - virtualization.test.ts: restore the page-0 eviction assertion that the PR had dropped, so the remount test again proves eviction happened first (verified it still passes). - styles.ts: scope the chrome-opt-out comment to structured-content labels; documentSection chrome is intentionally preserved. --- .../layout-engine/painters/dom/src/styles.ts | 8 +- .../dom/src/table/renderTableCell.test.ts | 97 +++++++++++++++++++ .../dom/src/table/renderTableFragment.test.ts | 37 +++++++ .../painters/dom/src/virtualization.test.ts | 3 + packages/superdoc/src/SuperDoc.test.js | 21 ++++ .../src/modules-config-passthrough.ts | 8 ++ 6 files changed, 171 insertions(+), 3 deletions(-) diff --git a/packages/layout-engine/painters/dom/src/styles.ts b/packages/layout-engine/painters/dom/src/styles.ts index 78ee14b847..e86fb474a0 100644 --- a/packages/layout-engine/painters/dom/src/styles.ts +++ b/packages/layout-engine/painters/dom/src/styles.ts @@ -785,9 +785,11 @@ const SDT_CONTAINER_STYLES = ` } /* Global content-control chrome opt-out: preserve SDT wrappers/datasets while - * suppressing all built-in visual chrome. Label elements are not emitted by - * renderer/helpers when this class is present (DOM non-emission), and these - * rules neutralize border/padding/hover/selection visuals. */ + * suppressing built-in visual chrome on structured-content controls. Their + * label elements are not emitted by renderer/helpers when this class is + * present (DOM non-emission), and these rules neutralize + * border/padding/hover/selection visuals. documentSection chrome (e.g. the + * locked-section tooltip) is intentionally preserved and not in scope. */ .superdoc-cc-chrome-none .superdoc-structured-content-inline, .superdoc-cc-chrome-none .superdoc-structured-content-block { border: none; diff --git a/packages/layout-engine/painters/dom/src/table/renderTableCell.test.ts b/packages/layout-engine/painters/dom/src/table/renderTableCell.test.ts index b50b3f65c8..da2823884d 100644 --- a/packages/layout-engine/painters/dom/src/table/renderTableCell.test.ts +++ b/packages/layout-engine/painters/dom/src/table/renderTableCell.test.ts @@ -4178,6 +4178,103 @@ describe('renderTableCell', () => { expect(tableChrome?.querySelector('.superdoc-structured-content__label')?.textContent).toBe('Nested Table'); }); + it('omits the nested table SDT label but keeps the wrapper when chrome is none', () => { + const nestedParagraph: ParagraphBlock = { + kind: 'paragraph', + id: 'nested-sdt-para-none', + runs: [{ text: 'Nested', fontFamily: 'Arial', fontSize: 16 }], + attrs: {}, + }; + const nestedTable: TableBlock = { + kind: 'table', + id: 'nested-sdt-table-none', + attrs: { + sdt: { + type: 'structuredContent', + scope: 'block', + id: 'nested-table-sdt-none', + alias: 'Nested Table', + }, + }, + rows: [ + { + id: 'nested-row-none', + cells: [ + { + id: 'nested-cell-none', + blocks: [nestedParagraph], + attrs: {}, + }, + ], + }, + ], + }; + const nestedMeasure: TableMeasure = { + kind: 'table', + rows: [ + { + height: 24, + cells: [ + { + width: 80, + height: 24, + gridColumnStart: 0, + colSpan: 1, + rowSpan: 1, + blocks: [ + { + kind: 'paragraph', + lines: [ + { + fromRun: 0, + fromChar: 0, + toRun: 0, + toChar: 6, + width: 60, + ascent: 12, + descent: 4, + lineHeight: 20, + }, + ], + totalHeight: 20, + }, + ], + }, + ], + }, + ], + columnWidths: [80], + totalWidth: 80, + totalHeight: 24, + }; + const cellMeasure: TableCellMeasure = { + blocks: [nestedMeasure], + width: 120, + height: 40, + gridColumnStart: 0, + colSpan: 1, + rowSpan: 1, + }; + const cell: TableCell = { + id: 'cell-nested-table-sdt-none', + blocks: [nestedTable], + attrs: {}, + }; + + const { cellElement } = renderTableCell({ + ...createBaseDeps(), + cellMeasure, + cell, + chrome: 'none', + }); + + const tableChrome = cellElement.querySelector('[data-block-id="nested-sdt-table-none"]') as HTMLElement; + // Wrapper survives so host/custom UI and the geometry APIs still resolve it... + expect(tableChrome?.classList.contains('superdoc-structured-content-block')).toBe(true); + // ...but the built-in label is not emitted under chrome: 'none'. + expect(tableChrome?.querySelector('.superdoc-structured-content__label')).toBeFalsy(); + }); + it('should set overflow:visible when only rendered nested descendants have SDT chrome', () => { const descendantSdt: SdtMetadata = { type: 'structuredContent', diff --git a/packages/layout-engine/painters/dom/src/table/renderTableFragment.test.ts b/packages/layout-engine/painters/dom/src/table/renderTableFragment.test.ts index b2cbdad377..b77aa3edd4 100644 --- a/packages/layout-engine/painters/dom/src/table/renderTableFragment.test.ts +++ b/packages/layout-engine/painters/dom/src/table/renderTableFragment.test.ts @@ -364,6 +364,43 @@ describe('renderTableFragment', () => { expect(chromeElements[0].querySelector('.superdoc-structured-content__label')?.textContent).toBe('Idless Table'); }); + it('omits the table SDT label but keeps the wrapper when chrome is none', () => { + const block = createTestTableBlock(); + block.attrs = { + sdt: { + type: 'structuredContent', + scope: 'block', + id: 'table-sdt-none', + alias: 'Table Control', + }, + }; + + const element = renderTableFragment({ + doc, + fragment: createTestTableFragment(), + context, + block, + measure: createTestTableMeasure(), + cellSpacingPx: 0, + effectiveColumnWidths: [100], + chrome: 'none', + renderLine: () => doc.createElement('div'), + applyFragmentFrame: () => {}, + applySdtDataset: () => {}, + applyStyles: (el, styles) => Object.assign(el.style, styles), + }); + + const chromeElements = [ + ...(element.classList.contains('superdoc-structured-content-block') ? [element] : []), + ...Array.from(element.querySelectorAll('.superdoc-structured-content-block')), + ]; + // Wrapper is still produced (proves the SDT path ran; host/custom UI and + // the geometry APIs depend on it)... + expect(chromeElements.length).toBeGreaterThanOrEqual(1); + // ...but the built-in label is not emitted under chrome: 'none'. + expect(element.querySelector('.superdoc-structured-content__label')).toBeFalsy(); + }); + describe('merged-cell border ownership', () => { it('renders the outer right border for a merged header cell in collapsed mode', () => { const block: TableBlock = { diff --git a/packages/layout-engine/painters/dom/src/virtualization.test.ts b/packages/layout-engine/painters/dom/src/virtualization.test.ts index c7e508087e..a87a803803 100644 --- a/packages/layout-engine/painters/dom/src/virtualization.test.ts +++ b/packages/layout-engine/painters/dom/src/virtualization.test.ts @@ -222,6 +222,9 @@ describe('DomPainter virtualization (vertical)', () => { mount.scrollTop = 3 * (500 + 72); mount.dispatchEvent(new Event('scroll')); + // Page 0 must actually evict before we can prove it remounts; without this + // the test would pass even if virtualization silently stopped evicting. + expect(mount.querySelector('.superdoc-page[data-page-index="0"]')).toBeNull(); mount.scrollTop = 0; mount.dispatchEvent(new Event('scroll')); diff --git a/packages/superdoc/src/SuperDoc.test.js b/packages/superdoc/src/SuperDoc.test.js index 3a9ab1410c..89c551408d 100644 --- a/packages/superdoc/src/SuperDoc.test.js +++ b/packages/superdoc/src/SuperDoc.test.js @@ -828,6 +828,27 @@ describe('SuperDoc.vue', () => { expect(options.layoutEngineOptions.flowMode).toBe('paginated'); }); + it('forwards modules.contentControls.chrome into layoutEngineOptions for PresentationEditor', async () => { + const superdocStub = createSuperdocStub(); + superdocStub.config.modules.contentControls = { chrome: 'none' }; + + const wrapper = await mountComponent(superdocStub); + await nextTick(); + + const options = wrapper.findComponent(SuperEditorStub).props('options'); + expect(options.layoutEngineOptions.contentControlsChrome).toBe('none'); + }); + + it('leaves contentControlsChrome undefined when modules.contentControls is not configured', async () => { + const superdocStub = createSuperdocStub(); + + const wrapper = await mountComponent(superdocStub); + await nextTick(); + + const options = wrapper.findComponent(SuperEditorStub).props('options'); + expect(options.layoutEngineOptions.contentControlsChrome).toBeUndefined(); + }); + it('handles replay comment update/delete events and triggers tracked-change resync', async () => { const superdocStub = createSuperdocStub(); const wrapper = await mountComponent(superdocStub); diff --git a/tests/consumer-typecheck/src/modules-config-passthrough.ts b/tests/consumer-typecheck/src/modules-config-passthrough.ts index 3524bf9157..3a66e5f8a3 100644 --- a/tests/consumer-typecheck/src/modules-config-passthrough.ts +++ b/tests/consumer-typecheck/src/modules-config-passthrough.ts @@ -67,6 +67,14 @@ const config: Config = { suppressInternalExternalComments: false, }, + // Documented field: built-in SDT chrome mode (SD-3159). A consumer must be + // able to set the union value and get IDE help on it; the pass-through + // index signature accepts forwarded extras like every other module config. + contentControls: { + chrome: 'none', + forwardedFlag: true, + }, + ai: { apiKey: 'test-key', endpoint: 'https://example.invalid/ai', From 9a9ccec91d76b4f33061a1faa0ba78b135af2768 Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Thu, 28 May 2026 19:21:32 -0300 Subject: [PATCH 5/6] fix(types): type modules.contentControls exactly (no pass-through index) contentControls has a single real runtime option: SuperDoc.vue reads modules.contentControls.chrome and forwards nothing else. The & Record intersection (mirrored from sibling module configs that genuinely forward extra keys) gave no typo safety for the new public option, and made the consumer fixture pin a pass-through contract that does not exist. Drop the index signature and the fixture's forwardedFlag so an unknown contentControls key is now a compile error. --- packages/superdoc/src/core/types/index.ts | 2 +- tests/consumer-typecheck/src/modules-config-passthrough.ts | 7 ++++--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/packages/superdoc/src/core/types/index.ts b/packages/superdoc/src/core/types/index.ts index 0122acd521..bb5a2d7234 100644 --- a/packages/superdoc/src/core/types/index.ts +++ b/packages/superdoc/src/core/types/index.ts @@ -1091,7 +1091,7 @@ export interface Modules { contentControls?: { /** Built-in SDT chrome rendering mode. */ chrome?: 'default' | 'none'; - } & Record; + }; /** * Comments module configuration (false to disable). The named fields below * are typed for IDE help; the runtime spreads the entire object through the diff --git a/tests/consumer-typecheck/src/modules-config-passthrough.ts b/tests/consumer-typecheck/src/modules-config-passthrough.ts index 3a66e5f8a3..9fd12d8f16 100644 --- a/tests/consumer-typecheck/src/modules-config-passthrough.ts +++ b/tests/consumer-typecheck/src/modules-config-passthrough.ts @@ -68,11 +68,12 @@ const config: Config = { }, // Documented field: built-in SDT chrome mode (SD-3159). A consumer must be - // able to set the union value and get IDE help on it; the pass-through - // index signature accepts forwarded extras like every other module config. + // able to set the union value and get IDE help on it. Unlike the other + // module configs in this fixture, contentControls is exact (no pass-through + // index signature): it has a single real runtime option, so an unknown key + // is a typo to catch, not a forwarded setting. contentControls: { chrome: 'none', - forwardedFlag: true, }, ai: { From dca5e5e3e08086bc8856847e28fb8bfef044dcf4 Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Thu, 28 May 2026 19:24:38 -0300 Subject: [PATCH 6/6] docs(consumer-typecheck): correct module-config type-policy header The header claimed every modules.X shape is intentionally open. That is now false: configs that forward runtime extras stay open, but contentControls (one real option, no forwarding) is intentionally exact. Reword so the file documents both policies. Comment-only. --- .../src/modules-config-passthrough.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/tests/consumer-typecheck/src/modules-config-passthrough.ts b/tests/consumer-typecheck/src/modules-config-passthrough.ts index 9fd12d8f16..de66573512 100644 --- a/tests/consumer-typecheck/src/modules-config-passthrough.ts +++ b/tests/consumer-typecheck/src/modules-config-passthrough.ts @@ -1,12 +1,14 @@ /** * Consumer typecheck: realistic Config with `modules.*` pass-through fields. * - * The runtime spreads consumer-provided module configs into downstream - * stores (comments-store, SuperToolbar, etc.), so each `modules.X` shape - * is intentionally open: typed fields for IDE help on documented options, + * The runtime spreads many consumer-provided module configs into downstream + * stores (comments-store, SuperToolbar, etc.), so those `modules.X` shapes + * are intentionally open: typed fields for IDE help on documented options, * plus an index-signature intersection to accept additional keys that the * runtime forwards. This fixture pins that contract so a future PR cannot - * silently re-narrow these into closed object literals. + * silently re-narrow them into closed object literals. Configs that forward + * nothing (e.g. `contentControls`, with a single real option) are instead + * intentionally exact; this fixture pins that shape too. * * Past regressions covered here: * - SD-2869 review pass flagged `Modules.comments` rejecting