From 90d8ea8a85eebfb0d2a09e2ff5b90af28ca3b9f8 Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Wed, 18 Mar 2026 13:23:37 -0300 Subject: [PATCH 1/9] feat(theme): add createTheme() and buildTheme() helpers (SD-2255) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Shadcn-inspired JS theming API. Set ~10 semantic color properties and the entire UI updates. A `vars` escape hatch covers any CSS variable not in the semantic layer. API: createTheme({ colors, font, radius, shadow, vars }) → className buildTheme(config) → { className, css } (for SSR) - colors: action, bg, text, border, etc. map to --sd-ui-* variables - font/radius/shadow: top-level shortcuts - vars: raw CSS variable overrides for power users - Style element auto-injected, idempotent on re-call - Exported from superdoc package - 17 unit tests --- .../superdoc/src/core/theme/create-theme.js | 136 +++++++++++++++ .../src/core/theme/create-theme.test.js | 161 ++++++++++++++++++ packages/superdoc/src/index.js | 1 + 3 files changed, 298 insertions(+) create mode 100644 packages/superdoc/src/core/theme/create-theme.js create mode 100644 packages/superdoc/src/core/theme/create-theme.test.js diff --git a/packages/superdoc/src/core/theme/create-theme.js b/packages/superdoc/src/core/theme/create-theme.js new file mode 100644 index 0000000000..bfaf79e6f2 --- /dev/null +++ b/packages/superdoc/src/core/theme/create-theme.js @@ -0,0 +1,136 @@ +/** + * @typedef {Object} ThemeColors + * @property {string} [action] Action/accent color (buttons, links, active states). Default: #1355ff + * @property {string} [actionHover] Action hover state. Default: #0f44cc + * @property {string} [bg] Default background for panels, cards, dropdowns. Default: #ffffff + * @property {string} [hoverBg] Hover background. Default: #dbdbdb + * @property {string} [activeBg] Active/pressed background. Default: #c8d0d8 + * @property {string} [disabledBg] Disabled background. Default: #f5f5f5 + * @property {string} [text] Primary text color. Default: #47484a + * @property {string} [textMuted] Secondary/muted text. Default: #666666 + * @property {string} [textDisabled] Disabled text. Default: #ababab + * @property {string} [border] Default border color. Default: #dbdbdb + */ + +/** + * @typedef {Object} ThemeConfig + * @property {string} [name] Theme name — used in the generated class name (e.g., "dark" → "sd-theme-dark") + * @property {string} [font] UI font family + * @property {string} [radius] Default border radius (e.g., "8px") + * @property {string} [shadow] Default box shadow + * @property {ThemeColors} [colors] Core color palette — cascades to every component + * @property {Record} [vars] Escape hatch — raw CSS variable overrides (e.g., { '--sd-ui-toolbar-bg': '#f8fafc' }) + */ + +/** @type {Record} */ +const COLORS_TO_VARS = { + action: '--sd-ui-action', + actionHover: '--sd-ui-action-hover', + bg: '--sd-ui-bg', + hoverBg: '--sd-ui-hover-bg', + activeBg: '--sd-ui-active-bg', + disabledBg: '--sd-ui-disabled-bg', + text: '--sd-ui-text', + textMuted: '--sd-ui-text-muted', + textDisabled: '--sd-ui-text-disabled', + border: '--sd-ui-border', +}; + +let themeCounter = 0; + +/** + * Create a SuperDoc theme from a config object. + * + * Returns a CSS class name. Apply it to `` to activate the theme. + * The style element is injected into the document automatically. + * + * @param {ThemeConfig} config + * @returns {string} The generated CSS class name + * + * @example + * ```js + * import { createTheme } from 'superdoc'; + * + * const theme = createTheme({ + * colors: { action: '#6366f1', bg: '#ffffff', text: '#1e293b' }, + * font: 'Inter, sans-serif', + * vars: { '--sd-ui-toolbar-bg': '#f8fafc' }, + * }); + * + * document.documentElement.classList.add(theme); + * ``` + */ +export function createTheme(config) { + const { name, font, radius, shadow, colors, vars } = config; + const className = `sd-theme-${name || `custom-${++themeCounter}`}`; + + /** @type {string[]} */ + const declarations = []; + + // Map semantic colors + if (colors) { + for (const [key, value] of Object.entries(colors)) { + if (value == null) continue; + const varName = COLORS_TO_VARS[key]; + if (varName) { + declarations.push(` ${varName}: ${value};`); + } + } + } + + // Map top-level shortcuts + if (font != null) declarations.push(` --sd-ui-font-family: ${font};`); + if (radius != null) declarations.push(` --sd-ui-radius: ${radius};`); + if (shadow != null) declarations.push(` --sd-ui-shadow: ${shadow};`); + + // Spread raw CSS variable overrides + if (vars) { + for (const [varName, value] of Object.entries(vars)) { + if (value == null) continue; + declarations.push(` ${varName}: ${value};`); + } + } + + if (declarations.length === 0) return className; + + const css = `.${className} {\n${declarations.join('\n')}\n}`; + + // Inject into document (SSR-safe — skips if no document) + if (typeof document !== 'undefined') { + let style = document.querySelector(`[data-sd-theme="${className}"]`); + if (!style) { + style = document.createElement('style'); + style.setAttribute('data-sd-theme', className); + document.head.appendChild(style); + } + style.textContent = css; + } + + // Store CSS for buildTheme() access + createTheme._lastCss = css; + + return className; +} + +/** + * Build a SuperDoc theme and return both the class name and raw CSS. + * Use this for SSR where you need to inject styles into the HTML template. + * + * @param {ThemeConfig} config + * @returns {{ className: string, css: string }} + * + * @example + * ```js + * import { buildTheme } from 'superdoc'; + * + * const { className, css } = buildTheme({ + * colors: { action: '#6366f1', bg: '#ffffff', text: '#1e293b' }, + * }); + * + * const html = `...`; + * ``` + */ +export function buildTheme(config) { + const className = createTheme(config); + return { className, css: createTheme._lastCss || '' }; +} diff --git a/packages/superdoc/src/core/theme/create-theme.test.js b/packages/superdoc/src/core/theme/create-theme.test.js new file mode 100644 index 0000000000..3134b3a49f --- /dev/null +++ b/packages/superdoc/src/core/theme/create-theme.test.js @@ -0,0 +1,161 @@ +import { describe, expect, it, beforeEach } from 'vitest'; +import { createTheme, buildTheme } from './create-theme.js'; + +describe('createTheme', () => { + beforeEach(() => { + document.querySelectorAll('[data-sd-theme]').forEach((el) => el.remove()); + }); + + it('returns a class name with sd-theme- prefix', () => { + const cls = createTheme({ colors: { action: '#ff0000' } }); + expect(cls).toMatch(/^sd-theme-/); + }); + + it('uses the provided name', () => { + expect(createTheme({ name: 'dark', colors: { bg: '#000' } })).toBe('sd-theme-dark'); + }); + + it('generates unique names when no name provided', () => { + const a = createTheme({ colors: { action: '#111' } }); + const b = createTheme({ colors: { action: '#222' } }); + expect(a).not.toBe(b); + }); + + describe('color mapping', () => { + it('maps all color properties to CSS variables', () => { + const { css } = buildTheme({ + name: 'colors-test', + colors: { + action: '#6366f1', + actionHover: '#4f46e5', + bg: '#ffffff', + hoverBg: '#f1f5f9', + activeBg: '#e2e8f0', + disabledBg: '#f5f5f5', + text: '#1e293b', + textMuted: '#64748b', + textDisabled: '#94a3b8', + border: '#e2e8f0', + }, + }); + expect(css).toContain('--sd-ui-action: #6366f1'); + expect(css).toContain('--sd-ui-action-hover: #4f46e5'); + expect(css).toContain('--sd-ui-bg: #ffffff'); + expect(css).toContain('--sd-ui-hover-bg: #f1f5f9'); + expect(css).toContain('--sd-ui-active-bg: #e2e8f0'); + expect(css).toContain('--sd-ui-disabled-bg: #f5f5f5'); + expect(css).toContain('--sd-ui-text: #1e293b'); + expect(css).toContain('--sd-ui-text-muted: #64748b'); + expect(css).toContain('--sd-ui-text-disabled: #94a3b8'); + expect(css).toContain('--sd-ui-border: #e2e8f0'); + }); + + it('ignores null and undefined color values', () => { + const { css } = buildTheme({ + name: 'null-test', + colors: { action: '#ff0000', bg: undefined, text: null }, + }); + expect(css).toContain('--sd-ui-action: #ff0000'); + expect(css).not.toContain('--sd-ui-bg'); + expect(css).not.toContain('--sd-ui-text'); + }); + + it('ignores unknown color keys', () => { + const { css } = buildTheme({ + name: 'unknown-test', + colors: { action: '#ff0000', notAColor: '#000' }, + }); + expect(css).toContain('--sd-ui-action'); + expect(css).not.toContain('notAColor'); + }); + }); + + describe('top-level shortcuts', () => { + it('maps font to --sd-ui-font-family', () => { + const { css } = buildTheme({ name: 'font-test', font: 'Inter, sans-serif' }); + expect(css).toContain('--sd-ui-font-family: Inter, sans-serif'); + }); + + it('maps radius to --sd-ui-radius', () => { + const { css } = buildTheme({ name: 'radius-test', radius: '8px' }); + expect(css).toContain('--sd-ui-radius: 8px'); + }); + + it('maps shadow to --sd-ui-shadow', () => { + const { css } = buildTheme({ name: 'shadow-test', shadow: '0 2px 8px rgba(0,0,0,0.1)' }); + expect(css).toContain('--sd-ui-shadow: 0 2px 8px rgba(0,0,0,0.1)'); + }); + }); + + describe('vars escape hatch', () => { + it('spreads raw CSS variable overrides', () => { + const { css } = buildTheme({ + name: 'vars-test', + vars: { + '--sd-ui-toolbar-bg': '#f8fafc', + '--sd-ui-comments-card-bg': '#f0f0ff', + }, + }); + expect(css).toContain('--sd-ui-toolbar-bg: #f8fafc'); + expect(css).toContain('--sd-ui-comments-card-bg: #f0f0ff'); + }); + + it('ignores null vars', () => { + const { css } = buildTheme({ + name: 'vars-null', + vars: { '--sd-ui-toolbar-bg': '#fff', '--sd-ui-menu-bg': null }, + }); + expect(css).toContain('--sd-ui-toolbar-bg'); + expect(css).not.toContain('--sd-ui-menu-bg'); + }); + + it('combines colors and vars', () => { + const { css } = buildTheme({ + name: 'combined', + colors: { action: '#6366f1' }, + vars: { '--sd-ui-toolbar-bg': '#f8fafc' }, + }); + expect(css).toContain('--sd-ui-action: #6366f1'); + expect(css).toContain('--sd-ui-toolbar-bg: #f8fafc'); + }); + }); + + describe('style injection', () => { + it('injects a style element into the document', () => { + const cls = createTheme({ name: 'inject', colors: { action: '#abc' } }); + const style = document.querySelector(`[data-sd-theme="${cls}"]`); + expect(style).not.toBeNull(); + expect(style.textContent).toContain('--sd-ui-action: #abc'); + }); + + it('updates existing style element on re-call with same name', () => { + createTheme({ name: 'reuse', colors: { action: '#111' } }); + createTheme({ name: 'reuse', colors: { action: '#222' } }); + const styles = document.querySelectorAll('[data-sd-theme="sd-theme-reuse"]'); + expect(styles.length).toBe(1); + expect(styles[0].textContent).toContain('#222'); + }); + + it('returns class name even with empty config', () => { + const cls = createTheme({ name: 'empty' }); + expect(cls).toBe('sd-theme-empty'); + // No style element injected for empty config + expect(document.querySelector('[data-sd-theme="sd-theme-empty"]')).toBeNull(); + }); + }); + + describe('buildTheme', () => { + it('returns className and css', () => { + const result = buildTheme({ name: 'build', colors: { action: '#f00' } }); + expect(result.className).toBe('sd-theme-build'); + expect(result.css).toContain('.sd-theme-build'); + expect(result.css).toContain('--sd-ui-action: #f00'); + }); + + it('wraps css in the class selector', () => { + const { css } = buildTheme({ name: 'selector', colors: { bg: '#fff' } }); + expect(css).toMatch(/^\.sd-theme-selector \{/); + expect(css).toMatch(/\}$/); + }); + }); +}); diff --git a/packages/superdoc/src/index.js b/packages/superdoc/src/index.js index 34047a9504..19bad9bd5d 100644 --- a/packages/superdoc/src/index.js +++ b/packages/superdoc/src/index.js @@ -17,6 +17,7 @@ import { getSchemaIntrospection } from './helpers/schema-introspection.js'; // Public exports export { SuperDoc } from './core/SuperDoc.js'; +export { createTheme, buildTheme } from './core/theme/create-theme.js'; export { BlankDOCX, getFileObject, From d499f78981d64b15210ea9d3d373bb1a9aae0268 Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Wed, 18 Mar 2026 13:29:24 -0300 Subject: [PATCH 2/9] refactor(theme): extract generateTheme() to eliminate _lastCss side-channel buildTheme() was reading CSS from a mutable property on createTheme(), which returned stale data when called with an empty config. Both functions now call a shared generateTheme() that returns { className, css } directly. Also extracted injectThemeStyle() for clarity. --- .../superdoc/src/core/theme/create-theme.js | 92 ++++++++++++------- 1 file changed, 57 insertions(+), 35 deletions(-) diff --git a/packages/superdoc/src/core/theme/create-theme.js b/packages/superdoc/src/core/theme/create-theme.js index bfaf79e6f2..0fc30fa9b2 100644 --- a/packages/superdoc/src/core/theme/create-theme.js +++ b/packages/superdoc/src/core/theme/create-theme.js @@ -22,6 +22,11 @@ * @property {Record} [vars] Escape hatch — raw CSS variable overrides (e.g., { '--sd-ui-toolbar-bg': '#f8fafc' }) */ +/* + * These map to the --sd-ui-* variable names introduced in the SD-2083 + * theming system. Components consume them once that PR lands. Until then, + * createTheme() generates the correct variables ahead of time. + */ /** @type {Record} */ const COLORS_TO_VARS = { action: '--sd-ui-action', @@ -39,28 +44,13 @@ const COLORS_TO_VARS = { let themeCounter = 0; /** - * Create a SuperDoc theme from a config object. - * - * Returns a CSS class name. Apply it to `` to activate the theme. - * The style element is injected into the document automatically. + * Generate the className and CSS string from a theme config. + * Shared core used by both createTheme and buildTheme. * * @param {ThemeConfig} config - * @returns {string} The generated CSS class name - * - * @example - * ```js - * import { createTheme } from 'superdoc'; - * - * const theme = createTheme({ - * colors: { action: '#6366f1', bg: '#ffffff', text: '#1e293b' }, - * font: 'Inter, sans-serif', - * vars: { '--sd-ui-toolbar-bg': '#f8fafc' }, - * }); - * - * document.documentElement.classList.add(theme); - * ``` + * @returns {{ className: string, css: string }} */ -export function createTheme(config) { +function generateTheme(config) { const { name, font, radius, shadow, colors, vars } = config; const className = `sd-theme-${name || `custom-${++themeCounter}`}`; @@ -91,24 +81,55 @@ export function createTheme(config) { } } - if (declarations.length === 0) return className; + const css = declarations.length > 0 ? `.${className} {\n${declarations.join('\n')}\n}` : ''; - const css = `.${className} {\n${declarations.join('\n')}\n}`; + return { className, css }; +} - // Inject into document (SSR-safe — skips if no document) - if (typeof document !== 'undefined') { - let style = document.querySelector(`[data-sd-theme="${className}"]`); - if (!style) { - style = document.createElement('style'); - style.setAttribute('data-sd-theme', className); - document.head.appendChild(style); - } - style.textContent = css; +/** + * Inject a theme's CSS into the document as a ` +
+ +`; +``` ## Preset themes -Three preset themes ship out of the box. Add the class to `` — some SuperDoc elements (popovers, dropdowns) are appended to ``, so they need to inherit from `` to pick up the theme. +Three presets ship out of the box. Add the class to `` — some SuperDoc elements (popovers, dropdowns) are appended to ``, so they need to inherit from ``. @@ -49,55 +94,21 @@ Three preset themes ship out of the box. Add the class to `` — some Supe -You can layer your own overrides on top of a preset: +## CSS variables (advanced) + +`createTheme()` generates CSS variables under the hood. You can also set them directly: ```css -.sd-theme-docs { - --sd-ui-action: #your-brand-color; - --sd-ui-comments-card-bg: #f0f0f0; +:root { + --sd-ui-action: #6366f1; + --sd-ui-bg: #ffffff; + --sd-ui-text: #1e293b; + --sd-ui-border: #e2e8f0; + --sd-ui-font-family: Inter, sans-serif; } ``` -## How the token system works - -Variables are organized in three tiers. Higher tiers reference lower tiers, so changing a primitive cascades everywhere. - -``` -Primitives UI semantic Component-specific ---sd-color-* --sd-ui-text --sd-ui-toolbar-* ---sd-font-size-* --sd-ui-bg --sd-ui-comments-* ---sd-radius-* --sd-ui-action --sd-ui-dropdown-* -``` - -**Tier 1 — Primitives.** Raw color scales, font sizes, and radii. You rarely need to touch these directly. - -**Tier 2 — UI semantic.** The "5-variable theme." Change these and every component updates: - -| Variable | Default | What it controls | -|----------|---------|-----------------| -| `--sd-ui-font-family` | `Arial, Helvetica, sans-serif` | Font for all UI chrome | -| `--sd-ui-text` | `#47484a` | Primary text color | -| `--sd-ui-bg` | `#ffffff` | Panels, cards, dropdowns | -| `--sd-ui-border` | `#dbdbdb` | All borders | -| `--sd-ui-action` | `#1355ff` | Buttons, links, active states | - -**Tier 3 — Component-specific.** Fine-grained overrides for individual components. These default to the semantic tier, so you only set them when a component needs to look different from the rest. See the [full reference](/guides/general/custom-themes). - -## JavaScript config - -The `trackChangesConfig` and `commentsConfig` JavaScript options still work. They take priority over CSS variables when set. Use the JS API for runtime changes, CSS variables for static theming. - -```javascript -new SuperDoc({ - selector: '#editor', - commentsConfig: { - highlightHoverColor: '#ff000055', - trackChangeHighlightColors: { - insertBorder: '#00ff00', - }, - }, -}); -``` +See the [full variable reference](/guides/general/custom-themes#variable-reference) for every token. ## Next steps @@ -107,7 +118,7 @@ new SuperDoc({ icon="palette" href="/guides/general/custom-themes" > - Full variable reference by component. Dark theme starter. How to build your own preset. + Full API reference, dark theme starter, all CSS variables by component. ` element. - -```css -.my-company-theme { - /* These cascade to every component */ - --sd-ui-action: #8b5cf6; - --sd-ui-action-hover: #7c3aed; - --sd-ui-text: #1e293b; - --sd-ui-bg: #f8fafc; - --sd-ui-border: #e2e8f0; - - /* Fine-tune where needed */ - --sd-ui-toolbar-bg: #f1f5f9; - --sd-ui-comments-card-bg: #f0f0ff; -} +Full `createTheme()` API reference and CSS variable catalog. Start with the [theming overview](/getting-started/theming) if you're new to theming. + +## `createTheme()` options + +```javascript +import { createTheme } from 'superdoc'; + +const theme = createTheme({ + name: 'my-brand', + font: 'Inter, sans-serif', + radius: '8px', + shadow: '0 2px 8px rgba(0,0,0,0.1)', + colors: { + action: '#6366f1', // buttons, links, active states + actionHover: '#4f46e5', + bg: '#ffffff', // panels, cards, dropdowns + hoverBg: '#f1f5f9', // hover backgrounds + activeBg: '#e2e8f0', // active/pressed backgrounds + disabledBg: '#f5f5f5', + text: '#1e293b', // primary text + textMuted: '#64748b', // secondary text + textDisabled: '#94a3b8', + border: '#e2e8f0', + }, + // Escape hatch — any --sd-* CSS variable + vars: { + '--sd-ui-toolbar-bg': '#f8fafc', + '--sd-ui-comments-card-bg': '#f0f0ff', + }, +}); + +document.documentElement.classList.add(theme); ``` -Start with the 5-10 semantic variables. Add component-specific ones only where the defaults don't look right. +Start with `colors` only. Use `vars` when a specific component needs to look different. -## Dark theme starter +## Dark theme example + +```javascript +import { createTheme } from 'superdoc'; + +const dark = createTheme({ + name: 'dark', + colors: { + bg: '#1a1a2e', + hoverBg: '#2a2a3e', + activeBg: '#3a3a4e', + text: '#e2e8f0', + textMuted: '#94a3b8', + border: '#334155', + action: '#60a5fa', + actionHover: '#93c5fd', + }, + vars: { + '--sd-ui-toolbar-bg': '#0f172a', + '--sd-ui-toolbar-button-text': '#e2e8f0', + '--sd-ui-comments-card-bg': '#1e293b', + '--sd-ui-comments-body-text': '#cbd5e1', + }, +}); + +document.documentElement.classList.add(dark); +``` + +## Raw CSS alternative -Override the core surface and text variables. Component backgrounds cascade from `--sd-ui-bg` automatically. +You can also write CSS directly. Apply overrides to ``. ```css .dark-theme { From 58a54d82f66444ea1c8e46fba9c9423a6d85955d Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Wed, 18 Mar 2026 14:06:49 -0300 Subject: [PATCH 4/9] feat: ship AGENTS.md with npm package for AI agent discovery Bun-style approach: agents working in projects that use SuperDoc read node_modules/superdoc/AGENTS.md and instantly know how to use createTheme(), configure the editor, and find all CSS variables. --- packages/superdoc/package.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/superdoc/package.json b/packages/superdoc/package.json index 82f14d5313..1655b2bf29 100644 --- a/packages/superdoc/package.json +++ b/packages/superdoc/package.json @@ -14,7 +14,8 @@ }, "readme": "../../README.md", "files": [ - "dist" + "dist", + "AGENTS.md" ], "exports": { ".": { From 7fa54ad37d99936372319973ed598c7c94a19269 Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Wed, 18 Mar 2026 17:00:12 -0300 Subject: [PATCH 5/9] feat: add theming example and update CLAUDE.md for agent discovery MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add examples/features/theming/ — React + TypeScript demo with 7 themes (3 via createTheme(), 3 presets, 1 default). Follows existing example pattern (comments, track-changes, etc.) - Add theming section to packages/superdoc/CLAUDE.md — agents working in the repo or reading node_modules/superdoc/ find createTheme() API, file pointers, and common vars keys - Add theming rows to root CLAUDE.md "Where to Look" table --- CLAUDE.md | 4 + examples/features/theming/index.html | 16 ++++ examples/features/theming/package.json | 21 +++++ examples/features/theming/src/App.tsx | 99 ++++++++++++++++++++++++ examples/features/theming/src/main.tsx | 9 +++ examples/features/theming/src/themes.ts | 91 ++++++++++++++++++++++ examples/features/theming/vite.config.ts | 6 ++ packages/superdoc/CLAUDE.md | 22 ++++++ 8 files changed, 268 insertions(+) create mode 100644 examples/features/theming/index.html create mode 100644 examples/features/theming/package.json create mode 100644 examples/features/theming/src/App.tsx create mode 100644 examples/features/theming/src/main.tsx create mode 100644 examples/features/theming/src/themes.ts create mode 100644 examples/features/theming/vite.config.ts diff --git a/CLAUDE.md b/CLAUDE.md index 229f5f2989..9ce8fb6110 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -67,6 +67,10 @@ tests/visual/ Visual regression tests (Playwright + R2 baselines) | Visual regression tests | `tests/visual/` (see its CLAUDE.md) | | Document API contract | `packages/document-api/src/contract/operation-definitions.ts` | | Adding a doc-api operation | See `packages/document-api/README.md` § "Adding a new operation" | +| Theming (`createTheme()`) | `packages/superdoc/src/core/theme/create-theme.js` | +| CSS variable defaults | `packages/superdoc/src/assets/styles/helpers/variables.css` | +| Preset themes | `packages/superdoc/src/assets/styles/helpers/themes.css` | +| Consumer-facing agent guide | `packages/superdoc/AGENTS.md` (ships with npm package) | ## Style Resolution Boundary diff --git a/examples/features/theming/index.html b/examples/features/theming/index.html new file mode 100644 index 0000000000..e8a68f7524 --- /dev/null +++ b/examples/features/theming/index.html @@ -0,0 +1,16 @@ + + + + + + SuperDoc — Theming + + + +
+ + + diff --git a/examples/features/theming/package.json b/examples/features/theming/package.json new file mode 100644 index 0000000000..ff5a364c98 --- /dev/null +++ b/examples/features/theming/package.json @@ -0,0 +1,21 @@ +{ + "name": "superdoc-theming-example", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build" + }, + "dependencies": { + "react": "^19.2.1", + "react-dom": "^19.2.1", + "superdoc": "latest" + }, + "devDependencies": { + "@types/react": "^19.2.7", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^5.1.2", + "typescript": "^5.9.3", + "vite": "^7.2.7" + } +} diff --git a/examples/features/theming/src/App.tsx b/examples/features/theming/src/App.tsx new file mode 100644 index 0000000000..33e2b9e2d2 --- /dev/null +++ b/examples/features/theming/src/App.tsx @@ -0,0 +1,99 @@ +import { useEffect, useRef, useState } from 'react'; +import { SuperDoc } from 'superdoc'; +import 'superdoc/style.css'; +import { themes, presets, themeLabels, type ThemeKey } from './themes'; + +const allThemeClasses = [ + ...Object.values(themes).filter(Boolean), + ...Object.values(presets), +] as string[]; + +function applyTheme(key: ThemeKey) { + const html = document.documentElement; + allThemeClasses.forEach((cls) => html.classList.remove(cls)); + + if (key === 'default') return; + const cls = (presets as any)[key] ?? (themes as any)[key]; + if (cls) html.classList.add(cls); +} + +export default function App() { + const [file, setFile] = useState(null); + const [theme, setTheme] = useState('default'); + const containerRef = useRef(null); + const superdocRef = useRef(null); + + useEffect(() => { + applyTheme(theme); + }, [theme]); + + useEffect(() => { + if (!containerRef.current) return; + + superdocRef.current?.destroy(); + superdocRef.current = new SuperDoc({ + selector: containerRef.current, + document: file ?? undefined, + documentMode: 'editing', + user: { name: 'Jane Doe', email: 'jane@example.com' }, + modules: { toolbar: true }, + }); + + return () => { + superdocRef.current?.destroy(); + superdocRef.current = null; + }; + }, [file]); + + return ( +
+
+ + + setFile(e.target.files?.[0] ?? null)} + style={{ color: '#e2e8f0', fontSize: 13 }} + /> + + + createTheme() demo — select a theme and load a .docx to see it in action + +
+ +
+
+ ); +} diff --git a/examples/features/theming/src/main.tsx b/examples/features/theming/src/main.tsx new file mode 100644 index 0000000000..c018515cd7 --- /dev/null +++ b/examples/features/theming/src/main.tsx @@ -0,0 +1,9 @@ +import React from 'react'; +import ReactDOM from 'react-dom/client'; +import App from './App'; + +ReactDOM.createRoot(document.getElementById('root')!).render( + + + , +); diff --git a/examples/features/theming/src/themes.ts b/examples/features/theming/src/themes.ts new file mode 100644 index 0000000000..c0a71c23bf --- /dev/null +++ b/examples/features/theming/src/themes.ts @@ -0,0 +1,91 @@ +import { createTheme } from 'superdoc'; + +export const themes = { + default: null, + + indigo: createTheme({ + name: 'indigo', + font: 'Inter, system-ui, sans-serif', + radius: '8px', + colors: { + action: '#6366f1', + actionHover: '#4f46e5', + bg: '#ffffff', + text: '#1e293b', + textMuted: '#64748b', + border: '#e2e8f0', + hoverBg: '#f1f5f9', + }, + vars: { + '--sd-ui-toolbar-bg': '#f8fafc', + }, + }), + + dark: createTheme({ + name: 'dark', + font: 'Inter, system-ui, sans-serif', + colors: { + bg: '#1a1a2e', + hoverBg: '#2a2a3e', + activeBg: '#3a3a4e', + text: '#e2e8f0', + textMuted: '#94a3b8', + textDisabled: '#64748b', + border: '#334155', + action: '#60a5fa', + actionHover: '#93c5fd', + }, + vars: { + '--sd-ui-toolbar-bg': '#0f172a', + '--sd-ui-toolbar-button-text': '#e2e8f0', + '--sd-ui-dropdown-bg': '#1e293b', + '--sd-ui-dropdown-border': '#334155', + '--sd-ui-menu-bg': '#1e293b', + '--sd-ui-menu-border': '#334155', + '--sd-ui-comments-card-bg': '#1e293b', + '--sd-ui-comments-input-bg': '#1e293b', + '--sd-ui-comments-body-text': '#cbd5e1', + '--sd-ui-tooltip-bg': '#f1f5f9', + '--sd-ui-tooltip-text': '#1e293b', + '--sd-layout-page-shadow': '0 4px 20px rgba(0, 0, 0, 0.4)', + }, + }), + + warm: createTheme({ + name: 'warm', + font: 'Georgia, serif', + radius: '4px', + colors: { + action: '#b45309', + actionHover: '#92400e', + bg: '#fffbeb', + hoverBg: '#fef3c7', + activeBg: '#fde68a', + text: '#451a03', + textMuted: '#78350f', + border: '#d97706', + }, + vars: { + '--sd-ui-toolbar-bg': '#fef3c7', + '--sd-ui-comments-card-bg': '#fef9e7', + }, + }), +} as const; + +export const presets = { + 'preset-docs': 'sd-theme-docs', + 'preset-word': 'sd-theme-word', + 'preset-blueprint': 'sd-theme-blueprint', +} as const; + +export type ThemeKey = keyof typeof themes | keyof typeof presets; + +export const themeLabels: Record = { + default: 'Default', + indigo: 'Indigo Brand', + dark: 'Dark', + warm: 'Warm Earth', + 'preset-docs': 'Preset: Docs', + 'preset-word': 'Preset: Word', + 'preset-blueprint': 'Preset: Blueprint', +}; diff --git a/examples/features/theming/vite.config.ts b/examples/features/theming/vite.config.ts new file mode 100644 index 0000000000..0466183af6 --- /dev/null +++ b/examples/features/theming/vite.config.ts @@ -0,0 +1,6 @@ +import { defineConfig } from 'vite'; +import react from '@vitejs/plugin-react'; + +export default defineConfig({ + plugins: [react()], +}); diff --git a/packages/superdoc/CLAUDE.md b/packages/superdoc/CLAUDE.md index c0a84e3656..1c6b28c204 100644 --- a/packages/superdoc/CLAUDE.md +++ b/packages/superdoc/CLAUDE.md @@ -55,6 +55,28 @@ Uses `layout-engine` for virtualized rendering with pagination. `PresentationEditor.ts` bridges state between modes. See `super-editor/src/core/presentation-editor/` for implementation. +## Theming + +SuperDoc UI is themed via `--sd-*` CSS variables. Use `createTheme()` for JS-based theming or set variables directly in CSS. + +```javascript +import { createTheme } from 'superdoc'; + +const theme = createTheme({ + colors: { action: '#6366f1', bg: '#ffffff', text: '#1e293b', border: '#e2e8f0' }, + font: 'Inter, sans-serif', + vars: { '--sd-ui-toolbar-bg': '#f8fafc' }, // escape hatch for any --sd-* variable +}); + +document.documentElement.classList.add(theme); +``` + +- `createTheme()` / `buildTheme()` — `src/core/theme/create-theme.js` +- CSS variable defaults — `src/assets/styles/helpers/variables.css` +- Preset themes — `src/assets/styles/helpers/themes.css` +- Backward-compat aliases — `src/assets/styles/helpers/compat.css` +- Consumer-facing agent guide — `AGENTS.md` (ships with npm package) + ## Testing - Unit tests: `src/SuperDoc.test.js` From 2c488ee062e8e27fee68fa4206018c0c0dca7579 Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Wed, 18 Mar 2026 17:04:50 -0300 Subject: [PATCH 6/9] chore: update lock file --- pnpm-lock.yaml | 118 +++++++++++++++++++++++++++---------------------- 1 file changed, 64 insertions(+), 54 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5372675ab2..3cf5dcee57 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -851,6 +851,34 @@ importers: specifier: npm:rolldown-vite@7.3.1 version: rolldown-vite@7.3.1(@types/node@25.3.5)(esbuild@0.27.3)(jiti@2.6.1)(less@4.4.2)(sass@1.97.3)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.2) + examples/features/theming: + dependencies: + react: + specifier: ^19.2.1 + version: 19.2.4 + react-dom: + specifier: ^19.2.1 + version: 19.2.4(react@19.2.4) + superdoc: + specifier: latest + version: 1.19.1(@hocuspocus/provider@2.15.3(y-protocols@1.0.7(yjs@13.6.19))(yjs@13.6.19))(canvas@3.2.1)(pdfjs-dist@5.4.624)(typescript@5.9.3)(y-prosemirror@1.3.7(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.6)(y-protocols@1.0.7(yjs@13.6.19))(yjs@13.6.19))(yjs@13.6.19) + devDependencies: + '@types/react': + specifier: ^19.2.7 + version: 19.2.14 + '@types/react-dom': + specifier: ^19.2.3 + version: 19.2.3(@types/react@19.2.14) + '@vitejs/plugin-react': + specifier: ^5.1.2 + version: 5.1.3(rolldown-vite@7.3.1(@types/node@25.3.5)(esbuild@0.27.3)(jiti@2.6.1)(less@4.4.2)(sass@1.97.3)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.2)) + typescript: + specifier: ^5.9.3 + version: 5.9.3 + vite: + specifier: npm:rolldown-vite@7.3.1 + version: rolldown-vite@7.3.1(@types/node@25.3.5)(esbuild@0.27.3)(jiti@2.6.1)(less@4.4.2)(sass@1.97.3)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.2) + examples/features/track-changes: dependencies: react: @@ -16745,6 +16773,14 @@ packages: y-prosemirror: ^1.3.7 yjs: 13.6.19 + superdoc@1.19.1: + resolution: {integrity: sha512-RRX1TTzWS/7K7sXwLpscjPYjuos3FUZN2BsO5F0hj8BPP9He50cDEmv7zx2HsbmoX1Ugufe6lc3fp2gRRPTFxA==} + peerDependencies: + '@hocuspocus/provider': ^2.13.6 + pdfjs-dist: ^5.4.296 + y-prosemirror: ^1.3.7 + yjs: 13.6.19 + supports-color@10.2.2: resolution: {integrity: sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==} engines: {node: '>=18'} @@ -21005,10 +21041,6 @@ snapshots: dependencies: css-render: 0.15.14 - '@css-render/vue3-ssr@0.15.14(vue@3.5.25(typescript@5.5.4))': - dependencies: - vue: 3.5.25(typescript@5.5.4) - '@css-render/vue3-ssr@0.15.14(vue@3.5.25(typescript@5.9.3))': dependencies: vue: 3.5.25(typescript@5.9.3) @@ -25512,7 +25544,7 @@ snapshots: dependencies: react: 19.2.4 react-dom: 19.2.4(react@19.2.4) - superdoc: 1.18.2(@hocuspocus/provider@2.15.3(y-protocols@1.0.7(yjs@13.6.19))(yjs@13.6.19))(canvas@3.2.1)(pdfjs-dist@5.4.624)(typescript@5.5.4)(y-prosemirror@1.3.7(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.6)(y-protocols@1.0.7(yjs@13.6.19))(yjs@13.6.19))(yjs@13.6.19) + superdoc: 1.19.1(@hocuspocus/provider@2.15.3(y-protocols@1.0.7(yjs@13.6.19))(yjs@13.6.19))(canvas@3.2.1)(pdfjs-dist@5.4.624)(typescript@5.5.4)(y-prosemirror@1.3.7(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.6)(y-protocols@1.0.7(yjs@13.6.19))(yjs@13.6.19))(yjs@13.6.19) transitivePeerDependencies: - '@hocuspocus/provider' - '@vue/composition-api' @@ -25529,7 +25561,7 @@ snapshots: dependencies: react: 19.2.4 react-dom: 19.2.4(react@19.2.4) - superdoc: 1.18.2(@hocuspocus/provider@2.15.3(y-protocols@1.0.7(yjs@13.6.19))(yjs@13.6.19))(canvas@3.2.1)(pdfjs-dist@5.4.624)(typescript@5.9.3)(y-prosemirror@1.3.7(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.6)(y-protocols@1.0.7(yjs@13.6.19))(yjs@13.6.19))(yjs@13.6.19) + superdoc: 1.19.1(@hocuspocus/provider@2.15.3(y-protocols@1.0.7(yjs@13.6.19))(yjs@13.6.19))(canvas@3.2.1)(pdfjs-dist@5.4.624)(typescript@5.9.3)(y-prosemirror@1.3.7(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.6)(y-protocols@1.0.7(yjs@13.6.19))(yjs@13.6.19))(yjs@13.6.19) transitivePeerDependencies: - '@hocuspocus/provider' - '@vue/composition-api' @@ -33552,29 +33584,6 @@ snapshots: object-assign: 4.1.1 thenify-all: 1.6.0 - naive-ui@2.43.2(vue@3.5.25(typescript@5.5.4)): - dependencies: - '@css-render/plugin-bem': 0.15.14(css-render@0.15.14) - '@css-render/vue3-ssr': 0.15.14(vue@3.5.25(typescript@5.5.4)) - '@types/katex': 0.16.8 - '@types/lodash': 4.17.23 - '@types/lodash-es': 4.17.12 - async-validator: 4.2.5 - css-render: 0.15.14 - csstype: 3.2.3 - date-fns: 4.1.0 - date-fns-tz: 3.2.0(date-fns@4.1.0) - evtd: 0.2.4 - highlight.js: 11.11.1 - lodash: 4.17.23 - lodash-es: 4.17.23 - seemly: 0.3.10 - treemate: 0.3.11 - vdirs: 0.1.8(vue@3.5.25(typescript@5.5.4)) - vooks: 0.2.12(vue@3.5.25(typescript@5.5.4)) - vue: 3.5.25(typescript@5.5.4) - vueuc: 0.4.65(vue@3.5.25(typescript@5.5.4)) - naive-ui@2.43.2(vue@3.5.25(typescript@5.9.3)): dependencies: '@css-render/plugin-bem': 0.15.14(css-render@0.15.14) @@ -37608,14 +37617,37 @@ snapshots: - typescript - utf-8-validate - superdoc@1.18.2(@hocuspocus/provider@2.15.3(y-protocols@1.0.7(yjs@13.6.19))(yjs@13.6.19))(canvas@3.2.1)(pdfjs-dist@5.4.624)(typescript@5.5.4)(y-prosemirror@1.3.7(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.6)(y-protocols@1.0.7(yjs@13.6.19))(yjs@13.6.19))(yjs@13.6.19): + superdoc@1.18.2(@hocuspocus/provider@2.15.3(y-protocols@1.0.7(yjs@13.6.19))(yjs@13.6.19))(canvas@3.2.1)(pdfjs-dist@5.4.624)(typescript@5.9.3)(y-prosemirror@1.3.7(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.6)(y-protocols@1.0.7(yjs@13.6.19))(yjs@13.6.19))(yjs@13.6.19): + dependencies: + '@hocuspocus/provider': 2.15.3(y-protocols@1.0.7(yjs@13.6.19))(yjs@13.6.19) + buffer-crc32: 1.0.0 + eventemitter3: 5.0.4 + jsdom: 27.3.0(canvas@3.2.1) + konva: 10.2.0 + naive-ui: 2.43.2(vue@3.5.25(typescript@5.9.3)) + pdfjs-dist: 5.4.624 + pinia: 2.3.1(typescript@5.9.3)(vue@3.5.25(typescript@5.9.3)) + rollup-plugin-copy: 3.5.0 + uuid: 9.0.1 + vue: 3.5.25(typescript@5.9.3) + y-prosemirror: 1.3.7(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.6)(y-protocols@1.0.7(yjs@13.6.19))(yjs@13.6.19) + y-websocket: 3.0.0(yjs@13.6.19) + yjs: 13.6.19 + transitivePeerDependencies: + - '@vue/composition-api' + - bufferutil + - canvas + - supports-color + - typescript + - utf-8-validate + + superdoc@1.19.1(@hocuspocus/provider@2.15.3(y-protocols@1.0.7(yjs@13.6.19))(yjs@13.6.19))(canvas@3.2.1)(pdfjs-dist@5.4.624)(typescript@5.5.4)(y-prosemirror@1.3.7(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.6)(y-protocols@1.0.7(yjs@13.6.19))(yjs@13.6.19))(yjs@13.6.19): dependencies: '@hocuspocus/provider': 2.15.3(y-protocols@1.0.7(yjs@13.6.19))(yjs@13.6.19) buffer-crc32: 1.0.0 eventemitter3: 5.0.4 jsdom: 27.3.0(canvas@3.2.1) konva: 10.2.0 - naive-ui: 2.43.2(vue@3.5.25(typescript@5.5.4)) pdfjs-dist: 5.4.624 pinia: 2.3.1(typescript@5.5.4)(vue@3.5.25(typescript@5.5.4)) rollup-plugin-copy: 3.5.0 @@ -37632,14 +37664,13 @@ snapshots: - typescript - utf-8-validate - superdoc@1.18.2(@hocuspocus/provider@2.15.3(y-protocols@1.0.7(yjs@13.6.19))(yjs@13.6.19))(canvas@3.2.1)(pdfjs-dist@5.4.624)(typescript@5.9.3)(y-prosemirror@1.3.7(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.6)(y-protocols@1.0.7(yjs@13.6.19))(yjs@13.6.19))(yjs@13.6.19): + superdoc@1.19.1(@hocuspocus/provider@2.15.3(y-protocols@1.0.7(yjs@13.6.19))(yjs@13.6.19))(canvas@3.2.1)(pdfjs-dist@5.4.624)(typescript@5.9.3)(y-prosemirror@1.3.7(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.6)(y-protocols@1.0.7(yjs@13.6.19))(yjs@13.6.19))(yjs@13.6.19): dependencies: '@hocuspocus/provider': 2.15.3(y-protocols@1.0.7(yjs@13.6.19))(yjs@13.6.19) buffer-crc32: 1.0.0 eventemitter3: 5.0.4 jsdom: 27.3.0(canvas@3.2.1) konva: 10.2.0 - naive-ui: 2.43.2(vue@3.5.25(typescript@5.9.3)) pdfjs-dist: 5.4.624 pinia: 2.3.1(typescript@5.9.3)(vue@3.5.25(typescript@5.9.3)) rollup-plugin-copy: 3.5.0 @@ -38634,11 +38665,6 @@ snapshots: vary@1.1.2: {} - vdirs@0.1.8(vue@3.5.25(typescript@5.5.4)): - dependencies: - evtd: 0.2.4 - vue: 3.5.25(typescript@5.5.4) - vdirs@0.1.8(vue@3.5.25(typescript@5.9.3)): dependencies: evtd: 0.2.4 @@ -39075,11 +39101,6 @@ snapshots: vm-browserify@1.1.2: {} - vooks@0.2.12(vue@3.5.25(typescript@5.5.4)): - dependencies: - evtd: 0.2.4 - vue: 3.5.25(typescript@5.5.4) - vooks@0.2.12(vue@3.5.25(typescript@5.9.3)): dependencies: evtd: 0.2.4 @@ -39152,17 +39173,6 @@ snapshots: optionalDependencies: typescript: 5.9.3 - vueuc@0.4.65(vue@3.5.25(typescript@5.5.4)): - dependencies: - '@css-render/vue3-ssr': 0.15.14(vue@3.5.25(typescript@5.5.4)) - '@juggle/resize-observer': 3.4.0 - css-render: 0.15.14 - evtd: 0.2.4 - seemly: 0.3.10 - vdirs: 0.1.8(vue@3.5.25(typescript@5.5.4)) - vooks: 0.2.12(vue@3.5.25(typescript@5.5.4)) - vue: 3.5.25(typescript@5.5.4) - vueuc@0.4.65(vue@3.5.25(typescript@5.9.3)): dependencies: '@css-render/vue3-ssr': 0.15.14(vue@3.5.25(typescript@5.9.3)) From 0cf6f76fe3b789e7d3a95f1edeff242728bed0f7 Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Wed, 18 Mar 2026 17:10:48 -0300 Subject: [PATCH 7/9] fix(theme): make buildTheme() pure, document CSP nonce limitation - buildTheme() no longer injects styles into the DOM. It returns { className, css } without side effects, matching its SSR purpose. - createTheme() JSDoc notes that strict CSP environments should use buildTheme() + manual injection with a nonce attribute. - Added test verifying buildTheme does not inject styles. --- packages/superdoc/src/core/theme/create-theme.js | 10 ++++++---- packages/superdoc/src/core/theme/create-theme.test.js | 5 +++++ 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/packages/superdoc/src/core/theme/create-theme.js b/packages/superdoc/src/core/theme/create-theme.js index 0fc30fa9b2..0fa351a5af 100644 --- a/packages/superdoc/src/core/theme/create-theme.js +++ b/packages/superdoc/src/core/theme/create-theme.js @@ -111,6 +111,9 @@ function injectThemeStyle(className, css) { * Returns a CSS class name. Apply it to `` to activate the theme. * The style element is injected into the document automatically. * + * For strict CSP environments that require a nonce, use `buildTheme()` instead + * and inject the CSS yourself with the appropriate nonce attribute. + * * @param {ThemeConfig} config * @returns {string} The generated CSS class name * @@ -135,7 +138,8 @@ export function createTheme(config) { /** * Build a SuperDoc theme and return both the class name and raw CSS. - * Use this for SSR where you need to inject styles into the HTML template. + * Pure function — does NOT inject styles into the DOM. Use this for SSR + * or when you need to control style injection yourself (e.g., CSP nonce). * * @param {ThemeConfig} config * @returns {{ className: string, css: string }} @@ -152,7 +156,5 @@ export function createTheme(config) { * ``` */ export function buildTheme(config) { - const { className, css } = generateTheme(config); - injectThemeStyle(className, css); - return { className, css }; + return generateTheme(config); } diff --git a/packages/superdoc/src/core/theme/create-theme.test.js b/packages/superdoc/src/core/theme/create-theme.test.js index 3134b3a49f..cfaee1c627 100644 --- a/packages/superdoc/src/core/theme/create-theme.test.js +++ b/packages/superdoc/src/core/theme/create-theme.test.js @@ -152,6 +152,11 @@ describe('createTheme', () => { expect(result.css).toContain('--sd-ui-action: #f00'); }); + it('does not inject styles into the DOM', () => { + buildTheme({ name: 'no-inject', colors: { action: '#abc' } }); + expect(document.querySelector('[data-sd-theme="sd-theme-no-inject"]')).toBeNull(); + }); + it('wraps css in the class selector', () => { const { css } = buildTheme({ name: 'selector', colors: { bg: '#fff' } }); expect(css).toMatch(/^\.sd-theme-selector \{/); From 4b53a8df483177d15566a5fdd4b8df73de26b0ba Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Wed, 18 Mar 2026 17:21:00 -0300 Subject: [PATCH 8/9] fix(examples): fix Laravel and Nuxt smoke test failures - Laravel: bind artisan serve to 0.0.0.0 so Playwright can reach it in CI containers (was only listening on 127.0.0.1) - Nuxt: increase body visibility timeout to 10s and use domcontentloaded wait strategy (Nuxt hydration briefly hides body during init) --- examples/__tests__/smoke.spec.ts | 5 +++-- examples/getting-started/laravel/package.json | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/examples/__tests__/smoke.spec.ts b/examples/__tests__/smoke.spec.ts index 96019d142d..49393c7cba 100644 --- a/examples/__tests__/smoke.spec.ts +++ b/examples/__tests__/smoke.spec.ts @@ -16,8 +16,9 @@ test('example loads without errors', async ({ page }) => { // Block telemetry requests during tests await page.route('**/ingest.superdoc.dev/**', (route) => route.abort()); - await page.goto('/'); - await expect(page.locator('body')).toBeVisible(); + await page.goto('/', { waitUntil: 'domcontentloaded' }); + // Frameworks like Nuxt may briefly hide the body during hydration + await expect(page.locator('body')).toBeVisible({ timeout: 10_000 }); // Give the app a moment to initialize (SuperDoc is async) await page.waitForTimeout(2000); diff --git a/examples/getting-started/laravel/package.json b/examples/getting-started/laravel/package.json index 5ced1f78dc..d42caa20b7 100644 --- a/examples/getting-started/laravel/package.json +++ b/examples/getting-started/laravel/package.json @@ -4,7 +4,7 @@ "type": "module", "scripts": { "build": "vite build", - "dev": "concurrently \"php artisan serve\" \"vite\"" + "dev": "concurrently \"php artisan serve --host=0.0.0.0 --port=8000\" \"vite\"" }, "dependencies": { "concurrently": "^9.0.0", From dc8084cd4045b03feeb8a43c7358cb00273c4560 Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Wed, 18 Mar 2026 17:33:41 -0300 Subject: [PATCH 9/9] refactor(theme): convert createTheme to TypeScript Replace JSDoc types with native TypeScript interfaces (ThemeColors, ThemeConfig, ThemeResult). Exports typed interfaces for consumers. Also fix theming example height and restore smoke test assertion. --- examples/__tests__/smoke.spec.ts | 5 +- examples/features/theming/index.html | 1 + .../src/core/theme/create-theme.test.js | 2 +- .../{create-theme.js => create-theme.ts} | 98 ++++++++++--------- packages/superdoc/src/index.js | 2 +- 5 files changed, 55 insertions(+), 53 deletions(-) rename packages/superdoc/src/core/theme/{create-theme.js => create-theme.ts} (61%) diff --git a/examples/__tests__/smoke.spec.ts b/examples/__tests__/smoke.spec.ts index 49393c7cba..96019d142d 100644 --- a/examples/__tests__/smoke.spec.ts +++ b/examples/__tests__/smoke.spec.ts @@ -16,9 +16,8 @@ test('example loads without errors', async ({ page }) => { // Block telemetry requests during tests await page.route('**/ingest.superdoc.dev/**', (route) => route.abort()); - await page.goto('/', { waitUntil: 'domcontentloaded' }); - // Frameworks like Nuxt may briefly hide the body during hydration - await expect(page.locator('body')).toBeVisible({ timeout: 10_000 }); + await page.goto('/'); + await expect(page.locator('body')).toBeVisible(); // Give the app a moment to initialize (SuperDoc is async) await page.waitForTimeout(2000); diff --git a/examples/features/theming/index.html b/examples/features/theming/index.html index e8a68f7524..8f513da037 100644 --- a/examples/features/theming/index.html +++ b/examples/features/theming/index.html @@ -6,6 +6,7 @@ SuperDoc — Theming diff --git a/packages/superdoc/src/core/theme/create-theme.test.js b/packages/superdoc/src/core/theme/create-theme.test.js index cfaee1c627..73fe1d89a4 100644 --- a/packages/superdoc/src/core/theme/create-theme.test.js +++ b/packages/superdoc/src/core/theme/create-theme.test.js @@ -1,5 +1,5 @@ import { describe, expect, it, beforeEach } from 'vitest'; -import { createTheme, buildTheme } from './create-theme.js'; +import { createTheme, buildTheme } from './create-theme.ts'; describe('createTheme', () => { beforeEach(() => { diff --git a/packages/superdoc/src/core/theme/create-theme.js b/packages/superdoc/src/core/theme/create-theme.ts similarity index 61% rename from packages/superdoc/src/core/theme/create-theme.js rename to packages/superdoc/src/core/theme/create-theme.ts index 0fa351a5af..3a10ac1fe6 100644 --- a/packages/superdoc/src/core/theme/create-theme.js +++ b/packages/superdoc/src/core/theme/create-theme.ts @@ -1,34 +1,52 @@ -/** - * @typedef {Object} ThemeColors - * @property {string} [action] Action/accent color (buttons, links, active states). Default: #1355ff - * @property {string} [actionHover] Action hover state. Default: #0f44cc - * @property {string} [bg] Default background for panels, cards, dropdowns. Default: #ffffff - * @property {string} [hoverBg] Hover background. Default: #dbdbdb - * @property {string} [activeBg] Active/pressed background. Default: #c8d0d8 - * @property {string} [disabledBg] Disabled background. Default: #f5f5f5 - * @property {string} [text] Primary text color. Default: #47484a - * @property {string} [textMuted] Secondary/muted text. Default: #666666 - * @property {string} [textDisabled] Disabled text. Default: #ababab - * @property {string} [border] Default border color. Default: #dbdbdb - */ +export interface ThemeColors { + /** Action/accent color (buttons, links, active states). Default: #1355ff */ + action?: string; + /** Action hover state. Default: #0f44cc */ + actionHover?: string; + /** Default background for panels, cards, dropdowns. Default: #ffffff */ + bg?: string; + /** Hover background. Default: #dbdbdb */ + hoverBg?: string; + /** Active/pressed background. Default: #c8d0d8 */ + activeBg?: string; + /** Disabled background. Default: #f5f5f5 */ + disabledBg?: string; + /** Primary text color. Default: #47484a */ + text?: string; + /** Secondary/muted text. Default: #666666 */ + textMuted?: string; + /** Disabled text. Default: #ababab */ + textDisabled?: string; + /** Default border color. Default: #dbdbdb */ + border?: string; +} -/** - * @typedef {Object} ThemeConfig - * @property {string} [name] Theme name — used in the generated class name (e.g., "dark" → "sd-theme-dark") - * @property {string} [font] UI font family - * @property {string} [radius] Default border radius (e.g., "8px") - * @property {string} [shadow] Default box shadow - * @property {ThemeColors} [colors] Core color palette — cascades to every component - * @property {Record} [vars] Escape hatch — raw CSS variable overrides (e.g., { '--sd-ui-toolbar-bg': '#f8fafc' }) - */ +export interface ThemeConfig { + /** Theme name — used in the generated class name (e.g., "dark" → "sd-theme-dark") */ + name?: string; + /** UI font family */ + font?: string; + /** Default border radius (e.g., "8px") */ + radius?: string; + /** Default box shadow */ + shadow?: string; + /** Core color palette — cascades to every component */ + colors?: ThemeColors; + /** Escape hatch — raw CSS variable overrides (e.g., { '--sd-ui-toolbar-bg': '#f8fafc' }) */ + vars?: Record; +} + +export interface ThemeResult { + className: string; + css: string; +} /* * These map to the --sd-ui-* variable names introduced in the SD-2083 * theming system. Components consume them once that PR lands. Until then, * createTheme() generates the correct variables ahead of time. */ -/** @type {Record} */ -const COLORS_TO_VARS = { +const COLORS_TO_VARS: Record = { action: '--sd-ui-action', actionHover: '--sd-ui-action-hover', bg: '--sd-ui-bg', @@ -46,18 +64,13 @@ let themeCounter = 0; /** * Generate the className and CSS string from a theme config. * Shared core used by both createTheme and buildTheme. - * - * @param {ThemeConfig} config - * @returns {{ className: string, css: string }} */ -function generateTheme(config) { +function generateTheme(config: ThemeConfig): ThemeResult { const { name, font, radius, shadow, colors, vars } = config; const className = `sd-theme-${name || `custom-${++themeCounter}`}`; - /** @type {string[]} */ - const declarations = []; + const declarations: string[] = []; - // Map semantic colors if (colors) { for (const [key, value] of Object.entries(colors)) { if (value == null) continue; @@ -68,12 +81,10 @@ function generateTheme(config) { } } - // Map top-level shortcuts if (font != null) declarations.push(` --sd-ui-font-family: ${font};`); if (radius != null) declarations.push(` --sd-ui-radius: ${radius};`); if (shadow != null) declarations.push(` --sd-ui-shadow: ${shadow};`); - // Spread raw CSS variable overrides if (vars) { for (const [varName, value] of Object.entries(vars)) { if (value == null) continue; @@ -90,11 +101,8 @@ function generateTheme(config) { * Inject a theme's CSS into the document as a `...`; * ``` */ -export function buildTheme(config) { +export function buildTheme(config: ThemeConfig): ThemeResult { return generateTheme(config); } diff --git a/packages/superdoc/src/index.js b/packages/superdoc/src/index.js index 19bad9bd5d..1fa8493be2 100644 --- a/packages/superdoc/src/index.js +++ b/packages/superdoc/src/index.js @@ -17,7 +17,7 @@ import { getSchemaIntrospection } from './helpers/schema-introspection.js'; // Public exports export { SuperDoc } from './core/SuperDoc.js'; -export { createTheme, buildTheme } from './core/theme/create-theme.js'; +export { createTheme, buildTheme } from './core/theme/create-theme.ts'; export { BlankDOCX, getFileObject,