Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions apps/docs/editor/custom-ui/content-controls.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
2 changes: 2 additions & 0 deletions apps/docs/editor/theming/custom-themes.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
54 changes: 54 additions & 0 deletions packages/layout-engine/painters/dom/src/styles.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
73 changes: 54 additions & 19 deletions packages/layout-engine/painters/dom/src/styles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
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));
}
Comment thread
caio-pizzol marked this conversation as resolved.
.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 {
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
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.
Expand Down Expand Up @@ -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;
}

Expand Down
19 changes: 18 additions & 1 deletion packages/superdoc/src/assets/styles/helpers/variables.css
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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);
Expand Down
Loading