diff --git a/apps/docs/editor/custom-ui/content-controls.mdx b/apps/docs/editor/custom-ui/content-controls.mdx index c0a94bcce8..9f3d73f734 100644 --- a/apps/docs/editor/custom-ui/content-controls.mdx +++ b/apps/docs/editor/custom-ui/content-controls.mdx @@ -30,6 +30,33 @@ new SuperDoc({ The event tells you *what* is active; `getRect` tells you *where* to draw. `active` is an `SdtRef` with `id`, `tag`, `alias`, `controlType`, and `scope`. +## Style the controls in place + +Turning off chrome erases the built-in look, including hover and selection. To paint your own field and clause look, set `--sd-content-controls-custom-*` variables on the painted wrapper. Target it by your own `data-sdt-*` attributes. No `!important`, and no need to touch SuperDoc's internal state classes: the painter applies your variables across rest, hover, and selected, so the box stays stable and you never write `.ProseMirror-selectednode` or hover rules yourself. + +```css +/* A field your app tagged { kind: 'smartField', ... } */ +.superdoc-cc-chrome-none .superdoc-structured-content-inline[data-sdt-tag*='smartField'] { + --sd-content-controls-custom-inline-border: 1px solid #1355ff; + --sd-content-controls-custom-inline-bg: color-mix(in srgb, #1355ff 12%, transparent); + --sd-content-controls-custom-inline-hover-bg: color-mix(in srgb, #1355ff 20%, transparent); + --sd-content-controls-custom-inline-radius: 4px; + --sd-content-controls-custom-inline-padding: 1px 6px; +} + +/* A clause your app tagged { kind: 'reusableSection', ... } */ +.superdoc-cc-chrome-none .superdoc-structured-content-block[data-sdt-tag*='reusableSection'] { + --sd-content-controls-custom-block-border: 1px solid #d6e0ff; + --sd-content-controls-custom-block-border-left: 4px solid #1355ff; /* accent rail */ + --sd-content-controls-custom-block-bg: color-mix(in srgb, #1355ff 4%, transparent); + --sd-content-controls-custom-block-radius: 6px; +} +``` + +`border` is a full CSS shorthand; `border-left` is an optional accent rail for block clauses. The background variables cascade, so set only what changes: `-hover-bg` defaults to `-bg`, and `-selected-bg` defaults to `-hover-bg`. + +This is the path for `chrome: 'none'`. To theme the **built-in** chrome instead (`chrome: 'default'`), use the `--sd-content-controls-*` variables (without `custom`). + ## Pick the right surface | Goal | API | diff --git a/apps/docs/editor/theming/custom-themes.mdx b/apps/docs/editor/theming/custom-themes.mdx index f21cbcfab9..1f3eda8d8a 100644 --- a/apps/docs/editor/theming/custom-themes.mdx +++ b/apps/docs/editor/theming/custom-themes.mdx @@ -318,6 +318,8 @@ If you also want tracked-change text inside comment threads to match, set `--sd- DOCX content controls (SDTs): form fields, dropdowns, date pickers. +These theme the **built-in** chrome (`modules.contentControls.chrome: 'default'`). If you turn the chrome off (`chrome: 'none'`) to draw your own field/clause look, style the controls with the `--sd-content-controls-custom-*` variables instead. See [Custom UI > Content controls](/editor/custom-ui/content-controls). + | Variable | Default | Controls | |----------|---------|----------| | `--sd-content-controls-block-border` | `#629be7` | Block control border | diff --git a/packages/layout-engine/painters/dom/src/styles.test.ts b/packages/layout-engine/painters/dom/src/styles.test.ts index fec200adcd..b8eb27442e 100644 --- a/packages/layout-engine/painters/dom/src/styles.test.ts +++ b/packages/layout-engine/painters/dom/src/styles.test.ts @@ -322,6 +322,60 @@ describe('ensureSdtContainerStyles', () => { expect(lastChromeShowing).toBeGreaterThan(-1); expect(chromeNoneSuppression).toBeGreaterThan(lastChromeShowing); }); + + it('exposes a --sd-content-controls-custom-* styling surface under chrome-none (SD-3322)', () => { + ensureSdtContainerStyles(document); + const styleEl = document.querySelector('[data-superdoc-sdt-container-styles="true"]'); + const cssText = styleEl?.textContent ?? ''; + + // Inline rest reads the custom vars; the default-preserving fallbacks + // (0-width transparent border, no background/radius/padding) keep + // chrome-none visually empty when no variable is set. + expect(cssText).toContain('background: var(--sd-content-controls-custom-inline-bg, none);'); + expect(cssText).toContain('border: var(--sd-content-controls-custom-inline-border, 0 solid transparent);'); + expect(cssText).toContain('padding: var(--sd-content-controls-custom-inline-padding, 0);'); + expect(cssText).toContain('border-radius: var(--sd-content-controls-custom-inline-radius, 0);'); + + // Hover and selected re-assert the SAME border var (constant box, no jitter) + // and read the background vars, which cascade from the rest background. + expect(cssText).toContain( + 'background: var(--sd-content-controls-custom-inline-hover-bg, var(--sd-content-controls-custom-inline-bg, none));', + ); + expect(cssText).toContain( + 'background: var(--sd-content-controls-custom-inline-selected-bg, var(--sd-content-controls-custom-inline-hover-bg, var(--sd-content-controls-custom-inline-bg, none)));', + ); + + // Block exposes the same set plus an accent rail (-border-left) that falls + // back to the regular border. + expect(cssText).toContain('background: var(--sd-content-controls-custom-block-bg, none);'); + expect(cssText).toContain( + 'border-left: var(--sd-content-controls-custom-block-border-left, var(--sd-content-controls-custom-block-border, 0 solid transparent));', + ); + }); + + it('locked-hover under chrome-none follows the custom hover background, not the built-in lock-hover (SD-3322)', () => { + ensureSdtContainerStyles(document); + const styleEl = document.querySelector('[data-superdoc-sdt-container-styles="true"]'); + const cssText = styleEl?.textContent ?? ''; + + // The base lock-hover rules (built-in tint on inline, transparent on block) + // come first and have equal specificity to the plain custom hover rules, so + // they would otherwise win for locked controls. + const baseInlineLockHover = cssText.indexOf('background-color: var(--sd-content-controls-lock-hover-bg'); + const baseBlockLockHover = cssText.indexOf( + '.superdoc-structured-content-block[data-lock-mode].sdt-group-hover:not(.ProseMirror-selectednode) {', + ); + expect(baseInlineLockHover).toBeGreaterThan(-1); + expect(baseBlockLockHover).toBeGreaterThan(-1); + + // The chrome-none lock-hover reset re-asserts the custom hover background + // AFTER them (extra .superdoc-cc-chrome-none class + later source order wins), + // so a locked control under chrome:'none' uses the custom variable. + const customInlineHoverReassert = cssText.lastIndexOf('--sd-content-controls-custom-inline-hover-bg'); + const customBlockHoverReassert = cssText.lastIndexOf('--sd-content-controls-custom-block-hover-bg'); + expect(customInlineHoverReassert).toBeGreaterThan(baseInlineLockHover); + expect(customBlockHoverReassert).toBeGreaterThan(baseBlockLockHover); + }); }); describe('ensureTrackChangeStyles', () => { diff --git a/packages/layout-engine/painters/dom/src/styles.ts b/packages/layout-engine/painters/dom/src/styles.ts index e86fb474a0..0a6198befc 100644 --- a/packages/layout-engine/painters/dom/src/styles.ts +++ b/packages/layout-engine/painters/dom/src/styles.ts @@ -787,30 +787,56 @@ const SDT_CONTAINER_STYLES = ` /* 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, + * present (DOM non-emission). documentSection chrome (e.g. the locked-section + * tooltip) is intentionally preserved and not in scope. + * + * Custom styling surface (SD-3322): instead of fully erasing the look, these + * rules read --sd-content-controls-custom-* variables whose defaults reproduce + * the empty look (0-width transparent border, no background, no radius/padding). + * So chrome:'none' stays visually empty by default, but a consumer can paint + * their own field/clause look by setting those variables on the painted wrapper + * (target it via data-sdt-* attributes) - no !important, and no need to fight + * the .ProseMirror-selectednode / .sdt-group-hover state classes, because the + * painter reads the variables across rest, hover, and selected. The border is a + * full shorthand (e.g. "1px solid #1355ff"); its default "0 solid transparent" + * is identical in layout to no border. It's re-asserted in every state so the + * box never shifts (no jitter); only the background changes on hover/selected. + * Block controls add a -border-left override for an accent rail. */ +.superdoc-cc-chrome-none .superdoc-structured-content-inline { + padding: var(--sd-content-controls-custom-inline-padding, 0); + border: var(--sd-content-controls-custom-inline-border, 0 solid transparent); + border-radius: var(--sd-content-controls-custom-inline-radius, 0); + background: var(--sd-content-controls-custom-inline-bg, none); +} .superdoc-cc-chrome-none .superdoc-structured-content-block { - border: none; - padding: 0; - border-radius: 0; - background: none; + padding: var(--sd-content-controls-custom-block-padding, 0); + border: var(--sd-content-controls-custom-block-border, 0 solid transparent); + border-left: var(--sd-content-controls-custom-block-border-left, var(--sd-content-controls-custom-block-border, 0 solid transparent)); + border-radius: var(--sd-content-controls-custom-block-radius, 0); + background: var(--sd-content-controls-custom-block-bg, none); } .superdoc-cc-chrome-none .superdoc-structured-content-inline:hover, +.superdoc-cc-chrome-none .superdoc-structured-content-inline[data-lock-mode]:hover { + border: var(--sd-content-controls-custom-inline-border, 0 solid transparent); + background: var(--sd-content-controls-custom-inline-hover-bg, var(--sd-content-controls-custom-inline-bg, none)); +} .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-block[data-lock-mode].sdt-group-hover { + border: var(--sd-content-controls-custom-block-border, 0 solid transparent); + border-left: var(--sd-content-controls-custom-block-border-left, var(--sd-content-controls-custom-block-border, 0 solid transparent)); + background: var(--sd-content-controls-custom-block-hover-bg, var(--sd-content-controls-custom-block-bg, none)); } -.superdoc-cc-chrome-none .superdoc-structured-content-inline.ProseMirror-selectednode, +.superdoc-cc-chrome-none .superdoc-structured-content-inline.ProseMirror-selectednode { + border: var(--sd-content-controls-custom-inline-border, 0 solid transparent); + background: var(--sd-content-controls-custom-inline-selected-bg, var(--sd-content-controls-custom-inline-hover-bg, var(--sd-content-controls-custom-inline-bg, none))); +} .superdoc-cc-chrome-none .superdoc-structured-content-block.ProseMirror-selectednode { - border-color: transparent; - background: none; + border: var(--sd-content-controls-custom-block-border, 0 solid transparent); + border-left: var(--sd-content-controls-custom-block-border-left, var(--sd-content-controls-custom-block-border, 0 solid transparent)); + background: var(--sd-content-controls-custom-block-selected-bg, var(--sd-content-controls-custom-block-hover-bg, var(--sd-content-controls-custom-block-bg, none))); } /* Hover highlight for SDT containers. @@ -859,11 +885,20 @@ const SDT_CONTAINER_STYLES = ` 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), +/* Chrome opt-out for the lock-hover affordance. The base lock-hover rules above + * paint a built-in tint and boost z-index on hovered locked controls; under + * chrome:'none' that would override the custom hover background and stack above + * host-attached UI. Re-assert the custom hover background (so a locked control + * follows --sd-content-controls-custom-*-hover-bg, defaulting to empty - no tint + * leaks) and reset the z-index. Mirrors the base lock-hover selectors with the + * chrome-none prefix, so the extra class wins over the base rules. Split inline + * vs block because each reads its own hover variable. */ .superdoc-cc-chrome-none .superdoc-structured-content-inline[data-lock-mode]:hover:not(.ProseMirror-selectednode, [data-appearance='hidden']) { + background: var(--sd-content-controls-custom-inline-hover-bg, var(--sd-content-controls-custom-inline-bg, none)); + z-index: auto; +} +.superdoc-cc-chrome-none .superdoc-structured-content-block[data-lock-mode].sdt-group-hover:not(.ProseMirror-selectednode) { + background: var(--sd-content-controls-custom-block-hover-bg, var(--sd-content-controls-custom-block-bg, none)); z-index: auto; } diff --git a/packages/superdoc/src/assets/styles/helpers/variables.css b/packages/superdoc/src/assets/styles/helpers/variables.css index d8ec54adb3..1da9f2d8bc 100644 --- a/packages/superdoc/src/assets/styles/helpers/variables.css +++ b/packages/superdoc/src/assets/styles/helpers/variables.css @@ -199,7 +199,8 @@ --sd-tracked-changes-delete-background-focused: #cb0e4744; --sd-tracked-changes-format-background-focused: #ffd70033; - /* Styles: content controls (SDT) — blue accent, intentionally standalone */ + /* Styles: content controls (SDT) — blue accent, intentionally standalone. + These theme the BUILT-IN chrome (modules.contentControls.chrome: 'default'). */ --sd-content-controls-block-border: #629be7; --sd-content-controls-block-hover-border: transparent; --sd-content-controls-block-hover-bg: var(--sd-ui-hover-bg); @@ -211,6 +212,22 @@ --sd-content-controls-label-text: #ffffff; --sd-content-controls-lock-hover-bg: rgba(98, 155, 231, 0.08); + /* Custom SDT styling under chrome:'none' (SD-3322). The built-in chrome is + off, so set these on the painted wrapper (target via data-sdt-* attributes) + to paint your own field/clause look. The painter applies them across rest, + hover, and selected, so no !important and no state-class selectors are + needed. Unset by default (chrome:'none' stays visually empty). `border` is a + full shorthand, e.g. `1px solid #1355ff`; block adds a `-border-left` for an + accent rail. Inline: + --sd-content-controls-custom-inline-bg + --sd-content-controls-custom-inline-border + --sd-content-controls-custom-inline-radius + --sd-content-controls-custom-inline-padding + --sd-content-controls-custom-inline-hover-bg + --sd-content-controls-custom-inline-selected-bg + Block (same set, plus): + --sd-content-controls-custom-block-border-left */ + /* UI: surface system — dialog and floating overlays */ --sd-ui-surface-bg: var(--sd-popover-bg, var(--sd-ui-bg)); --sd-ui-surface-border: var(--sd-ui-border);