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',