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/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;
diff --git a/packages/layout-engine/painters/dom/src/index.test.ts b/packages/layout-engine/painters/dom/src/index.test.ts
index 942586e187..ce7f7bb813 100644
--- a/packages/layout-engine/painters/dom/src/index.test.ts
+++ b/packages/layout-engine/painters/dom/src/index.test.ts
@@ -2546,6 +2546,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-*)
@@ -2858,6 +2914,74 @@ describe('DomPainter', () => {
expect(wrapper?.dataset.pmEnd).toBe('22');
});
+ 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
@@ -13918,6 +14042,64 @@ describe('applyRunDataAttributes', () => {
expect(fragment.style.getPropertyValue('--sd-sdt-chrome-width')).toBe('100px');
});
+ 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 e1772b6197..922d54dac0 100644
--- a/packages/layout-engine/painters/dom/src/paragraph/renderParagraphContent.ts
+++ b/packages/layout-engine/painters/dom/src/paragraph/renderParagraphContent.ts
@@ -107,6 +107,7 @@ export type RenderParagraphContentParams = {
convertFinalParagraphMark?: boolean;
lineTopOffset?: number;
sourceAnchor?: SourceAnchor;
+ contentControlsChrome?: 'default' | 'none';
};
export type RenderParagraphContentResult = {
@@ -139,6 +140,7 @@ export const renderParagraphContent = (params: RenderParagraphContentParams): Re
onSdtContainerChrome,
applySdtDataset,
applyContainerSdtDataset,
+ contentControlsChrome,
renderDropCap,
lineTopOffset = 0,
} = params;
@@ -163,7 +165,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 ef368ff25a..9a29784d45 100644
--- a/packages/layout-engine/painters/dom/src/sdt/container.ts
+++ b/packages/layout-engine/painters/dom/src/sdt/container.ts
@@ -112,6 +112,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 bd8de6d1d2..2d62b2cc53 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 7a83744378..fec200adcd 100644
--- a/packages/layout-engine/painters/dom/src/styles.test.ts
+++ b/packages/layout-engine/painters/dom/src/styles.test.ts
@@ -269,6 +269,59 @@ 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');
+ });
+
+ it('suppresses block SDT pseudo-element chrome (::before/::after) under chrome-none', () => {
+ // Block chrome is painted through ::before (background) and ::after
+ // (border) pseudo-elements, so element-level rules cannot reach it. These
+ // selectors must exist or block chrome leaks under contentControlsChrome=none.
+ ensureSdtContainerStyles(document);
+ const styleEl = document.querySelector('[data-superdoc-sdt-container-styles="true"]');
+ const cssText = styleEl?.textContent ?? '';
+
+ // Selected-node blue border (the case viewing mode has no equivalent for).
+ expect(cssText).toContain(
+ '.superdoc-cc-chrome-none .superdoc-structured-content-block.ProseMirror-selectednode::after',
+ );
+ // 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.
+ expect(cssText).toContain(
+ '.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', () => {
diff --git a/packages/layout-engine/painters/dom/src/styles.ts b/packages/layout-engine/painters/dom/src/styles.ts
index afc84f7846..e86fb474a0 100644
--- a/packages/layout-engine/painters/dom/src/styles.ts
+++ b/packages/layout-engine/painters/dom/src/styles.ts
@@ -784,6 +784,35 @@ const SDT_CONTAINER_STYLES = `
background-color: transparent;
}
+/* Global content-control chrome opt-out: preserve SDT wrappers/datasets while
+ * 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;
+ 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).
@@ -809,6 +838,35 @@ const SDT_CONTAINER_STYLES = `
background-color: var(--sd-content-controls-lock-hover-bg, rgba(98, 155, 231, 0.08));
}
+/* Chrome opt-out for block SDTs. Main paints block chrome through ::before
+ * (background) and ::after (border) pseudo-elements, which the element-level
+ * .superdoc-cc-chrome-none rules above cannot reach. Suppress the pseudo
+ * chrome directly, including the selected-node border and the lock-hover
+ * ::before background. Declared after every chrome-showing pseudo rule so
+ * source order resolves equal-specificity ties, the same way the
+ * viewing-mode rules below do. */
+.superdoc-cc-chrome-none .superdoc-structured-content-block::before,
+.superdoc-cc-chrome-none .superdoc-structured-content-block:hover::before,
+.superdoc-cc-chrome-none .superdoc-structured-content-block.sdt-group-hover::before,
+.superdoc-cc-chrome-none .superdoc-structured-content-block[data-lock-mode].sdt-group-hover::before {
+ background: none;
+}
+
+.superdoc-cc-chrome-none .superdoc-structured-content-block::after,
+.superdoc-cc-chrome-none .superdoc-structured-content-block:hover::after,
+.superdoc-cc-chrome-none .superdoc-structured-content-block.sdt-group-hover::after,
+.superdoc-cc-chrome-none .superdoc-structured-content-block.ProseMirror-selectednode::after {
+ border: none;
+}
+
+/* Reset the lock-hover z-index boost so a suppressed SDT does not stack
+ * above host-attached custom UI. Mirrors the base lock-hover selectors with
+ * the chrome-none prefix so specificity stays above the boost rule. */
+.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']) {
+ 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.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/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.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/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..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,8 @@ 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;
@@ -233,6 +235,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 b5bcb04ea4..28ffde7618 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.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/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..bb5a2d7234 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';
+ };
/**
* 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 3524bf9157..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
@@ -67,6 +69,15 @@ 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. 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',
+ },
+
ai: {
apiKey: 'test-key',
endpoint: 'https://example.invalid/ai',