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/apps/docs/getting-started/theming.mdx b/apps/docs/getting-started/theming.mdx index 3a46855e8e..98500f1949 100644 --- a/apps/docs/getting-started/theming.mdx +++ b/apps/docs/getting-started/theming.mdx @@ -1,32 +1,77 @@ --- title: Theming sidebarTitle: Theming -keywords: "theming, css variables, custom theme, dark mode, design tokens, branding, css customization, styling" +keywords: "theming, css variables, custom theme, dark mode, design tokens, branding, css customization, createTheme, styling" --- -Change five CSS variables and your brand colors flow through every SuperDoc component — toolbar, comments, dropdowns, everything. No JavaScript needed. +Set your brand colors in JavaScript and every SuperDoc component updates — toolbar, comments, dropdowns, everything. -```css -.my-theme { - --sd-ui-action: #6366f1; - --sd-ui-action-hover: #4f46e5; - --sd-ui-toolbar-bg: #f8fafc; - --sd-ui-bg: #ffffff; - --sd-ui-text: #1e293b; -} +```javascript +import { createTheme } from 'superdoc'; + +const theme = createTheme({ + colors: { action: '#6366f1', bg: '#ffffff', text: '#1e293b', border: '#e2e8f0' }, + font: 'Inter, sans-serif', +}); + +document.documentElement.classList.add(theme); ``` -```html - -
- +Five properties theme the entire UI. + +## `createTheme()` + +Pass a config object, get back a CSS class name. Apply it to ``. + +```javascript +import { createTheme } from 'superdoc'; + +const theme = createTheme({ + name: 'my-brand', // optional — generates sd-theme-my-brand + font: 'Inter, sans-serif', + radius: '8px', + colors: { + action: '#6366f1', // buttons, links, active states + actionHover: '#4f46e5', + bg: '#ffffff', // panels, cards, dropdowns + text: '#1e293b', // primary text + textMuted: '#64748b', // secondary text + border: '#e2e8f0', // all borders + }, + // Escape hatch — any CSS variable + vars: { + '--sd-ui-toolbar-bg': '#f8fafc', + '--sd-ui-comments-card-bg': '#f0f0ff', + }, +}); + +document.documentElement.classList.add(theme); ``` -Override any `--sd-*` CSS custom property on the `` element and the changes cascade to all components. +`colors` controls the semantic tier — every component inherits from it. The `vars` escape hatch lets you set any `--sd-*` CSS variable directly for fine-grained control. + +## SSR support + +Use `buildTheme()` to get the raw CSS string for server-side rendering: + +```javascript +import { buildTheme } from 'superdoc'; + +const { className, css } = buildTheme({ + colors: { action: '#6366f1', bg: '#ffffff', text: '#1e293b' }, +}); + +const html = ` + + +
+ +`; +``` ## 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 { diff --git a/examples/features/theming/index.html b/examples/features/theming/index.html new file mode 100644 index 0000000000..8f513da037 --- /dev/null +++ b/examples/features/theming/index.html @@ -0,0 +1,17 @@ + + + + + + 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/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", 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` 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": { ".": { 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..73fe1d89a4 --- /dev/null +++ b/packages/superdoc/src/core/theme/create-theme.test.js @@ -0,0 +1,166 @@ +import { describe, expect, it, beforeEach } from 'vitest'; +import { createTheme, buildTheme } from './create-theme.ts'; + +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('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 \{/); + expect(css).toMatch(/\}$/); + }); + }); +}); diff --git a/packages/superdoc/src/core/theme/create-theme.ts b/packages/superdoc/src/core/theme/create-theme.ts new file mode 100644 index 0000000000..3a10ac1fe6 --- /dev/null +++ b/packages/superdoc/src/core/theme/create-theme.ts @@ -0,0 +1,162 @@ +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; +} + +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. + */ +const COLORS_TO_VARS: Record = { + 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; + +/** + * Generate the className and CSS string from a theme config. + * Shared core used by both createTheme and buildTheme. + */ +function generateTheme(config: ThemeConfig): ThemeResult { + const { name, font, radius, shadow, colors, vars } = config; + const className = `sd-theme-${name || `custom-${++themeCounter}`}`; + + const declarations: string[] = []; + + 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};`); + } + } + } + + 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};`); + + if (vars) { + for (const [varName, value] of Object.entries(vars)) { + if (value == null) continue; + declarations.push(` ${varName}: ${value};`); + } + } + + const css = declarations.length > 0 ? `.${className} {\n${declarations.join('\n')}\n}` : ''; + + return { className, css }; +} + +/** + * Inject a theme's CSS into the document as a `...`; + * ``` + */ +export function buildTheme(config: ThemeConfig): ThemeResult { + return generateTheme(config); +} diff --git a/packages/superdoc/src/index.js b/packages/superdoc/src/index.js index 34047a9504..1fa8493be2 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.ts'; export { BlankDOCX, getFileObject, 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))