diff --git a/CLAUDE.md b/CLAUDE.md index ed981697a1..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 @@ -193,12 +197,15 @@ Pixel-level before/after comparison for documents that failed layout comparison. ## Brand & Design System -Brand guidelines, voice, and design tokens live in `brand/`. Token values are defined in `packages/superdoc/src/assets/styles/tokens.css`. +Brand guidelines, voice, and design tokens live in `brand/`. +Token contract source is `packages/superdoc/src/assets/styles/helpers/variables.css` (`:root` defaults). +Preset theme overrides are defined in `packages/superdoc/src/assets/styles/helpers/themes.css`. **When creating or modifying UI components:** -- Use `--sd-*` CSS custom properties — never hardcode hex values. See `tokens.css` for all available variables. -- Tokens follow three tiers: primitive (`--sd-color-blue-500`) → semantic (`--sd-action-primary`) → component (`--sd-comment-bg`). Components reference semantic or component-level variables. -- Expose component-specific variables as `--sd-{component}-*` so consumers can customize via CSS. -- Document component CSS variables in `apps/docs/ui-components/` (Mintlify docs). +- Use `--sd-*` CSS custom properties — never hardcode hex values. +- Treat `variables.css` as the canonical token contract; add new tokens there. +- Keep preset themes in `themes.css` (`.sd-theme-*`) and override only the tokens that need theme-specific values. +- Tokens are organized by layers: primitive (`--sd-color-blue-500`) → UI/document tokens (`--sd-ui-*`, `--sd-comments-*`, etc.) → component usage. +- Expose UI component-specific variables as `--sd-ui-{component}-*` so consumers can customize via CSS. **When writing copy or content:** see `brand/brand-guidelines.md` for voice, tone, and the dual-register pattern (developer vs. leader). Product name is always **SuperDoc** (capital S, capital D). diff --git a/apps/docs/docs.json b/apps/docs/docs.json index c9e5cebb58..9de76d9c90 100644 --- a/apps/docs/docs.json +++ b/apps/docs/docs.json @@ -65,7 +65,8 @@ ] }, "getting-started/import-export", - "getting-started/fonts" + "getting-started/fonts", + "getting-started/theming" ] }, { @@ -238,6 +239,7 @@ "pages": [ "guides/general/storage", "guides/general/stable-navigation", + "guides/general/custom-themes", { "group": "Collaboration", "pages": [ @@ -251,7 +253,8 @@ "pages": [ "guides/migration/prosemirror", "guides/migration/breaking-changes-v1", - "guides/migration/typescript-migration" + "guides/migration/typescript-migration", + "guides/migration/css-variables" ] } ] diff --git a/apps/docs/getting-started/theming.mdx b/apps/docs/getting-started/theming.mdx new file mode 100644 index 0000000000..98500f1949 --- /dev/null +++ b/apps/docs/getting-started/theming.mdx @@ -0,0 +1,130 @@ +--- +title: Theming +sidebarTitle: Theming +keywords: "theming, css variables, custom theme, dark mode, design tokens, branding, css customization, createTheme, styling" +--- + +Set your brand colors in JavaScript and every SuperDoc component updates — toolbar, comments, dropdowns, everything. + +```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); +``` + +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); +``` + +`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 presets ship out of the box. Add the class to `` — some SuperDoc elements (popovers, dropdowns) are appended to ``, so they need to inherit from ``. + + + + Google Docs aesthetic. Clean blues, compact toolbar, subtle shadows. + ```html + + ``` + + + Microsoft Word aesthetic. Fluent-style borders and surfaces. + ```html + + ``` + + + High-contrast technical preset. Teal accents, structured layout. + ```html + + ``` + + + +## CSS variables (advanced) + +`createTheme()` generates CSS variables under the hood. You can also set them directly: + +```css +:root { + --sd-ui-action: #6366f1; + --sd-ui-bg: #ffffff; + --sd-ui-text: #1e293b; + --sd-ui-border: #e2e8f0; + --sd-ui-font-family: Inter, sans-serif; +} +``` + +See the [full variable reference](/guides/general/custom-themes#variable-reference) for every token. + +## Next steps + + + + Full API reference, dark theme starter, all CSS variables by component. + + + Old-to-new variable name mapping. Backward compatibility details. + + diff --git a/apps/docs/guides/general/custom-themes.mdx b/apps/docs/guides/general/custom-themes.mdx new file mode 100644 index 0000000000..ed091fad27 --- /dev/null +++ b/apps/docs/guides/general/custom-themes.mdx @@ -0,0 +1,331 @@ +--- +title: Custom themes +sidebarTitle: Custom themes +keywords: "custom theme, css variables reference, design tokens, theming guide, component styling, dark theme" +--- + +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', + actionText: '#ffffff', // text on action buttons + 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 `colors` only. Use `vars` when a specific component needs to look different. + + +## 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', + actionText: '#0f172a', + }, + 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 + +You can also write CSS directly. Apply overrides to ``. + +```css +.dark-theme { + --sd-ui-bg: #1a1a2e; + --sd-ui-hover-bg: #2a2a3e; + --sd-ui-active-bg: #3a3a4e; + --sd-ui-text: #e2e8f0; + --sd-ui-text-muted: #94a3b8; + --sd-ui-border: #334155; + --sd-ui-action: #60a5fa; + --sd-ui-action-hover: #93c5fd; + --sd-ui-action-text: #0f172a; + + /* Toolbar */ + --sd-ui-toolbar-bg: #0f172a; + --sd-ui-toolbar-button-text: #e2e8f0; + --sd-ui-toolbar-button-hover-bg: #1e293b; + --sd-ui-toolbar-button-active-bg: #334155; + + /* Dropdowns */ + --sd-ui-dropdown-bg: #1e293b; + --sd-ui-dropdown-border: #334155; + --sd-ui-dropdown-hover-bg: #2d3a4e; + + /* Comments */ + --sd-ui-comments-card-bg: #1e293b; + --sd-ui-comments-card-active-bg: #0f172a; + --sd-ui-comments-input-bg: #1e293b; + --sd-ui-comments-author-text: #e2e8f0; + --sd-ui-comments-body-text: #cbd5e1; + --sd-ui-comments-timestamp-text: #64748b; +} +``` + +## Variable reference + +### Semantic (UI-wide) + +These cascade to every component. Changing one updates the entire UI. + +| Variable | Default | Controls | +|----------|---------|----------| +| `--sd-ui-font-family` | `Arial, Helvetica, sans-serif` | UI font | +| `--sd-ui-text` | `#47484a` | Primary text | +| `--sd-ui-text-muted` | `#666666` | Secondary text | +| `--sd-ui-text-disabled` | `#ababab` | Disabled text | +| `--sd-ui-bg` | `#ffffff` | Default background | +| `--sd-ui-hover-bg` | `#dbdbdb` | Hover background | +| `--sd-ui-active-bg` | `#c8d0d8` | Active/pressed background | +| `--sd-ui-disabled-bg` | `#f5f5f5` | Disabled background | +| `--sd-ui-border` | `#dbdbdb` | Default border | +| `--sd-ui-action` | `#1355ff` | Action/accent color | +| `--sd-ui-action-hover` | `#0f44cc` | Action hover | +| `--sd-ui-action-text` | `#ffffff` | Text on action buttons | +| `--sd-ui-shadow` | `0 4px 12px rgba(0,0,0,0.12)` | Default shadow | +| `--sd-ui-radius` | `6px` | Default border radius | + +### Toolbar + +| Variable | Default | Controls | +|----------|---------|----------| +| `--sd-ui-toolbar-height` | `32px` | Row height | +| `--sd-ui-toolbar-padding-x` | `16px` | Horizontal padding | +| `--sd-ui-toolbar-padding-y` | `4px` | Vertical padding | +| `--sd-ui-toolbar-item-gap` | `2px` | Gap between buttons | +| `--sd-ui-toolbar-item-padding` | `5px` | Button padding | +| `--sd-ui-toolbar-bg` | `transparent` | Background | +| `--sd-ui-toolbar-button-text` | inherits `--sd-ui-text` | Button text/icon color | +| `--sd-ui-toolbar-button-hover-bg` | inherits `--sd-ui-hover-bg` | Button hover | +| `--sd-ui-toolbar-button-active-bg` | inherits `--sd-ui-active-bg` | Button active | + +### Dropdowns + +Toolbar dropdowns (font picker, style picker) and the overflow menu. + +| Variable | Default | Controls | +|----------|---------|----------| +| `--sd-ui-dropdown-bg` | inherits `--sd-ui-bg` | Panel background | +| `--sd-ui-dropdown-border` | inherits `--sd-ui-border` | Panel border | +| `--sd-ui-dropdown-text` | inherits `--sd-ui-text` | Option text | +| `--sd-ui-dropdown-hover-text` | inherits dropdown text | Hovered option text | +| `--sd-ui-dropdown-selected-text` | inherits dropdown text | Selected option text | +| `--sd-ui-dropdown-hover-bg` | inherits `--sd-ui-hover-bg` | Option hover | +| `--sd-ui-dropdown-active-bg` | inherits `--sd-ui-active-bg` | Option active | +| `--sd-ui-dropdown-option-radius` | `3px` | Option border radius | +| `--sd-ui-dropdown-shadow` | `0 8px 24px rgba(0,0,0,0.16)` | Panel shadow | + +### Context menu + +The right-click menu. + +| Variable | Default | Controls | +|----------|---------|----------| +| `--sd-ui-menu-bg` | inherits `--sd-ui-bg` | Background | +| `--sd-ui-menu-text` | inherits `--sd-ui-text` | Text | +| `--sd-ui-menu-font-size` | `12px` | Text size | +| `--sd-ui-menu-radius` | `0` | Border radius | +| `--sd-ui-menu-border` | inherits `--sd-ui-border` | Border | +| `--sd-ui-menu-shadow` | `0 0 0 1px rgba(0,0,0,0.05), 0 10px 20px rgba(0,0,0,0.1)` | Shadow | +| `--sd-ui-menu-input-border` | inherits `--sd-ui-border` | Search input border | +| `--sd-ui-menu-input-focus-border` | inherits `--sd-ui-action` | Search input focus | +| `--sd-ui-menu-item-hover-bg` | inherits `--sd-ui-hover-bg` | Item hover | +| `--sd-ui-menu-item-active-bg` | inherits `--sd-ui-active-bg` | Active item | +| `--sd-ui-menu-item-active-text` | inherits `--sd-ui-action` | Active item text | + +### Tooltip + +| Variable | Default | Controls | +|----------|---------|----------| +| `--sd-ui-tooltip-bg` | `#262626` | Background (intentionally dark) | +| `--sd-ui-tooltip-text` | `#ffffff` | Text (intentionally light) | +| `--sd-ui-tooltip-radius` | `6px` | Border radius | +| `--sd-ui-tooltip-shadow` | `0 3px 12px rgba(0,0,0,0.28)` | Shadow | + +### Comments + +Card styling, typography, and input elements for the comments sidebar. + +**Card:** + +| Variable | Default | Controls | +|----------|---------|----------| +| `--sd-ui-comments-card-bg` | `#f3f6fd` | Default card | +| `--sd-ui-comments-card-hover-bg` | inherits card bg | Hovered card | +| `--sd-ui-comments-card-active-bg` | inherits `--sd-ui-bg` | Selected card | +| `--sd-ui-comments-card-resolved-bg` | inherits `--sd-ui-disabled-bg` | Resolved card | +| `--sd-ui-comments-card-active-border` | inherits `--sd-ui-border` | Selected card border | +| `--sd-ui-comments-card-radius` | `12px` | Card radius | +| `--sd-ui-comments-card-padding` | `16px` | Card padding | +| `--sd-ui-comments-card-shadow` | `0 4px 12px rgba(50,50,50,0.15)` | Card shadow | +| `--sd-ui-comments-transition` | `all 200ms ease` | Animation | +| `--sd-ui-comments-separator` | inherits `--sd-ui-border` | Divider line | + +**Text:** + +| Variable | Default | Controls | +|----------|---------|----------| +| `--sd-ui-comments-author-text` | inherits `--sd-ui-text` | Author name | +| `--sd-ui-comments-author-size` | `14px` | Author size | +| `--sd-ui-comments-author-weight` | `600` | Author weight | +| `--sd-ui-comments-timestamp-text` | inherits `--sd-ui-text-muted` | Timestamp | +| `--sd-ui-comments-timestamp-size` | `12px` | Timestamp size | +| `--sd-ui-comments-body-text` | inherits `--sd-ui-text` | Body text | +| `--sd-ui-comments-body-size` | `14px` | Body size | + +**Input:** + +| Variable | Default | Controls | +|----------|---------|----------| +| `--sd-ui-comments-input-bg` | inherits `--sd-ui-bg` | Input background | +| `--sd-ui-comments-input-border` | inherits `--sd-ui-border` | Input border | + +**Status:** + +| Variable | Default | Controls | +|----------|---------|----------| +| `--sd-ui-comments-resolved-text` | `#00853d` | "Resolved" badge | +| `--sd-ui-comments-insert-text` | `#00853d` | Tracked change insert | +| `--sd-ui-comments-delete-text` | `#cb0e47` | Tracked change delete | + +**Filter dropdown:** + +| Variable | Default | Controls | +|----------|---------|----------| +| `--sd-ui-comments-dropdown-bg` | inherits `--sd-ui-bg` | Dropdown background | +| `--sd-ui-comments-dropdown-border` | inherits `--sd-ui-border` | Dropdown border | +| `--sd-ui-comments-dropdown-shadow` | `0 8px 24px rgba(0,0,0,0.12)` | Dropdown shadow | +| `--sd-ui-comments-option-text` | inherits body text | Option text | +| `--sd-ui-comments-option-hover-text` | inherits option text | Hovered option | +| `--sd-ui-comments-option-hover-bg` | inherits `--sd-ui-hover-bg` | Option hover | +| `--sd-ui-comments-option-size` | `14px` | Option size | + +**Visibility badges:** + +| Variable | Default | Controls | +|----------|---------|----------| +| `--sd-ui-comments-internal-bg` | `#cde6e6` | Internal badge | +| `--sd-ui-comments-external-bg` | `#f5cfda` | External badge | + +### Comment highlights (in-document) + +Colors applied to highlighted text when comments are present. These are document-level, not UI chrome. + +| Variable | Default | Controls | +|----------|---------|----------| +| `--sd-comments-highlight-external` | `#b1124b40` | External highlight | +| `--sd-comments-highlight-external-active` | `#b1124b66` | Active external | +| `--sd-comments-highlight-external-faded` | `#b1124b20` | Faded external | +| `--sd-comments-highlight-external-nested-border` | `#b1124b99` | Nested left border | +| `--sd-comments-highlight-internal` | `#07838340` | Internal highlight | +| `--sd-comments-highlight-internal-active` | `#07838366` | Active internal | +| `--sd-comments-highlight-internal-faded` | `#07838320` | Faded internal | +| `--sd-comments-highlight-internal-nested-border` | `#07838399` | Nested left border | +| `--sd-comments-highlight-hover` | `#1354ff55` | Hover highlight | +| `--sd-comments-selection-background` | `#1354ff55` | Selection highlight | + +### Tracked changes + +Insert, delete, and format change decorations in the document. + +| Variable | Default | Controls | +|----------|---------|----------| +| `--sd-tracked-changes-insert-background` | `#399c7222` | Insert highlight | +| `--sd-tracked-changes-insert-border` | `#00853d` | Insert left border | +| `--sd-tracked-changes-delete-background` | `#cb0e4722` | Delete highlight | +| `--sd-tracked-changes-delete-border` | `#cb0e47` | Delete left border | +| `--sd-tracked-changes-format-border` | `gold` | Format change border | +| `--sd-tracked-changes-insert-background-focused` | `#399c7244` | Focused insert | +| `--sd-tracked-changes-delete-background-focused` | `#cb0e4744` | Focused delete | +| `--sd-tracked-changes-format-background-focused` | `#ffd70033` | Focused format change | + +### Content controls + +DOCX content controls (SDTs) — form fields, dropdowns, date pickers. + +| Variable | Default | Controls | +|----------|---------|----------| +| `--sd-content-controls-block-border` | `#629be7` | Block control border | +| `--sd-content-controls-block-hover-bg` | inherits `--sd-ui-hover-bg` | Block hover | +| `--sd-content-controls-inline-border` | `#629be7` | Inline control border | +| `--sd-content-controls-inline-hover-bg` | inherits `--sd-ui-hover-bg` | Inline hover | +| `--sd-content-controls-label-border` | `#629be7` | Label border | +| `--sd-content-controls-label-bg` | `#629be7ee` | Label background | +| `--sd-content-controls-label-text` | `#ffffff` | Label text | +| `--sd-content-controls-lock-hover-bg` | `rgba(98,155,231,0.08)` | Locked control hover | + +### Layout + +Page appearance in presentation mode. + +| Variable | Default | Controls | +|----------|---------|----------| +| `--sd-layout-page-bg` | inherits `--sd-ui-bg` | Page background | +| `--sd-layout-page-shadow` | `0 4px 20px rgba(15,23,42,0.08)` | Page shadow | + +### Tools + +The floating action toolbar for AI and insert actions. + +| Variable | Default | Controls | +|----------|---------|----------| +| `--sd-ui-tools-gap` | `6px` | Gap between items | +| `--sd-ui-tools-item-size` | `50px` | Button size | +| `--sd-ui-tools-item-radius` | `12px` | Button radius | +| `--sd-ui-tools-item-bg` | `rgba(219,219,219,0.6)` | Button background | +| `--sd-ui-tools-icon-size` | `20px` | Icon size | + +## Source files + +Token definitions live in your `node_modules/superdoc/` package: + +- `src/assets/styles/helpers/variables.css` — defaults +- `src/assets/styles/helpers/themes.css` — preset themes +- `src/assets/styles/helpers/compat.css` — aliases for old names that map to a different concept diff --git a/apps/docs/guides/migration/css-variables.mdx b/apps/docs/guides/migration/css-variables.mdx new file mode 100644 index 0000000000..043bb52e7f --- /dev/null +++ b/apps/docs/guides/migration/css-variables.mdx @@ -0,0 +1,163 @@ +--- +title: CSS variable migration +sidebarTitle: CSS variables +keywords: "migration, css variables, renamed variables, breaking changes, backward compatibility, tokens" +--- + +SuperDoc's CSS variables were restructured into a three-tier token system. This page maps every old name to its new equivalent. + + +**Old names still work.** New tokens fall back to old names automatically. You don't need to change your code immediately. + + +## What changed + +The old system used flat names (`--sd-comment-bg`, `--sd-track-insert-border`). The new system uses a structured pattern: + +``` +--sd-{scope}-{component}-{element?}-{state?}-{property} + +Old: --sd-comment-bg +New: --sd-ui-comments-card-bg +``` + +The JavaScript config API is unchanged. Only CSS variable names moved. + +## Full mapping + +### Surfaces + +| Old | New | +|-----|-----| +| `--sd-surface-page` | `--sd-ui-bg` | +| `--sd-surface-canvas` | `--sd-color-gray-50` | +| `--sd-surface-card` | `--sd-ui-bg` | +| `--sd-surface-muted` | `--sd-ui-disabled-bg` | +| `--sd-surface-hover` | `--sd-ui-hover-bg` | +| `--sd-surface-selected` | `--sd-color-blue-100` | + +### Text + +| Old | New | +|-----|-----| +| `--sd-text-primary` | `--sd-ui-text` | +| `--sd-text-secondary` | `--sd-color-gray-700` | +| `--sd-text-muted` | `--sd-ui-text-muted` | +| `--sd-text-placeholder` | `--sd-ui-text-disabled` | + +### Borders + +| Old | New | +|-----|-----| +| `--sd-border-default` | `--sd-ui-border` | +| `--sd-border-subtle` | `--sd-ui-comments-separator` | +| `--sd-border-focus` | `--sd-ui-action` | + +### Actions + +| Old | New | +|-----|-----| +| `--sd-action-primary` | `--sd-ui-action` | +| `--sd-action-primary-hover` | `--sd-ui-action-hover` | + +### Typography and radius + +| Old | New | +|-----|-----| +| `--sd-font-family` | `--sd-ui-font-family` | +| `--sd-radius-sm` | `--sd-radius-50` | +| `--sd-radius-md` | `--sd-radius-200` | +| `--sd-radius-lg` | `--sd-radius-300` | + +### Comment card + +| Old | New | +|-----|-----| +| `--sd-comment-bg` | `--sd-ui-comments-card-bg` | +| `--sd-comment-bg-hover` | `--sd-ui-comments-card-hover-bg` | +| `--sd-comment-bg-active` | `--sd-ui-comments-card-active-bg` | +| `--sd-comment-bg-resolved` | `--sd-ui-comments-card-resolved-bg` | +| `--sd-comment-border-active` | `--sd-ui-comments-card-active-border` | +| `--sd-comment-radius` | `--sd-ui-comments-card-radius` | +| `--sd-comment-padding` | `--sd-ui-comments-card-padding` | +| `--sd-comment-shadow` | `--sd-ui-comments-card-shadow` | +| `--sd-comment-separator` | `--sd-ui-comments-separator` | +| `--sd-comment-transition` | `--sd-ui-comments-transition` | + +### Comment text + +| Old | New | +|-----|-----| +| `--sd-comment-author-color` | `--sd-ui-comments-author-text` | +| `--sd-comment-author-size` | `--sd-ui-comments-author-size` | +| `--sd-comment-author-weight` | `--sd-ui-comments-author-weight` | +| `--sd-comment-time-color` | `--sd-ui-comments-timestamp-text` | +| `--sd-comment-time-size` | `--sd-ui-comments-timestamp-size` | +| `--sd-comment-body-size` | `--sd-ui-comments-body-size` | +| `--sd-comment-tc-insert-color` | `--sd-ui-comments-insert-text` | +| `--sd-comment-tc-delete-color` | `--sd-ui-comments-delete-text` | + +### Comment highlights + +| Old | New | +|-----|-----| +| `--sd-comment-highlight-internal` | `--sd-comments-highlight-internal` | +| `--sd-comment-highlight-external` | `--sd-comments-highlight-external` | +| `--sd-comment-internal-bg` | `--sd-ui-comments-internal-bg` | +| `--sd-comment-external-bg` | `--sd-ui-comments-external-bg` | + + +`--sd-comment-highlight-opacity` and `--sd-comment-highlight-opacity-active` were removed. The new system uses pre-computed RGBA colors (`--sd-comments-highlight-external: #b1124b40`). Override the full value directly. + + +### Tracked changes + +| Old | New | +|-----|-----| +| `--sd-track-insert-border` | `--sd-tracked-changes-insert-border` | +| `--sd-track-insert-bg` | `--sd-tracked-changes-insert-background` | +| `--sd-track-delete-border` | `--sd-tracked-changes-delete-border` | +| `--sd-track-delete-bg` | `--sd-tracked-changes-delete-background` | +| `--sd-track-format-border` | `--sd-tracked-changes-format-border` | + +### Removed + +These have no replacement: + +| Variable | Why | +|----------|-----| +| `--sd-comment-highlight-opacity` | Replaced by pre-computed RGBA values | +| `--sd-comment-highlight-opacity-active` | Replaced by pre-computed RGBA values | +| `--sd-comment-max-width` | Managed internally | +| `--sd-comment-min-width` | Managed internally | +| `--sd-comment-group-bg` | Group bubble styling removed | +| `--sd-comment-group-color` | Group bubble styling removed | +| `--sd-comment-group-size` | Group bubble styling removed | +| `--sd-comment-group-expanded-size` | Group bubble styling removed | +| `--sd-font-mono` | No longer exposed | + +## How backward compatibility works + +New tokens in `variables.css` fall back to old names via `var()`: + +```css +/* Inside variables.css */ +:root { + --sd-ui-comments-card-bg: var(--sd-comment-bg, #f3f6fd); + --sd-tracked-changes-insert-border: var(--sd-track-insert-border, #00853d); +} +``` + +CSS resolves `var()` at computed-value time. If you set `--sd-comment-bg` on any element, the new token picks it up in that scope: + +```css +.my-overrides { + --sd-comment-bg: #f0f0f0; /* works — --sd-ui-comments-card-bg resolves to #f0f0f0 */ +} +``` + +Setting the new name directly works too — it overrides the whole fallback expression. + + +Old-name fallbacks will be removed in the next major version. Migrate to the new names when convenient. + diff --git a/brand/README.md b/brand/README.md index dae815d749..3a4ed2c80d 100644 --- a/brand/README.md +++ b/brand/README.md @@ -14,17 +14,19 @@ brand/ ## Design tokens -Token values live in `packages/superdoc/src/assets/styles/tokens.css` as CSS custom properties (`--sd-*`). That file is the single source of truth. +Token defaults live in `packages/superdoc/src/assets/styles/helpers/variables.css` as CSS custom properties (`--sd-*`). +Preset theme overrides live in `packages/superdoc/src/assets/styles/helpers/themes.css`. +Together, these files are the design-token source of truth. -Tokens follow three tiers: -- **Primitive** (`--sd-color-blue-500`) — raw palette values -- **Semantic** (`--sd-action-primary`, `--sd-surface-card`) — UI roles that reference primitives -- **Component** (`--sd-comment-bg`) — component-specific overrides that reference semantic tokens +Tokens are organized by layers: +- **Primitive** (`--sd-color-blue-500`, `--sd-font-size-400`, `--sd-radius-100`) — raw design values +- **UI/Document semantic** (`--sd-ui-*`, `--sd-comments-*`, `--sd-tracked-changes-*`, `--sd-layout-*`) — role-based tokens used by components and rendering layers +- **Component-level (optional)** (`--sd-ui-{component}-*`) — local overrides for a specific UI component when cross-component tokens are not enough -Consumers customize SuperDoc by overriding `--sd-*` variables in their own CSS. Component customization is documented at `apps/docs/ui-components/`. +Consumers customize SuperDoc by overriding `--sd-*` variables in their own CSS. ## How to use -**For development**: Use semantic or component tokens in CSS — never hardcode hex values. When adding a new UI component, expose its visual properties as `--sd-{component}-*` variables in `tokens.css`. +**For development**: Use semantic or component tokens in CSS — never hardcode hex values. When adding a new UI component, expose its visual properties as `--sd-ui-{component}-*` variables in `variables.css`; add per-theme overrides in `themes.css` only when needed. **For marketing/content**: See `brand-guidelines.md` for voice, tone, and the dual-register pattern (developer vs. leader). diff --git a/brand/visual-identity.md b/brand/visual-identity.md index 85acb3cd5e..20f2b37127 100644 --- a/brand/visual-identity.md +++ b/brand/visual-identity.md @@ -12,7 +12,7 @@ - **Don't place blue text on blue backgrounds** — maintain contrast ### Color scale -See `packages/superdoc/src/assets/styles/tokens.css` for the full blue scale (`--sd-color-blue-50` through `--sd-color-blue-900`). The scale moves from near-white (#EBF0FF) to near-black (#041133), with 500 being the canonical brand color. +See `packages/superdoc/src/assets/styles/helpers/variables.css` for the full blue scale (`--sd-color-blue-50` through `--sd-color-blue-900`). The scale moves from near-white (#EBF0FF) to near-black (#041133), with 500 being the canonical brand color. ## Logo @@ -82,7 +82,7 @@ No heavy shadows, no gradients on UI elements (gradients are reserved for market Dark mode is currently used on the homepage/marketing site. The product UI is light-only. -Dark mode token overrides are documented in `packages/superdoc/src/assets/styles/tokens.css`. +Theme overrides are defined in `packages/superdoc/src/assets/styles/helpers/themes.css`, while base token defaults are in `packages/superdoc/src/assets/styles/helpers/variables.css`. Key principle: dark backgrounds (#0B0C10) with reduced-brightness text (#E8E8E8), not pure white on pure black. 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/layout-engine/painters/dom/src/css-token.test.ts b/packages/layout-engine/painters/dom/src/css-token.test.ts new file mode 100644 index 0000000000..4107087363 --- /dev/null +++ b/packages/layout-engine/painters/dom/src/css-token.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from 'vitest'; +import { cssToken } from './css-token.js'; + +describe('cssToken', () => { + it('builds a var() string with the fallback embedded', () => { + const token = cssToken('--sd-ui-bg', '#ffffff'); + expect(token.css).toBe('var(--sd-ui-bg, #ffffff)'); + }); + + it('stores the fallback separately for jsdom environments', () => { + const token = cssToken('--sd-ui-bg', '#ffffff'); + expect(token.fallback).toBe('#ffffff'); + }); + + it('keeps css and fallback in sync', () => { + const token = cssToken('--sd-comments-highlight-external', '#B1124B40'); + expect(token.css).toContain(token.fallback); + }); + + it('works with rgba values', () => { + const token = cssToken('--sd-ui-tools-item-bg', 'rgba(219, 219, 219, 0.6)'); + expect(token.css).toBe('var(--sd-ui-tools-item-bg, rgba(219, 219, 219, 0.6))'); + expect(token.fallback).toBe('rgba(219, 219, 219, 0.6)'); + }); +}); diff --git a/packages/layout-engine/painters/dom/src/css-token.ts b/packages/layout-engine/painters/dom/src/css-token.ts new file mode 100644 index 0000000000..5d15e3ba4a --- /dev/null +++ b/packages/layout-engine/painters/dom/src/css-token.ts @@ -0,0 +1,14 @@ +export type CssToken = { + css: string; + fallback: string; +}; + +/** + * Creates a CSS variable token with a fallback value. + * The `css` string is used in real browsers (supports var()). + * The `fallback` is used in environments that don't (e.g., jsdom in tests). + */ +export const cssToken = (varName: string, fallback: string): CssToken => ({ + css: `var(${varName}, ${fallback})`, + fallback, +}); diff --git a/packages/layout-engine/painters/dom/src/renderer.ts b/packages/layout-engine/painters/dom/src/renderer.ts index 16ae2cea51..a589982cd7 100644 --- a/packages/layout-engine/painters/dom/src/renderer.ts +++ b/packages/layout-engine/painters/dom/src/renderer.ts @@ -591,11 +591,25 @@ const LIST_MARKER_GAP = 8; const DEFAULT_PAGE_HEIGHT_PX = 1056; /** Default gap used when virtualization is enabled (kept in sync with PresentationEditor layout defaults). */ const DEFAULT_VIRTUALIZED_PAGE_GAP = 72; -const COMMENT_EXTERNAL_COLOR = '#B1124B'; -const COMMENT_INTERNAL_COLOR = '#078383'; -const COMMENT_INACTIVE_ALPHA = '40'; // ~25% for inactive -const COMMENT_ACTIVE_ALPHA = '66'; // ~40% for active/selected -const COMMENT_FADED_ALPHA = '20'; // ~12% for non-selected when another comment is active +import { cssToken } from './css-token.js'; +import type { CssToken } from './css-token.js'; + +type CommentHighlightToken = CssToken; + +const COMMENT_HIGHLIGHT_EXTERNAL = cssToken('--sd-comments-highlight-external', '#B1124B40'); +const COMMENT_HIGHLIGHT_EXTERNAL_ACTIVE = cssToken('--sd-comments-highlight-external-active', '#B1124B66'); +const COMMENT_HIGHLIGHT_EXTERNAL_FADED = cssToken('--sd-comments-highlight-external-faded', '#B1124B20'); +const COMMENT_HIGHLIGHT_INTERNAL = cssToken('--sd-comments-highlight-internal', '#07838340'); +const COMMENT_HIGHLIGHT_INTERNAL_ACTIVE = cssToken('--sd-comments-highlight-internal-active', '#07838366'); +const COMMENT_HIGHLIGHT_INTERNAL_FADED = cssToken('--sd-comments-highlight-internal-faded', '#07838320'); +const COMMENT_HIGHLIGHT_EXTERNAL_NESTED_BORDER = cssToken( + '--sd-comments-highlight-external-nested-border', + '#B1124B99', +); +const COMMENT_HIGHLIGHT_INTERNAL_NESTED_BORDER = cssToken( + '--sd-comments-highlight-internal-nested-border', + '#07838399', +); type LinkRenderData = { href?: string; @@ -2595,7 +2609,7 @@ export class DomPainter { const base = this.options.pageStyles ?? {}; return { ...base, - background: base.background ?? '#fff', + background: base.background ?? 'var(--sd-layout-page-bg, #fff)', boxShadow: 'none', border: 'none', margin: '0', @@ -4605,14 +4619,23 @@ export class DomPainter { const commentHighlight = getCommentHighlight(textRun, this.activeCommentId); if (commentHighlight.color && hasAnyComment) { - (elem as HTMLElement).style.backgroundColor = commentHighlight.color; - // Add thin visual indicator for nested comments when outer comment is selected - // Use box-shadow instead of border to avoid affecting text layout - if (commentHighlight.hasNestedComments && commentHighlight.baseColor) { - const borderColor = `${commentHighlight.baseColor}99`; // Semi-transparent for subtlety - (elem as HTMLElement).style.boxShadow = `inset 1px 0 0 ${borderColor}, inset -1px 0 0 ${borderColor}`; + const runElement = elem as HTMLElement; + const previousBackgroundColor = runElement.style.backgroundColor; + runElement.style.backgroundColor = commentHighlight.color.css; + // jsdom may drop var() values for inline style properties. + // Fall back to concrete color to keep rendering/tests stable. + if (!runElement.style.backgroundColor || runElement.style.backgroundColor === previousBackgroundColor) { + runElement.style.backgroundColor = commentHighlight.color.fallback; + } + // Add thin visual indicator for nested comments when outer comment is selected. + // Use box-shadow instead of border to avoid affecting text layout. + if (commentHighlight.hasNestedComments && commentHighlight.nestedBorderColor) { + runElement.style.boxShadow = `inset 1px 0 0 ${commentHighlight.nestedBorderColor.css}, inset -1px 0 0 ${commentHighlight.nestedBorderColor.css}`; + if (!runElement.style.boxShadow) { + runElement.style.boxShadow = `inset 1px 0 0 ${commentHighlight.nestedBorderColor.fallback}, inset -1px 0 0 ${commentHighlight.nestedBorderColor.fallback}`; + } } else { - (elem as HTMLElement).style.boxShadow = ''; + runElement.style.boxShadow = ''; } } // We still need to preserve the comment ids @@ -6958,8 +6981,8 @@ const applyRunStyles = (element: HTMLElement, run: Run, _isLink = false): void = }; interface CommentHighlightResult { - color?: string; - baseColor?: string; + color?: CommentHighlightToken; + nestedBorderColor?: CommentHighlightToken; hasNestedComments?: boolean; } @@ -7000,27 +7023,25 @@ const getCommentHighlight = (run: TextRun, activeCommentId: string | null): Comm matchesId(c as { commentId: string; importedId?: string }, activeCommentId), ); if (activeComment) { - const base = activeComment.internal ? COMMENT_INTERNAL_COLOR : COMMENT_EXTERNAL_COLOR; - // Check if there are OTHER comments besides the active one (nested comments) const nestedComments = comments.filter( (c) => !matchesId(c as { commentId: string; importedId?: string }, activeCommentId), ); return { - color: `${base}${COMMENT_ACTIVE_ALPHA}`, - baseColor: base, + color: activeComment.internal ? COMMENT_HIGHLIGHT_INTERNAL_ACTIVE : COMMENT_HIGHLIGHT_EXTERNAL_ACTIVE, + nestedBorderColor: activeComment.internal + ? COMMENT_HIGHLIGHT_INTERNAL_NESTED_BORDER + : COMMENT_HIGHLIGHT_EXTERNAL_NESTED_BORDER, hasNestedComments: nestedComments.length > 0, }; } // Active comment is set but this run does not belong to it - show faded highlight. const fadedPrimary = comments[0]; - const fadedBase = fadedPrimary.internal ? COMMENT_INTERNAL_COLOR : COMMENT_EXTERNAL_COLOR; - return { color: `${fadedBase}${COMMENT_FADED_ALPHA}` }; + return { color: fadedPrimary.internal ? COMMENT_HIGHLIGHT_INTERNAL_FADED : COMMENT_HIGHLIGHT_EXTERNAL_FADED }; } // No active comment - show uniform light highlight (like Word/Google Docs) const primary = comments[0]; - const base = primary.internal ? COMMENT_INTERNAL_COLOR : COMMENT_EXTERNAL_COLOR; - return { color: `${base}${COMMENT_INACTIVE_ALPHA}` }; + return { color: primary.internal ? COMMENT_HIGHLIGHT_INTERNAL : COMMENT_HIGHLIGHT_EXTERNAL }; }; /** diff --git a/packages/layout-engine/painters/dom/src/styles.ts b/packages/layout-engine/painters/dom/src/styles.ts index 3f9e39ba0d..05ad697d52 100644 --- a/packages/layout-engine/painters/dom/src/styles.ts +++ b/packages/layout-engine/painters/dom/src/styles.ts @@ -18,8 +18,8 @@ export type PageStyles = { }; export const DEFAULT_PAGE_STYLES: Required = { - background: '#fff', - boxShadow: '0 4px 20px rgba(15, 23, 42, 0.08)', + background: 'var(--sd-layout-page-bg, #fff)', + boxShadow: 'var(--sd-layout-page-shadow, 0 4px 20px rgba(15, 23, 42, 0.08))', border: '1px solid rgba(15, 23, 42, 0.08)', margin: '0 auto', }; @@ -233,38 +233,38 @@ const TRACK_CHANGE_STYLES = ` } .superdoc-layout .track-insert-dec.highlighted { - border-top: 1px dashed #00853d; - border-bottom: 1px dashed #00853d; - background-color: #399c7222; + border-top: 1px dashed var(--sd-tracked-changes-insert-border, #00853d); + border-bottom: 1px dashed var(--sd-tracked-changes-insert-border, #00853d); + background-color: var(--sd-tracked-changes-insert-background, #399c7222); } .superdoc-layout .track-delete-dec.highlighted { - border-top: 1px dashed #cb0e47; - border-bottom: 1px dashed #cb0e47; - background-color: #cb0e4722; + border-top: 1px dashed var(--sd-tracked-changes-delete-border, #cb0e47); + border-bottom: 1px dashed var(--sd-tracked-changes-delete-border, #cb0e47); + background-color: var(--sd-tracked-changes-delete-background, #cb0e4722); text-decoration: line-through !important; text-decoration-thickness: 2px !important; } .superdoc-layout .track-format-dec.highlighted { - border-bottom: 2px solid gold; + border-bottom: 2px solid var(--sd-tracked-changes-format-border, gold); } .superdoc-layout .track-insert-dec.highlighted.track-change-focused { border-style: solid; border-width: 2px; - background-color: #399c7244; + background-color: var(--sd-tracked-changes-insert-background-focused, #399c7244); } .superdoc-layout .track-delete-dec.highlighted.track-change-focused { border-style: solid; border-width: 2px; - background-color: #cb0e4744; + background-color: var(--sd-tracked-changes-delete-background-focused, #cb0e4744); } .superdoc-layout .track-format-dec.highlighted.track-change-focused { border-bottom-width: 3px; - background-color: #ffd70033; + background-color: var(--sd-tracked-changes-format-background-focused, #ffd70033); } `; @@ -380,19 +380,19 @@ const SDT_CONTAINER_STYLES = ` } .superdoc-structured-content-block:not(.ProseMirror-selectednode):hover { - background-color: #f2f2f2; + background-color: var(--sd-content-controls-block-hover-bg, #f2f2f2); border-color: transparent; } /* Group hover (JavaScript-coordinated) */ .superdoc-structured-content-block.sdt-group-hover:not(.ProseMirror-selectednode), .superdoc-structured-content-block.sdt-hover:not(.ProseMirror-selectednode) { - background-color: #f2f2f2; + background-color: var(--sd-content-controls-block-hover-bg, #f2f2f2); border-color: transparent; } .superdoc-structured-content-block.ProseMirror-selectednode { - border-color: #629be7; + border-color: var(--sd-content-controls-block-border, #629be7); outline: none; } @@ -409,10 +409,11 @@ const SDT_CONTAINER_STYLES = ` min-width: 0; height: 18px; padding: 0 4px; - border: 1px solid #629be7; + border: 1px solid var(--sd-content-controls-label-border, #629be7); border-bottom: none; border-radius: 6px 6px 0 0; - background-color: #629be7ee; + background-color: var(--sd-content-controls-label-bg, #629be7ee); + color: var(--sd-content-controls-label-text, #ffffff); box-sizing: border-box; z-index: 10; display: none; @@ -478,12 +479,12 @@ const SDT_CONTAINER_STYLES = ` /* Hover effect for inline structured content */ .superdoc-structured-content-inline:not(.ProseMirror-selectednode):hover { - background-color: #f2f2f2; + background-color: var(--sd-content-controls-inline-hover-bg, #f2f2f2); border-color: transparent; } .superdoc-structured-content-inline.ProseMirror-selectednode { - border-color: #629be7; + border-color: var(--sd-content-controls-inline-border, #629be7); outline: none; background-color: transparent; } @@ -495,8 +496,9 @@ const SDT_CONTAINER_STYLES = ` transform: translateX(-50%); font-size: 11px; padding: 0 4px; - background-color: #629be7ee; - color: white; + border: 1px solid var(--sd-content-controls-label-border, #629be7); + background-color: var(--sd-content-controls-label-bg, #629be7ee); + color: var(--sd-content-controls-label-text, #ffffff); border-radius: 4px; white-space: nowrap; z-index: 100; @@ -521,7 +523,7 @@ const SDT_CONTAINER_STYLES = ` * Hover is suppressed when the node is selected (SD-1584). */ .superdoc-structured-content-block[data-lock-mode].sdt-hover:not(.ProseMirror-selectednode), .superdoc-structured-content-inline[data-lock-mode]:hover:not(.ProseMirror-selectednode) { - background-color: rgba(98, 155, 231, 0.08); + background-color: var(--sd-content-controls-lock-hover-bg, rgba(98, 155, 231, 0.08)); z-index: 9999999; } diff --git a/packages/super-editor/src/assets/styles/elements/prosemirror.css b/packages/super-editor/src/assets/styles/elements/prosemirror.css index 9dfa3863d8..8adf349020 100644 --- a/packages/super-editor/src/assets/styles/elements/prosemirror.css +++ b/packages/super-editor/src/assets/styles/elements/prosemirror.css @@ -255,21 +255,21 @@ https://github.com/ProseMirror/prosemirror-tables/blob/master/demo/index.html } .sd-editor-scoped .ProseMirror .track-insert-dec.highlighted { - border-top: 1px dashed var(--sd-track-insert-border, #00853d); - border-bottom: 1px dashed var(--sd-track-insert-border, #00853d); - background-color: var(--sd-track-insert-bg, #399c7222); + border-top: 1px dashed var(--sd-tracked-changes-insert-border, #00853d); + border-bottom: 1px dashed var(--sd-tracked-changes-insert-border, #00853d); + background-color: var(--sd-tracked-changes-insert-background, #399c7222); } .sd-editor-scoped .ProseMirror .track-delete-dec.highlighted { - border-top: 1px dashed var(--sd-track-delete-border, #cb0e47); - border-bottom: 1px dashed var(--sd-track-delete-border, #cb0e47); - background-color: var(--sd-track-delete-bg, #cb0e4722); + border-top: 1px dashed var(--sd-tracked-changes-delete-border, #cb0e47); + border-bottom: 1px dashed var(--sd-tracked-changes-delete-border, #cb0e47); + background-color: var(--sd-tracked-changes-delete-background, #cb0e4722); text-decoration: line-through !important; text-decoration-thickness: 2px !important; } .sd-editor-scoped .ProseMirror .track-format-dec.highlighted { - border-bottom: 2px solid var(--sd-track-format-border, gold); + border-bottom: 2px solid var(--sd-tracked-changes-format-border, gold); } .sd-editor-scoped .ProseMirror .track-delete-widget { diff --git a/packages/super-editor/src/assets/styles/extensions/comments.css b/packages/super-editor/src/assets/styles/extensions/comments.css index 9b4ab407e0..3c75c319a0 100644 --- a/packages/super-editor/src/assets/styles/extensions/comments.css +++ b/packages/super-editor/src/assets/styles/extensions/comments.css @@ -3,7 +3,7 @@ } .sd-editor-comment-highlight:hover { - background-color: var(--sd-comment-highlight-hover, #1354ff55); + background-color: var(--sd-comments-highlight-hover, #1354ff55); } .sd-editor-comment-highlight.sd-custom-selection { diff --git a/packages/super-editor/src/assets/styles/extensions/structured-content.css b/packages/super-editor/src/assets/styles/extensions/structured-content.css index 8d80d5d434..387e70a71b 100644 --- a/packages/super-editor/src/assets/styles/extensions/structured-content.css +++ b/packages/super-editor/src/assets/styles/extensions/structured-content.css @@ -13,15 +13,21 @@ } /* Hover (not selected): light grey background, no handle */ -.super-editor .sd-structured-content:not(.ProseMirror-selectednode):hover, +.super-editor .sd-structured-content:not(.ProseMirror-selectednode):hover { + background-color: var(--sd-content-controls-inline-hover-bg, #f2f2f2); +} + .super-editor .sd-structured-content-block:not(.ProseMirror-selectednode):hover { - background-color: #f2f2f2; + background-color: var(--sd-content-controls-block-hover-bg, #f2f2f2); } /* Selected: border + handle visible */ -.super-editor .sd-structured-content.ProseMirror-selectednode, +.super-editor .sd-structured-content.ProseMirror-selectednode { + border-color: var(--sd-content-controls-inline-border, #629be7); +} + .super-editor .sd-structured-content-block.ProseMirror-selectednode { - border-color: #629be7; + border-color: var(--sd-content-controls-block-border, #629be7); } .super-editor .sd-structured-content-draggable { @@ -36,10 +42,11 @@ min-width: 0; height: 18px; padding: 0 4px; - border: 1px solid #629be7; + border: 1px solid var(--sd-content-controls-label-border, #629be7); border-bottom: none; border-radius: 6px 6px 0 0; - background-color: #629be7dd; + background-color: var(--sd-content-controls-label-bg, #629be7ee); + color: var(--sd-content-controls-label-text, #ffffff); box-sizing: border-box; z-index: 10; cursor: grab; diff --git a/packages/super-editor/src/components/context-menu/ContextMenu.vue b/packages/super-editor/src/components/context-menu/ContextMenu.vue index 4147d044b4..53dd19da12 100644 --- a/packages/super-editor/src/components/context-menu/ContextMenu.vue +++ b/packages/super-editor/src/components/context-menu/ContextMenu.vue @@ -527,13 +527,13 @@ onBeforeUnmount(() => { position: fixed; z-index: 50; width: 180px; - color: #47484a; - background: white; - box-shadow: - 0 0 0 1px rgba(0, 0, 0, 0.05), - 0px 10px 20px rgba(0, 0, 0, 0.1); + color: var(--sd-ui-menu-text, #47484a); + background: var(--sd-ui-menu-bg, #ffffff); + border-radius: var(--sd-ui-menu-radius, 0); + overflow: hidden; + box-shadow: var(--sd-ui-menu-shadow, 0 0 0 1px rgba(0, 0, 0, 0.05), 0px 10px 20px rgba(0, 0, 0, 0.1)); margin-top: 0.5rem; - font-size: 12px; + font-size: var(--sd-ui-menu-font-size, 12px); } /* Hide the input but keep it functional */ @@ -555,18 +555,18 @@ onBeforeUnmount(() => { .context-menu-search { padding: 0.5rem; - border-bottom: 1px solid #eee; + border-bottom: 1px solid var(--sd-ui-menu-border, #eee); } .context-menu-search input { width: 100%; padding: 0.25rem 0.5rem; - border: 1px solid #ddd; + border: 1px solid var(--sd-ui-menu-input-border, #ddd); outline: none; } .context-menu-search input:focus { - border-color: #0096fd; + border-color: var(--sd-ui-menu-input-focus-border, #0096fd); } /* Remove unused group styles */ @@ -584,13 +584,13 @@ onBeforeUnmount(() => { } .context-menu-item:hover { - background: #f5f5f5; + background: var(--sd-ui-menu-item-hover-bg, #f5f5f5); } .context-menu-item.is-selected { - background: #edf6ff; - color: #0096fd; - fill: #0096fd; + background: var(--sd-ui-menu-item-active-bg, #edf6ff); + color: var(--sd-ui-menu-item-active-text, #0096fd); + fill: var(--sd-ui-menu-item-active-text, #0096fd); } .context-menu-item-icon { @@ -617,17 +617,15 @@ onBeforeUnmount(() => { } .popover { - background: white; - border-radius: 6px; - box-shadow: - 0 0 0 1px rgba(0, 0, 0, 0.05), - 0px 10px 20px rgba(0, 0, 0, 0.1); + background: var(--sd-ui-menu-bg, #ffffff); + border-radius: var(--sd-ui-menu-radius, 0); + box-shadow: var(--sd-ui-menu-shadow, 0 0 0 1px rgba(0, 0, 0, 0.05), 0px 10px 20px rgba(0, 0, 0, 0.1)); z-index: 100; } .context-menu-divider { height: 1px; - background: #eee; + background: var(--sd-ui-menu-border, #eee); margin: 4px 0; } diff --git a/packages/super-editor/src/components/rulers/Ruler.vue b/packages/super-editor/src/components/rulers/Ruler.vue index 517b312f5d..8d14098607 100644 --- a/packages/super-editor/src/components/rulers/Ruler.vue +++ b/packages/super-editor/src/components/rulers/Ruler.vue @@ -527,7 +527,7 @@ onUnmounted(() => { position: absolute; top: -16px; left: -2px; - font-size: 10px; + font-size: var(--sd-ui-font-size-50, 10px); pointer-events: none; user-select: none; } diff --git a/packages/super-editor/src/components/toolbar/AIWriter.vue b/packages/super-editor/src/components/toolbar/AIWriter.vue index c75db9fbb4..74280a9a20 100644 --- a/packages/super-editor/src/components/toolbar/AIWriter.vue +++ b/packages/super-editor/src/components/toolbar/AIWriter.vue @@ -419,7 +419,7 @@ const handleInput = (event) => { padding-left: 8px; width: 100%; color: #47484a; - font-size: 12px; + font-size: var(--sd-ui-font-size-200, 12px); border: none; background: transparent; outline: none; @@ -449,7 +449,7 @@ const handleInput = (event) => { .ai-textarea-icon { display: block; font-weight: 800; - font-size: 14px; + font-size: var(--sd-ui-font-size-400, 14px); width: 16px; height: 16px; } diff --git a/packages/super-editor/src/components/toolbar/AlignmentButtons.vue b/packages/super-editor/src/components/toolbar/AlignmentButtons.vue index a621308cc1..7f90757fa5 100644 --- a/packages/super-editor/src/components/toolbar/AlignmentButtons.vue +++ b/packages/super-editor/src/components/toolbar/AlignmentButtons.vue @@ -105,17 +105,19 @@ onMounted(() => { .button-icon { cursor: pointer; padding: 5px; - font-size: 16px; + font-size: var(--sd-ui-font-size-600, 16px); + color: var(--sd-ui-dropdown-text, #47484a); width: 25px; height: 25px; - border-radius: 4px; + border-radius: var(--sd-ui-dropdown-option-radius, 3px); display: flex; justify-content: center; align-items: center; box-sizing: border-box; &:hover { - background-color: #d8dee5; + background-color: var(--sd-ui-dropdown-hover-bg, #d8dee5); + color: var(--sd-ui-dropdown-hover-text, #47484a); } :deep(svg) { diff --git a/packages/super-editor/src/components/toolbar/DocumentMode.vue b/packages/super-editor/src/components/toolbar/DocumentMode.vue index 021e83597c..aa2dbcaca6 100644 --- a/packages/super-editor/src/components/toolbar/DocumentMode.vue +++ b/packages/super-editor/src/components/toolbar/DocumentMode.vue @@ -103,14 +103,14 @@ onMounted(() => { .option-item { display: flex; flex-direction: row; - background-color: white; + background-color: var(--sd-ui-dropdown-bg, #ffffff); padding: 10px; - border-radius: 4px; + border-radius: var(--sd-ui-dropdown-option-radius, 3px); cursor: pointer; box-sizing: border-box; &:hover { - background-color: #c8d0d8; + background-color: var(--sd-ui-dropdown-hover-bg, #d8dee5); } } @@ -147,8 +147,8 @@ onMounted(() => { .document-mode-type { font-weight: 400; - font-size: 15px; - color: #222; + font-size: var(--sd-ui-font-size-500, 15px); + color: var(--sd-ui-text, #47484a); } .icon-column { @@ -156,7 +156,7 @@ onMounted(() => { justify-content: flex-start; align-items: center; padding: 0 5px; - color: black; + color: var(--sd-ui-text, #47484a); height: 100%; box-sizing: border-box; @@ -166,7 +166,7 @@ onMounted(() => { align-items: center; flex-shrink: 0; height: 18px; - color: #47484a; + color: var(--sd-ui-text, #47484a); } } @@ -177,7 +177,7 @@ onMounted(() => { } .document-mode-description { - font-size: 12px; - color: #666; + font-size: var(--sd-ui-font-size-200, 12px); + color: var(--sd-ui-text-muted, #666666); } diff --git a/packages/super-editor/src/components/toolbar/IconGrid.vue b/packages/super-editor/src/components/toolbar/IconGrid.vue index 946a5f865b..82dcde6846 100644 --- a/packages/super-editor/src/components/toolbar/IconGrid.vue +++ b/packages/super-editor/src/components/toolbar/IconGrid.vue @@ -53,7 +53,7 @@ const handleSelect = (option) => { diff --git a/packages/super-editor/src/components/toolbar/OverflowMenu.vue b/packages/super-editor/src/components/toolbar/OverflowMenu.vue index 9f668d40d4..912871a6bc 100644 --- a/packages/super-editor/src/components/toolbar/OverflowMenu.vue +++ b/packages/super-editor/src/components/toolbar/OverflowMenu.vue @@ -72,24 +72,27 @@ onBeforeUnmount(() => { diff --git a/packages/super-editor/src/components/toolbar/TableGrid.vue b/packages/super-editor/src/components/toolbar/TableGrid.vue index 6c3c825cde..5aa1b591be 100644 --- a/packages/super-editor/src/components/toolbar/TableGrid.vue +++ b/packages/super-editor/src/components/toolbar/TableGrid.vue @@ -157,13 +157,13 @@ onMounted(() => { .toolbar-table-grid__item { width: 20px; height: 20px; - border: 1px solid #d3d3d3; + border: 1px solid var(--sd-ui-border, #dbdbdb); cursor: pointer; transition: all 0.15s; } .toolbar-table-grid__item.selected { - background-color: #dbdbdb; + background-color: var(--sd-ui-dropdown-hover-bg, #d8dee5); } &.high-contrast { @@ -177,7 +177,7 @@ onMounted(() => { } .toolbar-table-grid-value { - font-size: 13px; + font-size: var(--sd-ui-font-size-300, 13px); line-height: 1.1; padding: 0px 8px 2px; } diff --git a/packages/super-editor/src/components/toolbar/Toolbar.vue b/packages/super-editor/src/components/toolbar/Toolbar.vue index 7fccad53af..918ba93e9e 100644 --- a/packages/super-editor/src/components/toolbar/Toolbar.vue +++ b/packages/super-editor/src/components/toolbar/Toolbar.vue @@ -140,7 +140,8 @@ const handleToolbarMousedown = (e) => { display: flex; width: 100%; justify-content: space-between; - padding: 4px 16px; + background: var(--sd-ui-toolbar-bg, transparent); + padding: var(--sd-ui-toolbar-padding-y, 4px) var(--sd-ui-toolbar-padding-x, 16px); box-sizing: border-box; font-family: var(--sd-ui-font-family, Arial, Helvetica, sans-serif); } diff --git a/packages/super-editor/src/components/toolbar/ToolbarButton.vue b/packages/super-editor/src/components/toolbar/ToolbarButton.vue index 26a6f4a27c..6747576dcb 100644 --- a/packages/super-editor/src/components/toolbar/ToolbarButton.vue +++ b/packages/super-editor/src/components/toolbar/ToolbarButton.vue @@ -158,7 +158,7 @@ const caretIcon = computed(() => { position: relative; z-index: 1; min-width: 30px; - margin: 0 1px; + margin: 0 calc(var(--sd-ui-toolbar-item-gap, 2px) / 2); } .visually-hidden { @@ -170,16 +170,16 @@ const caretIcon = computed(() => { } .toolbar-button { - padding: 5px; - height: 32px; - max-height: 32px; - border-radius: 6px; + padding: var(--sd-ui-toolbar-item-padding, 5px); + height: var(--sd-ui-toolbar-height, 32px); + max-height: var(--sd-ui-toolbar-height, 32px); + border-radius: var(--sd-ui-radius, 6px); overflow-y: visible; display: flex; align-items: center; justify-content: center; cursor: pointer; - color: #47484a; + color: var(--sd-ui-toolbar-button-text, #47484a); transition: all 0.2s ease-out; user-select: none; position: relative; @@ -187,7 +187,7 @@ const caretIcon = computed(() => { } .toolbar-button:hover { - background-color: #dbdbdb; + background-color: var(--sd-ui-toolbar-button-hover-bg, var(--sd-ui-hover-bg, #dbdbdb)); .toolbar-icon { &.high-contrast { @@ -203,7 +203,7 @@ const caretIcon = computed(() => { .toolbar-button:active, .active { - background-color: #c8d0d8; + background-color: var(--sd-ui-toolbar-button-active-bg, var(--sd-ui-active-bg, #c8d0d8)); } .button-label { @@ -213,7 +213,7 @@ const caretIcon = computed(() => { text-overflow: ellipsis; white-space: nowrap; font-weight: 400; - font-size: 15px; + font-size: var(--sd-ui-font-size-500, 15px); margin: 5px; } @@ -255,17 +255,17 @@ const caretIcon = computed(() => { } .button-text-input { - color: #47484a; + color: var(--sd-ui-toolbar-button-text, #47484a); border-radius: 4px; text-align: center; width: 30px; - font-size: 14px; + font-size: var(--sd-ui-font-size-400, 14px); margin-right: 5px; font-weight: 400; background-color: transparent; padding: 2px 0; outline: none; - border: 1px solid #d8dee5; + border: 1px solid var(--sd-ui-border, #dbdbdb); box-sizing: border-box; &.high-contrast { @@ -278,7 +278,7 @@ const caretIcon = computed(() => { } .button-text-input::placeholder { - color: #47484a; + color: var(--sd-ui-toolbar-button-text, #47484a); } .dropdown-caret { diff --git a/packages/super-editor/src/components/toolbar/ToolbarButtonIcon.vue b/packages/super-editor/src/components/toolbar/ToolbarButtonIcon.vue index d91b989538..d0f6739150 100644 --- a/packages/super-editor/src/components/toolbar/ToolbarButtonIcon.vue +++ b/packages/super-editor/src/components/toolbar/ToolbarButtonIcon.vue @@ -64,15 +64,6 @@ const hasColorBar = computed(() => { margin-top: -3px; } -.toolbar-button:hover { - color: black; - background-color: #d8dee5; -} -.toolbar-button:active, -.active { - background-color: #c8d0d8; -} - .color-bar { border-radius: 4px; position: absolute; diff --git a/packages/super-editor/src/components/toolbar/ToolbarDropdown.vue b/packages/super-editor/src/components/toolbar/ToolbarDropdown.vue index afdc2ffb4c..65cb02b409 100644 --- a/packages/super-editor/src/components/toolbar/ToolbarDropdown.vue +++ b/packages/super-editor/src/components/toolbar/ToolbarDropdown.vue @@ -373,10 +373,10 @@ onBeforeUnmount(() => { .toolbar-dropdown-menu { min-width: 80px; padding: 4px; - border-radius: 8px; - background: #fff; - border: 1px solid #e4e6eb; - box-shadow: 0 8px 24px rgba(0, 0, 0, 0.16); + border-radius: var(--sd-ui-radius, 6px); + background: var(--sd-ui-dropdown-bg, #fff); + border: 1px solid var(--sd-ui-dropdown-border, #e4e6eb); + box-shadow: var(--sd-ui-dropdown-shadow, 0 8px 24px rgba(0, 0, 0, 0.16)); box-sizing: border-box; } @@ -389,9 +389,10 @@ onBeforeUnmount(() => { display: flex; align-items: center; padding: 0 10px; - border-radius: 3px; + border-radius: var(--sd-ui-dropdown-option-radius, 3px); cursor: pointer; - font-size: 14px; + font-size: var(--sd-ui-font-size-400, 14px); + color: var(--sd-ui-dropdown-text, #47484a); transition: background-color 0.2s ease-out; box-sizing: border-box; } @@ -413,11 +414,13 @@ onBeforeUnmount(() => { } .toolbar-dropdown-option:hover { - background: #d8dee5; + background: var(--sd-ui-dropdown-hover-bg, #d8dee5); + color: var(--sd-ui-dropdown-hover-text, #47484a); } .toolbar-dropdown-option.selected { - background: #d8dee5; + background: var(--sd-ui-dropdown-active-bg, #d8dee5); + color: var(--sd-ui-dropdown-selected-text, #47484a); } .toolbar-dropdown-menu.high-contrast .toolbar-dropdown-option:not(.render):hover { @@ -438,9 +441,12 @@ onBeforeUnmount(() => { .toolbar-dropdown-option.render { padding: 0; cursor: default; + background: transparent; + color: inherit; } -.toolbar-dropdown-option.render:hover { +.toolbar-dropdown-option.render:hover, +.toolbar-dropdown-option.render.selected { background: transparent; color: inherit; } diff --git a/packages/super-editor/src/components/toolbar/ToolbarSeparator.vue b/packages/super-editor/src/components/toolbar/ToolbarSeparator.vue index 059a16927a..3a5efee57d 100644 --- a/packages/super-editor/src/components/toolbar/ToolbarSeparator.vue +++ b/packages/super-editor/src/components/toolbar/ToolbarSeparator.vue @@ -14,7 +14,7 @@ const getSeparatorColor = () => { if (isHighContrastMode.value) { return '#000'; } - return '#dbdbdb'; + return 'var(--sd-ui-border, #dbdbdb)'; }; @@ -26,13 +26,13 @@ const getSeparatorColor = () => { 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 a44b78c23a..5bedb8f6a4 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/SuperDoc.test.js b/packages/superdoc/src/SuperDoc.test.js index d6297298ec..deaeb0ee68 100644 --- a/packages/superdoc/src/SuperDoc.test.js +++ b/packages/superdoc/src/SuperDoc.test.js @@ -1466,9 +1466,9 @@ describe('SuperDoc.vue', () => { const styleVars = wrapper.vm.superdocStyleVars; // Active insertBorder should be overridden - expect(styleVars['--sd-track-insert-border']).toBe('#ff0000'); + expect(styleVars['--sd-tracked-changes-insert-border']).toBe('#ff0000'); // deleteBackground should be inherited from base config - expect(styleVars['--sd-track-delete-bg']).toBe('#0000ff'); + expect(styleVars['--sd-tracked-changes-delete-background']).toBe('#0000ff'); }); it('sets track change CSS vars from base config when no active config provided', async () => { @@ -1486,9 +1486,9 @@ describe('SuperDoc.vue', () => { const styleVars = wrapper.vm.superdocStyleVars; - expect(styleVars['--sd-track-insert-border']).toBe('#11ff11'); - expect(styleVars['--sd-track-delete-border']).toBe('#ff1111'); - expect(styleVars['--sd-track-format-border']).toBe('#1111ff'); + expect(styleVars['--sd-tracked-changes-insert-border']).toBe('#11ff11'); + expect(styleVars['--sd-tracked-changes-delete-border']).toBe('#ff1111'); + expect(styleVars['--sd-tracked-changes-format-border']).toBe('#1111ff'); }); it('sets comment highlight hover color CSS var', async () => { @@ -1501,6 +1501,6 @@ describe('SuperDoc.vue', () => { await nextTick(); const styleVars = wrapper.vm.superdocStyleVars; - expect(styleVars['--sd-comment-highlight-hover']).toBe('#abcdef88'); + expect(styleVars['--sd-comments-highlight-hover']).toBe('#abcdef88'); }); }); diff --git a/packages/superdoc/src/SuperDoc.vue b/packages/superdoc/src/SuperDoc.vue index 453dc4d773..a57fd88a9f 100644 --- a/packages/superdoc/src/SuperDoc.vue +++ b/packages/superdoc/src/SuperDoc.vue @@ -130,7 +130,7 @@ const superdocStyleVars = computed(() => { if (!commentsConfig || commentsConfig === false) return vars; if (commentsConfig.highlightHoverColor) { - vars['--sd-comment-highlight-hover'] = commentsConfig.highlightHoverColor; + vars['--sd-comments-highlight-hover'] = commentsConfig.highlightHoverColor; } const trackChangeColors = commentsConfig.trackChangeHighlightColors || {}; @@ -138,11 +138,16 @@ const superdocStyleVars = computed(() => { ...trackChangeColors, ...(commentsConfig.trackChangeActiveHighlightColors || {}), }; - if (activeTrackChangeColors.insertBorder) vars['--sd-track-insert-border'] = activeTrackChangeColors.insertBorder; - if (activeTrackChangeColors.insertBackground) vars['--sd-track-insert-bg'] = activeTrackChangeColors.insertBackground; - if (activeTrackChangeColors.deleteBorder) vars['--sd-track-delete-border'] = activeTrackChangeColors.deleteBorder; - if (activeTrackChangeColors.deleteBackground) vars['--sd-track-delete-bg'] = activeTrackChangeColors.deleteBackground; - if (activeTrackChangeColors.formatBorder) vars['--sd-track-format-border'] = activeTrackChangeColors.formatBorder; + if (activeTrackChangeColors.insertBorder) + vars['--sd-tracked-changes-insert-border'] = activeTrackChangeColors.insertBorder; + if (activeTrackChangeColors.insertBackground) + vars['--sd-tracked-changes-insert-background'] = activeTrackChangeColors.insertBackground; + if (activeTrackChangeColors.deleteBorder) + vars['--sd-tracked-changes-delete-border'] = activeTrackChangeColors.deleteBorder; + if (activeTrackChangeColors.deleteBackground) + vars['--sd-tracked-changes-delete-background'] = activeTrackChangeColors.deleteBackground; + if (activeTrackChangeColors.formatBorder) + vars['--sd-tracked-changes-format-border'] = activeTrackChangeColors.formatBorder; return vars; }); @@ -1536,26 +1541,17 @@ const getPDFViewer = () => { z-index: 3; display: flex; flex-direction: column; - gap: 6px; -} - -.tools .tool-icon { - font-size: 20px; - border-radius: 12px; - border: none; - outline: none; - background-color: #dbdbdb; - cursor: pointer; + gap: var(--sd-ui-tools-gap, 6px); } .tools-item { display: flex; align-items: center; justify-content: center; - width: 50px; - height: 50px; - background-color: rgba(219, 219, 219, 0.6); - border-radius: 12px; + width: var(--sd-ui-tools-item-size, 50px); + height: var(--sd-ui-tools-item-size, 50px); + background-color: var(--sd-ui-tools-item-bg, rgba(219, 219, 219, 0.6)); + border-radius: var(--sd-ui-tools-item-radius, 12px); cursor: pointer; position: relative; } @@ -1565,8 +1561,8 @@ const getPDFViewer = () => { } .superdoc__tools-icon { - width: 20px; - height: 20px; + width: var(--sd-ui-tools-icon-size, 20px); + height: var(--sd-ui-tools-icon-size, 20px); flex-shrink: 0; } @@ -1614,45 +1610,6 @@ const getPDFViewer = () => { z-index: 50; } */ -/* Tools styles */ -.tools { - position: absolute; - z-index: 3; - display: flex; - gap: 6px; -} - -.tools .tool-icon { - font-size: 20px; - border-radius: 12px; - border: none; - outline: none; - background-color: #dbdbdb; - cursor: pointer; -} - -.tools-item { - display: flex; - align-items: center; - justify-content: center; - position: relative; - width: 50px; - height: 50px; - background-color: rgba(219, 219, 219, 0.6); - border-radius: 12px; - cursor: pointer; -} - -.tools-item i { - cursor: pointer; -} - -.superdoc__tools-icon { - width: 20px; - height: 20px; - flex-shrink: 0; -} - .ai-tool > svg { fill: transparent; } diff --git a/packages/superdoc/src/assets/styles/helpers/_all.css b/packages/superdoc/src/assets/styles/helpers/_all.css index 470745bdc3..1f70dc6a9b 100644 --- a/packages/superdoc/src/assets/styles/helpers/_all.css +++ b/packages/superdoc/src/assets/styles/helpers/_all.css @@ -1 +1,3 @@ @import './variables.css'; +@import './compat.css'; +@import './themes.css'; diff --git a/packages/superdoc/src/assets/styles/helpers/compat.css b/packages/superdoc/src/assets/styles/helpers/compat.css new file mode 100644 index 0000000000..c6107d1729 --- /dev/null +++ b/packages/superdoc/src/assets/styles/helpers/compat.css @@ -0,0 +1,22 @@ +/* + * Backward-compatible aliases for renamed CSS variables. + * + * Most old names are honored via var() fallbacks in variables.css — if a + * customer sets --sd-comment-bg, the new --sd-ui-comments-card-bg picks it up + * automatically. This file only covers aliases where the old name maps to a + * different concept (no direct 1:1 fallback possible). + * + * These aliases will be removed in the next major version. + */ + +:root { + /* Old semantic names that map to primitives (no new-token equivalent) */ + --sd-surface-canvas: var(--sd-color-gray-50); + --sd-surface-selected: var(--sd-color-blue-100); + --sd-text-secondary: var(--sd-color-gray-700); + + /* Old names that map to a different concept in the new system */ + --sd-surface-page: var(--sd-ui-bg); + --sd-border-subtle: var(--sd-ui-comments-separator); + --sd-border-focus: var(--sd-ui-action); +} diff --git a/packages/superdoc/src/assets/styles/helpers/compat.test.ts b/packages/superdoc/src/assets/styles/helpers/compat.test.ts new file mode 100644 index 0000000000..ff8efe1144 --- /dev/null +++ b/packages/superdoc/src/assets/styles/helpers/compat.test.ts @@ -0,0 +1,169 @@ +import { describe, expect, it } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { resolve, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const variablesCss = readFileSync(resolve(__dirname, 'variables.css'), 'utf-8'); +const compatCss = readFileSync(resolve(__dirname, 'compat.css'), 'utf-8'); +const themesCss = readFileSync(resolve(__dirname, 'themes.css'), 'utf-8'); + +/** Extract all --sd-* variable declarations from a CSS string. */ +const extractDeclaredVars = (css: string): Set => { + const vars = new Set(); + for (const match of css.matchAll(/(--sd-[\w-]+)\s*:/g)) { + vars.add(match[1]); + } + return vars; +}; + +/** Extract all var(--sd-*) references from a CSS string. */ +const extractReferencedVars = (css: string): Set => { + const vars = new Set(); + for (const match of css.matchAll(/var\((--sd-[\w-]+)/g)) { + vars.add(match[1]); + } + return vars; +}; + +describe('backward compatibility', () => { + const declaredInVariables = extractDeclaredVars(variablesCss); + const declaredInCompat = extractDeclaredVars(compatCss); + const referencedByCompat = extractReferencedVars(compatCss); + const referencedByVariables = extractReferencedVars(variablesCss); + + describe('compat.css aliases', () => { + it('every alias points to a variable defined in variables.css', () => { + const broken: string[] = []; + for (const ref of referencedByCompat) { + if (!declaredInVariables.has(ref)) { + broken.push(ref); + } + } + expect(broken, `Compat aliases reference undefined variables: ${broken.join(', ')}`).toEqual([]); + }); + + it('does not re-declare any variable from variables.css', () => { + const collisions: string[] = []; + for (const name of declaredInCompat) { + if (declaredInVariables.has(name)) { + collisions.push(name); + } + } + expect(collisions, `Compat re-declares variables from variables.css: ${collisions.join(', ')}`).toEqual([]); + }); + + it('has no circular references with variables.css', () => { + // A circular reference: compat declares --old pointing to --new, + // AND variables.css declares --new pointing back to --old. + const circular: string[] = []; + for (const compatVar of declaredInCompat) { + // Find what this compat alias points to + const match = compatCss.match( + new RegExp(`${compatVar.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*:\\s*var\\((--sd-[\\w-]+)`), + ); + if (!match) continue; + const target = match[1]; + // Check if variables.css references the compat var name + const targetDecl = variablesCss.match( + new RegExp( + `${target.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*:[^;]*var\\(${compatVar.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`, + ), + ); + if (targetDecl) { + circular.push(`${compatVar} ↔ ${target}`); + } + } + expect(circular, `Circular references found: ${circular.join(', ')}`).toEqual([]); + }); + }); + + describe('old-name fallbacks in variables.css', () => { + it('honors old comment variable names', () => { + const expected: [string, string][] = [ + ['--sd-ui-comments-card-bg', '--sd-comment-bg'], + ['--sd-ui-comments-card-hover-bg', '--sd-comment-bg-hover'], + ['--sd-ui-comments-card-active-bg', '--sd-comment-bg-active'], + ['--sd-ui-comments-separator', '--sd-comment-separator'], + ['--sd-ui-comments-author-text', '--sd-comment-author-color'], + ['--sd-ui-comments-timestamp-text', '--sd-comment-time-color'], + ['--sd-ui-comments-internal-bg', '--sd-comment-internal-bg'], + ['--sd-ui-comments-external-bg', '--sd-comment-external-bg'], + ]; + for (const [newName, oldName] of expected) { + const pattern = new RegExp( + `${newName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*:\\s*var\\(${oldName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`, + ); + expect(variablesCss, `Expected ${newName} to fall back to ${oldName}`).toMatch(pattern); + } + }); + + it('honors old tracked change variable names', () => { + const expected: [string, string][] = [ + ['--sd-tracked-changes-insert-border', '--sd-track-insert-border'], + ['--sd-tracked-changes-insert-background', '--sd-track-insert-bg'], + ['--sd-tracked-changes-delete-border', '--sd-track-delete-border'], + ['--sd-tracked-changes-delete-background', '--sd-track-delete-bg'], + ['--sd-tracked-changes-format-border', '--sd-track-format-border'], + ]; + for (const [newName, oldName] of expected) { + const pattern = new RegExp( + `${newName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*:\\s*var\\(${oldName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`, + ); + expect(variablesCss, `Expected ${newName} to fall back to ${oldName}`).toMatch(pattern); + } + }); + + it('honors old semantic variable names', () => { + const expected: [string, string][] = [ + ['--sd-ui-bg', '--sd-surface-card'], + ['--sd-ui-text', '--sd-text-primary'], + ['--sd-ui-border', '--sd-border-default'], + ['--sd-ui-action', '--sd-action-primary'], + ['--sd-ui-font-family', '--sd-font-family'], + ['--sd-ui-hover-bg', '--sd-surface-hover'], + ]; + for (const [newName, oldName] of expected) { + const pattern = new RegExp( + `${newName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*:\\s*var\\(${oldName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`, + ); + expect(variablesCss, `Expected ${newName} to fall back to ${oldName}`).toMatch(pattern); + } + }); + }); + + describe('preset themes', () => { + /** Extract variables declared inside each .sd-theme-* block. */ + const extractThemeBlocks = (css: string): Map> => { + const themes = new Map>(); + const blockRegex = /\.(sd-theme-[\w-]+)\s*\{([^}]+)\}/g; + for (const match of css.matchAll(blockRegex)) { + const vars = new Set(); + for (const decl of match[2].matchAll(/(--sd-[\w-]+)\s*:/g)) { + vars.add(decl[1]); + } + themes.set(match[1], vars); + } + return themes; + }; + + const themeBlocks = extractThemeBlocks(themesCss); + + it('contains all expected preset themes', () => { + expect([...themeBlocks.keys()].sort()).toEqual(['sd-theme-blueprint', 'sd-theme-docs', 'sd-theme-word']); + }); + + it('every theme variable is declared in variables.css', () => { + const declaredInVariables = extractDeclaredVars(variablesCss); + const broken: string[] = []; + for (const [theme, vars] of themeBlocks) { + for (const v of vars) { + if (!declaredInVariables.has(v)) { + broken.push(`${theme}: ${v}`); + } + } + } + expect(broken, `Theme variables not in variables.css: ${broken.join(', ')}`).toEqual([]); + }); + }); +}); diff --git a/packages/superdoc/src/assets/styles/helpers/themes.css b/packages/superdoc/src/assets/styles/helpers/themes.css new file mode 100644 index 0000000000..4b3a29ba82 --- /dev/null +++ b/packages/superdoc/src/assets/styles/helpers/themes.css @@ -0,0 +1,166 @@ +/* Preset theme: Docs-like */ +.sd-theme-docs { + /* UI base */ + --sd-ui-font-family: Arial, sans-serif; + --sd-ui-text: #202124; + --sd-ui-bg: #f1f3f4; + --sd-ui-border: #dadce0; + --sd-ui-action: #1a73e8; + --sd-ui-action-hover: #185abc; + + /* UI: toolbar/dropdown/context */ + --sd-ui-toolbar-bg: #f0f4f9; + --sd-ui-toolbar-button-text: #3c4043; + --sd-ui-toolbar-button-hover-bg: #e8eaed; + --sd-ui-toolbar-button-active-bg: #d2e3fc; + --sd-ui-toolbar-item-padding: 4px; + + --sd-ui-dropdown-border: #dadce0; + --sd-ui-dropdown-bg: #ffffff; + --sd-ui-dropdown-hover-bg: #e8eaed; + --sd-ui-dropdown-active-bg: #d2e3fc; + --sd-ui-dropdown-shadow: 0 1px 2px rgba(60, 64, 67, 0.3), 0 1px 3px 1px rgba(60, 64, 67, 0.15); + + --sd-ui-menu-bg: #ffffff; + --sd-ui-menu-border: #dadce0; + --sd-ui-menu-shadow: 0 1px 2px rgba(60, 64, 67, 0.25), 0 3px 8px rgba(60, 64, 67, 0.18); + --sd-ui-menu-item-hover-bg: #e8eaed; + --sd-ui-menu-item-active-bg: #d2e3fc; + + /* Comments */ + --sd-ui-comments-card-bg: #d7dde8; + --sd-ui-comments-card-active-bg: #ffffff; + --sd-ui-comments-card-active-border: #c7cdd6; + --sd-ui-comments-card-shadow: 0 1px 2px rgba(60, 64, 67, 0.2), 0 2px 6px rgba(60, 64, 67, 0.18); + --sd-ui-comments-separator: #c7cdd6; + --sd-ui-comments-timestamp-text: #5f6368; + --sd-ui-comments-option-hover-bg: #e8eaed; + --sd-ui-comments-input-border: #80868b; + + --sd-comments-highlight-external-base: #f9ab00; + --sd-comments-highlight-external: #f9ab0033; + --sd-comments-highlight-external-active: #f9ab0044; + --sd-comments-highlight-external-faded: #f9ab001f; + --sd-comments-highlight-external-nested-border: #f9ab0088; + --sd-comments-highlight-internal-base: #188038; + --sd-comments-highlight-internal: #18803833; + --sd-comments-highlight-internal-active: #18803844; + --sd-comments-highlight-internal-faded: #1880381f; + --sd-comments-highlight-internal-nested-border: #18803888; + + /* Layout */ + --sd-layout-page-bg: #ffffff; + --sd-layout-page-shadow: 0 1px 2px rgba(60, 64, 67, 0.2), 0 2px 6px rgba(60, 64, 67, 0.12); +} + +/* Preset theme: Microsoft Word-like */ +.sd-theme-word { + /* UI base */ + --sd-ui-font-family: 'Segoe UI', Arial, sans-serif; + --sd-ui-text: #323130; + --sd-ui-bg: #f3f2f1; + --sd-ui-border: #d2d0ce; + --sd-ui-action: #185abd; + --sd-ui-action-hover: #134a9e; + + /* UI: toolbar/dropdown/context */ + --sd-ui-toolbar-bg: #f3f2f1; + --sd-ui-toolbar-button-text: #201f1e; + --sd-ui-toolbar-button-hover-bg: #e8e6e4; + --sd-ui-toolbar-button-active-bg: #d2d0ce; + --sd-ui-toolbar-item-padding: 4px; + + --sd-ui-dropdown-border: #d2d0ce; + --sd-ui-dropdown-bg: #ffffff; + --sd-ui-dropdown-hover-bg: #e8e6e4; + --sd-ui-dropdown-active-bg: #d2d0ce; + --sd-ui-dropdown-shadow: 0 1px 2px rgba(0, 0, 0, 0.14), 0 4px 8px rgba(0, 0, 0, 0.08); + + --sd-ui-menu-bg: #ffffff; + --sd-ui-menu-border: #d2d0ce; + --sd-ui-menu-shadow: 0 2px 6px rgba(0, 0, 0, 0.14); + --sd-ui-menu-item-hover-bg: #f3f2f1; + --sd-ui-menu-item-active-bg: #edebe9; + + /* Comments */ + --sd-ui-comments-card-bg: #ebebeb; + --sd-ui-comments-card-active-bg: #ffffff; + --sd-ui-comments-card-active-border: #d2d0ce; + --sd-ui-comments-card-shadow: 0 2px 4px rgba(0, 0, 0, 0.16), 0 10px 20px rgba(0, 0, 0, 0.1); + --sd-ui-comments-separator: #c8c6c4; + --sd-ui-comments-timestamp-text: #605e5c; + --sd-ui-comments-option-hover-bg: #f3f2f1; + --sd-ui-comments-input-border: #8a8886; + + --sd-comments-highlight-external-base: #8a8886; + --sd-comments-highlight-external: #8a888633; + --sd-comments-highlight-external-active: #8a888655; + --sd-comments-highlight-external-faded: #8a88861f; + --sd-comments-highlight-external-nested-border: #8a888688; + --sd-comments-highlight-internal-base: #0f6cbd; + --sd-comments-highlight-internal: #0f6cbd33; + --sd-comments-highlight-internal-active: #0f6cbd55; + --sd-comments-highlight-internal-faded: #0f6cbd1f; + --sd-comments-highlight-internal-nested-border: #0f6cbd88; + + /* Layout */ + --sd-layout-page-bg: #ffffff; + --sd-layout-page-shadow: 0 1px 2px rgba(0, 0, 0, 0.16), 0 6px 12px rgba(0, 0, 0, 0.08); +} + +/* Preset theme: Blueprint */ +.sd-theme-blueprint { + /* UI base */ + --sd-ui-font-family: 'IBM Plex Sans', 'Segoe UI', Arial, sans-serif; + --sd-ui-text: #0f172a; + --sd-ui-text-muted: #334155; + --sd-ui-text-disabled: #64748b; + --sd-ui-bg: #e8eff5; + --sd-ui-hover-bg: #d3e2ef; + --sd-ui-active-bg: #bfd4e6; + --sd-ui-border: #9fb4c7; + --sd-ui-action: #0f766e; + --sd-ui-action-hover: #115e59; + --sd-ui-shadow: 0 2px 8px rgba(15, 23, 42, 0.18); + + /* Toolbar / dropdown / context */ + --sd-ui-toolbar-bg: #dbe6f0; + --sd-ui-toolbar-button-text: #0f172a; + --sd-ui-toolbar-button-hover-bg: #c9dced; + --sd-ui-toolbar-button-active-bg: #b7cee3; + --sd-ui-dropdown-bg: #ffffff; + --sd-ui-dropdown-border: #8ea5b8; + --sd-ui-dropdown-hover-bg: #e7f0f8; + --sd-ui-dropdown-active-bg: #cde2f3; + --sd-ui-dropdown-shadow: 0 8px 24px rgba(15, 23, 42, 0.22); + --sd-ui-menu-bg: #ffffff; + --sd-ui-menu-border: #8ea5b8; + --sd-ui-menu-item-hover-bg: #e2eef8; + --sd-ui-menu-item-active-bg: #cde2f3; + --sd-ui-menu-shadow: 0 8px 24px rgba(15, 23, 42, 0.22); + + /* Layout */ + --sd-layout-page-bg: #ffffff; + --sd-layout-page-shadow: 0 8px 24px rgba(15, 23, 42, 0.16); + + /* Comments */ + --sd-ui-comments-card-bg: #dbe7f2; + --sd-ui-comments-card-active-bg: #ffffff; + --sd-ui-comments-card-active-border: #8ea5b8; + --sd-ui-comments-card-shadow: 0 10px 24px rgba(15, 23, 42, 0.18); + --sd-ui-comments-input-border: #7f96aa; + --sd-ui-comments-separator: #9fb4c7; + --sd-ui-comments-timestamp-text: #475569; + + /* Highlights */ + --sd-comments-highlight-external-base: #b45309; + --sd-comments-highlight-external: #b4530933; + --sd-comments-highlight-external-active: #b4530944; + --sd-comments-highlight-external-faded: #b453091f; + --sd-comments-highlight-external-nested-border: #b4530988; + --sd-comments-highlight-internal-base: #0f766e; + --sd-comments-highlight-internal: #0f766e33; + --sd-comments-highlight-internal-active: #0f766e44; + --sd-comments-highlight-internal-faded: #0f766e1f; + --sd-comments-highlight-internal-nested-border: #0f766e88; +} diff --git a/packages/superdoc/src/assets/styles/helpers/variables.css b/packages/superdoc/src/assets/styles/helpers/variables.css index d9bb9236df..df9bac3657 100644 --- a/packages/superdoc/src/assets/styles/helpers/variables.css +++ b/packages/superdoc/src/assets/styles/helpers/variables.css @@ -1,3 +1,200 @@ :root { - /* CSS variables */ + /* SuperDoc CSS Variables */ + + /* Primitive: colors */ + --sd-color-blue-50: #ebf0ff; + --sd-color-blue-100: #d6e1ff; + --sd-color-blue-200: #adc3ff; + --sd-color-blue-300: #85a5ff; + --sd-color-blue-400: #5c87ff; + --sd-color-blue-500: #1355ff; + --sd-color-blue-600: #0f44cc; + --sd-color-blue-700: #0b3399; + --sd-color-blue-800: #082266; + --sd-color-blue-900: #041133; + + --sd-color-gray-50: #fafafa; + --sd-color-gray-100: #f5f5f5; + --sd-color-gray-200: #f2f2f2; + --sd-color-gray-300: #e0e0e0; + --sd-color-gray-400: #dbdbdb; + --sd-color-gray-500: #ababab; + --sd-color-gray-600: #888888; + --sd-color-gray-700: #666666; + --sd-color-gray-800: #424242; + --sd-color-gray-900: #212121; + + --sd-color-red-500: #ed4337; + --sd-color-green-500: #00853d; + --sd-color-rose-500: #cb0e47; + + /* Primitive: font-size */ + --sd-font-size-50: 10px; + --sd-font-size-100: 11px; + --sd-font-size-200: 12px; + --sd-font-size-300: 13px; + --sd-font-size-400: 14px; + --sd-font-size-500: 15px; + --sd-font-size-600: 16px; + + /* Primitive: radius — old names (--sd-radius-sm/md/lg) honored via fallback */ + --sd-radius-50: var(--sd-radius-sm, 4px); + --sd-radius-100: 6px; + --sd-radius-200: var(--sd-radius-md, 8px); + --sd-radius-300: var(--sd-radius-lg, 12px); + + /* + * UI: general (semantic tier) + * + * These are the "5-variable theme" — change these and the entire UI updates. + * Component-specific variables below cascade from these defaults. + * + * Old-name fallbacks (var(--sd-old-name, ...)) honor pre-existing customer + * overrides. They resolve at computed-value time, so setting the old name on + * any element still works. These fallbacks will be removed in the next major. + */ + --sd-ui-font-family: var(--sd-font-family, Arial, Helvetica, sans-serif); + --sd-ui-text: var(--sd-text-primary, #47484a); + --sd-ui-text-muted: var(--sd-text-muted, var(--sd-color-gray-700)); + --sd-ui-text-disabled: var(--sd-text-placeholder, var(--sd-color-gray-500)); + --sd-ui-bg: var(--sd-surface-card, #ffffff); + --sd-ui-hover-bg: var(--sd-surface-hover, var(--sd-color-gray-400)); + --sd-ui-active-bg: #c8d0d8; + --sd-ui-disabled-bg: var(--sd-surface-muted, var(--sd-color-gray-100)); + --sd-ui-border: var(--sd-border-default, var(--sd-color-gray-400)); + --sd-ui-action: var(--sd-action-primary, var(--sd-color-blue-500)); + --sd-ui-action-hover: var(--sd-action-primary-hover, var(--sd-color-blue-600)); + --sd-ui-action-text: #ffffff; + --sd-ui-shadow: 0 4px 12px rgba(0, 0, 0, 0.12); + --sd-ui-radius: var(--sd-radius-100); + + --sd-ui-font-size-50: var(--sd-font-size-50); + --sd-ui-font-size-100: var(--sd-font-size-100); + --sd-ui-font-size-200: var(--sd-font-size-200); + --sd-ui-font-size-300: var(--sd-font-size-300); + --sd-ui-font-size-400: var(--sd-font-size-400); + --sd-ui-font-size-500: var(--sd-font-size-500); + --sd-ui-font-size-600: var(--sd-font-size-600); + + /* UI: dropdown — cascades from semantic tier */ + --sd-ui-dropdown-border: var(--sd-ui-border); + --sd-ui-dropdown-bg: var(--sd-ui-bg); + --sd-ui-dropdown-option-radius: 3px; + --sd-ui-dropdown-text: var(--sd-ui-text); + --sd-ui-dropdown-hover-text: var(--sd-ui-dropdown-text); + --sd-ui-dropdown-selected-text: var(--sd-ui-dropdown-text); + --sd-ui-dropdown-hover-bg: var(--sd-ui-hover-bg); + --sd-ui-dropdown-active-bg: var(--sd-ui-active-bg); + --sd-ui-dropdown-shadow: 0 8px 24px rgba(0, 0, 0, 0.16); + + /* UI: tooltip — intentionally inverted palette (dark bg, light text) */ + --sd-ui-tooltip-bg: #262626; + --sd-ui-tooltip-text: #ffffff; + --sd-ui-tooltip-radius: var(--sd-radius-100); + --sd-ui-tooltip-shadow: 0 3px 12px rgba(0, 0, 0, 0.28); + + /* UI: toolbar — cascades from semantic tier */ + --sd-ui-toolbar-height: 32px; + --sd-ui-toolbar-padding-x: 16px; + --sd-ui-toolbar-padding-y: 4px; + --sd-ui-toolbar-item-gap: 2px; + --sd-ui-toolbar-item-padding: 5px; + --sd-ui-toolbar-bg: transparent; + --sd-ui-toolbar-button-text: var(--sd-ui-text); + --sd-ui-toolbar-button-hover-bg: var(--sd-ui-hover-bg); + --sd-ui-toolbar-button-active-bg: var(--sd-ui-active-bg); + + /* UI: context menu — cascades from semantic tier */ + --sd-ui-menu-bg: var(--sd-ui-bg); + --sd-ui-menu-text: var(--sd-ui-text); + --sd-ui-menu-font-size: var(--sd-font-size-200); + --sd-ui-menu-radius: 0; + --sd-ui-menu-shadow: 0 0 0 1px rgba(0, 0, 0, 0.05), 0px 10px 20px rgba(0, 0, 0, 0.1); + --sd-ui-menu-border: var(--sd-ui-border); + --sd-ui-menu-input-border: var(--sd-ui-border); + --sd-ui-menu-input-focus-border: var(--sd-ui-action); + --sd-ui-menu-item-hover-bg: var(--sd-ui-hover-bg); + --sd-ui-menu-item-active-bg: var(--sd-ui-active-bg); + --sd-ui-menu-item-active-text: var(--sd-ui-action); + + /* UI: tools */ + --sd-ui-tools-gap: 6px; + --sd-ui-tools-item-size: 50px; + --sd-ui-tools-item-radius: var(--sd-radius-300); + --sd-ui-tools-item-bg: rgba(219, 219, 219, 0.6); + --sd-ui-tools-icon-size: 20px; + + /* UI: comments — cascades from semantic tier, old names honored via fallback */ + --sd-ui-comments-card-bg: var(--sd-comment-bg, #f3f6fd); + --sd-ui-comments-card-hover-bg: var(--sd-comment-bg-hover, var(--sd-ui-comments-card-bg)); + --sd-ui-comments-card-active-bg: var(--sd-comment-bg-active, var(--sd-ui-bg)); + --sd-ui-comments-card-resolved-bg: var(--sd-comment-bg-resolved, var(--sd-ui-disabled-bg)); + --sd-ui-comments-card-active-border: var(--sd-comment-border-active, var(--sd-ui-border)); + --sd-ui-comments-card-radius: var(--sd-comment-radius, var(--sd-radius-300)); + --sd-ui-comments-card-padding: var(--sd-comment-padding, 16px); + --sd-ui-comments-card-shadow: var(--sd-comment-shadow, 0px 4px 12px 0px rgba(50, 50, 50, 0.15)); + --sd-ui-comments-separator: var(--sd-comment-separator, var(--sd-ui-border)); + --sd-ui-comments-transition: var(--sd-comment-transition, all 200ms ease); + --sd-ui-comments-author-text: var(--sd-comment-author-color, var(--sd-ui-text)); + --sd-ui-comments-author-size: var(--sd-comment-author-size, var(--sd-font-size-400)); + --sd-ui-comments-author-weight: var(--sd-comment-author-weight, 600); + --sd-ui-comments-tag-text: var(--sd-ui-text-muted); + --sd-ui-comments-tag-bg: var(--sd-color-gray-200); + --sd-ui-comments-timestamp-text: var(--sd-comment-time-color, var(--sd-ui-text-muted)); + --sd-ui-comments-timestamp-size: var(--sd-comment-time-size, var(--sd-font-size-200)); + --sd-ui-comments-body-text: var(--sd-ui-text); + --sd-ui-comments-body-size: var(--sd-comment-body-size, var(--sd-font-size-400)); + --sd-ui-comments-resolved-text: var(--sd-color-green-500); + --sd-ui-comments-insert-text: var(--sd-comment-tc-insert-color, var(--sd-color-green-500)); + --sd-ui-comments-delete-text: var(--sd-comment-tc-delete-color, var(--sd-color-rose-500)); + --sd-ui-comments-dropdown-border: var(--sd-ui-border); + --sd-ui-comments-dropdown-bg: var(--sd-ui-bg); + --sd-ui-comments-dropdown-shadow: 0 8px 24px rgba(0, 0, 0, 0.12); + --sd-ui-comments-option-size: var(--sd-font-size-400); + --sd-ui-comments-option-text: var(--sd-ui-comments-body-text); + --sd-ui-comments-option-hover-text: var(--sd-ui-comments-option-text); + --sd-ui-comments-option-selected-text: var(--sd-ui-comments-option-text); + --sd-ui-comments-option-hover-bg: var(--sd-ui-hover-bg); + --sd-ui-comments-input-bg: var(--sd-ui-bg); + --sd-ui-comments-input-border: var(--sd-ui-border); + --sd-ui-comments-internal-bg: var(--sd-comment-internal-bg, #cde6e6); + --sd-ui-comments-external-bg: var(--sd-comment-external-bg, #f5cfda); + + /* Styles: comments — old names honored via fallback */ + --sd-comments-highlight-external-base: #b1124b; + --sd-comments-highlight-external: var(--sd-comment-highlight-external, #b1124b40); + --sd-comments-highlight-external-active: #b1124b66; + --sd-comments-highlight-external-faded: #b1124b20; + --sd-comments-highlight-internal-base: #078383; + --sd-comments-highlight-internal: var(--sd-comment-highlight-internal, #07838340); + --sd-comments-highlight-internal-active: #07838366; + --sd-comments-highlight-internal-faded: #07838320; + --sd-comments-highlight-external-nested-border: #b1124b99; + --sd-comments-highlight-internal-nested-border: #07838399; + --sd-comments-highlight-hover: #1354ff55; + --sd-comments-selection-background: #1354ff55; + + /* Styles: tracked changes — old names honored via fallback */ + --sd-tracked-changes-insert-background: var(--sd-track-insert-bg, #399c7222); + --sd-tracked-changes-insert-border: var(--sd-track-insert-border, #00853d); + --sd-tracked-changes-delete-background: var(--sd-track-delete-bg, #cb0e4722); + --sd-tracked-changes-delete-border: var(--sd-track-delete-border, #cb0e47); + --sd-tracked-changes-format-border: var(--sd-track-format-border, gold); + --sd-tracked-changes-insert-background-focused: #399c7244; + --sd-tracked-changes-delete-background-focused: #cb0e4744; + --sd-tracked-changes-format-background-focused: #ffd70033; + + /* Styles: content controls (SDT) — blue accent, intentionally standalone */ + --sd-content-controls-block-border: #629be7; + --sd-content-controls-block-hover-bg: var(--sd-ui-hover-bg); + --sd-content-controls-inline-border: #629be7; + --sd-content-controls-inline-hover-bg: var(--sd-ui-hover-bg); + --sd-content-controls-label-border: #629be7; + --sd-content-controls-label-bg: #629be7ee; + --sd-content-controls-label-text: #ffffff; + --sd-content-controls-lock-hover-bg: rgba(98, 155, 231, 0.08); + + /* Styles: layout — cascades from semantic tier */ + --sd-layout-page-bg: var(--sd-ui-bg); + --sd-layout-page-shadow: 0 4px 20px rgba(15, 23, 42, 0.08); } diff --git a/packages/superdoc/src/assets/styles/tokens.css b/packages/superdoc/src/assets/styles/tokens.css deleted file mode 100644 index 9105f36522..0000000000 --- a/packages/superdoc/src/assets/styles/tokens.css +++ /dev/null @@ -1,112 +0,0 @@ -/* - * SuperDoc Design Tokens as CSS Custom Properties - * - * Generated from brand/tokens/. In production, a build step (Style Dictionary) - * would generate this file automatically. For now, it's maintained manually. - * - * Users of SuperDoc can override any of these variables to customize - * the look and feel of every component. - */ - -:root { - /* ─── Primitive: Colors ─── */ - --sd-color-blue-50: #ebf0ff; - --sd-color-blue-100: #d6e1ff; - --sd-color-blue-200: #adc3ff; - --sd-color-blue-300: #85a5ff; - --sd-color-blue-400: #5c87ff; - --sd-color-blue-500: #1355ff; - --sd-color-blue-600: #0f44cc; - --sd-color-blue-700: #0b3399; - --sd-color-blue-800: #082266; - --sd-color-blue-900: #041133; - - --sd-color-gray-50: #fafafa; - --sd-color-gray-100: #f5f5f5; - --sd-color-gray-200: #f2f2f2; - --sd-color-gray-300: #e0e0e0; - --sd-color-gray-400: #dbdbdb; - --sd-color-gray-500: #ababab; - --sd-color-gray-600: #888888; - --sd-color-gray-700: #666666; - --sd-color-gray-800: #424242; - --sd-color-gray-900: #212121; - - --sd-color-red-500: #ed4337; - --sd-color-green-500: #00853d; - --sd-color-rose-500: #cb0e47; - - /* ─── Primitive: Typography ─── */ - --sd-font-family: 'Inter', system-ui, sans-serif; - --sd-font-mono: 'JetBrains Mono', monospace; - - /* ─── Primitive: Spacing ─── */ - --sd-radius-sm: 4px; - --sd-radius-md: 8px; - --sd-radius-lg: 12px; - - /* ─── Semantic: Surfaces ─── */ - --sd-surface-page: #ffffff; - --sd-surface-canvas: var(--sd-color-gray-50); - --sd-surface-card: #ffffff; - --sd-surface-muted: var(--sd-color-gray-100); - --sd-surface-hover: var(--sd-color-gray-200); - --sd-surface-selected: var(--sd-color-blue-100); - - /* ─── Semantic: Text ─── */ - --sd-text-primary: var(--sd-color-gray-900); - --sd-text-secondary: var(--sd-color-gray-700); - --sd-text-muted: var(--sd-color-gray-600); - --sd-text-placeholder: var(--sd-color-gray-500); - - /* ─── Semantic: Borders ─── */ - --sd-border-default: var(--sd-color-gray-400); - --sd-border-subtle: var(--sd-color-gray-300); - --sd-border-focus: var(--sd-color-blue-500); - - /* ─── Semantic: Actions ─── */ - --sd-action-primary: var(--sd-color-blue-500); - --sd-action-primary-hover: var(--sd-color-blue-600); - - /* ─── Component: Comment Dialog ─── */ - --sd-comment-bg: #f3f6fd; - --sd-comment-bg-hover: #f3f6fd; - --sd-comment-bg-active: var(--sd-surface-card); - --sd-comment-bg-resolved: #f0f0f0; - --sd-comment-border-active: var(--sd-border-subtle); - --sd-comment-radius: var(--sd-radius-lg); - --sd-comment-padding: 16px; - --sd-comment-shadow: 0px 4px 12px 0px rgba(50, 50, 50, 0.15); - --sd-comment-max-width: 300px; - --sd-comment-min-width: 200px; - --sd-comment-separator: var(--sd-border-subtle); - --sd-comment-transition: all 200ms ease; - - /* Comment: author & timestamp */ - --sd-comment-author-color: var(--sd-text-primary); - --sd-comment-author-size: 14px; - --sd-comment-author-weight: 600; - --sd-comment-time-color: var(--sd-text-muted); - --sd-comment-time-size: 12px; - --sd-comment-body-size: 14px; - - /* Comment: tracked change text colors */ - --sd-comment-tc-insert-color: var(--sd-color-green-500); - --sd-comment-tc-delete-color: var(--sd-color-rose-500); - - /* Comment: document highlights */ - --sd-comment-highlight-internal: #078383; - --sd-comment-highlight-external: #b1124b; - --sd-comment-highlight-opacity: 0.2; - --sd-comment-highlight-opacity-active: 0.4; - - /* Comment: internal/external toggle */ - --sd-comment-internal-bg: #cde6e6; - --sd-comment-external-bg: #f5cfda; - - /* Comment: group bubble (collapsed) */ - --sd-comment-group-bg: #e2e9fb; - --sd-comment-group-color: var(--sd-action-primary); - --sd-comment-group-size: 51px; - --sd-comment-group-expanded-size: 300px; -} diff --git a/packages/superdoc/src/components/CommentsLayer/CommentDialog.vue b/packages/superdoc/src/components/CommentsLayer/CommentDialog.vue index 1c8f4e11d3..b3e91645f5 100644 --- a/packages/superdoc/src/components/CommentsLayer/CommentDialog.vue +++ b/packages/superdoc/src/components/CommentsLayer/CommentDialog.vue @@ -754,18 +754,18 @@ watch(editingCommentId, (commentId) => { .comments-dialog { display: flex; flex-direction: column; - padding: var(--sd-comment-padding, 16px); - border-radius: var(--sd-comment-radius, 12px); - background-color: var(--sd-comment-bg, #f3f6fd); + padding: var(--sd-ui-comments-card-padding, 16px); + border-radius: var(--sd-ui-comments-card-radius, 12px); + background-color: var(--sd-ui-comments-card-bg, #f3f6fd); border: 1px solid transparent; font-family: var(--sd-ui-font-family, Arial, Helvetica, sans-serif); - font-size: var(--sd-comment-body-size, 14px); + font-size: var(--sd-ui-comments-body-size, 14px); line-height: 1.5; - transition: var(--sd-comment-transition, all 200ms ease); + transition: var(--sd-ui-comments-transition, all 200ms ease); box-shadow: none; z-index: 5; - max-width: var(--sd-comment-max-width, 300px); - min-width: var(--sd-comment-min-width, 200px); + max-width: 300px; + min-width: 200px; width: 100%; overflow-wrap: break-word; word-break: break-word; @@ -774,33 +774,33 @@ watch(editingCommentId, (commentId) => { cursor: pointer; } .comments-dialog:not(.is-active):not(.is-resolved):hover { - background-color: var(--sd-comment-bg-hover, #f3f6fd); + background-color: var(--sd-ui-comments-card-hover-bg, #f3f6fd); } .comments-dialog:not(.is-resolved):hover :deep(.overflow-menu) { opacity: 1; pointer-events: auto; } .comments-dialog.is-active { - background-color: var(--sd-comment-bg-active, #ffffff); - border-color: var(--sd-comment-border-active, #e0e0e0); - box-shadow: var(--sd-comment-shadow, 0px 4px 12px 0px rgba(50, 50, 50, 0.15)); + background-color: var(--sd-ui-comments-card-active-bg, #ffffff); + border-color: var(--sd-ui-comments-card-active-border, #e0e0e0); + box-shadow: var(--sd-ui-comments-card-shadow, 0px 4px 12px 0px rgba(50, 50, 50, 0.15)); z-index: 10; } .comments-dialog.is-resolved { - background-color: var(--sd-comment-bg-resolved, #f0f0f0); + background-color: var(--sd-ui-comments-card-resolved-bg, #f0f0f0); } .comment-separator { - background-color: var(--sd-comment-separator, #e0e0e0); + background-color: var(--sd-ui-comments-separator, #e0e0e0); height: 1px; width: 100%; margin: 10px 0; } .comment { - font-size: var(--sd-comment-body-size, 14px); + font-size: var(--sd-ui-comments-body-size, 14px); line-height: 1.5; - color: var(--sd-comment-author-color, #212121); + color: var(--sd-ui-comments-body-text, #212121); margin: 4px 0 0 0; } .comment :deep(p) { @@ -808,22 +808,22 @@ watch(editingCommentId, (commentId) => { } .tracked-change { - font-size: var(--sd-comment-body-size, 14px); + font-size: var(--sd-ui-comments-body-size, 14px); line-height: 1.5; - color: var(--sd-comment-author-color, #212121); + color: var(--sd-ui-comments-body-text, #212121); margin: 4px 0 0 0; } .change-type { - color: var(--sd-comment-author-color, #212121); + color: var(--sd-ui-comments-body-text, #212121); } .tracked-change-text { - color: var(--sd-comment-author-color, #212121); + color: var(--sd-ui-comments-body-text, #212121); } .tracked-change-text.is-deleted { - color: var(--sd-comment-tc-delete-color, #cb0e47); + color: var(--sd-ui-comments-delete-text, #cb0e47); } .tracked-change-text.is-inserted { - color: var(--sd-comment-tc-insert-color, #00853d); + color: var(--sd-ui-comments-insert-text, #00853d); font-weight: 500; } @@ -834,7 +834,7 @@ watch(editingCommentId, (commentId) => { gap: 4px; font-size: 11px; font-weight: 500; - color: var(--sd-color-green-500, #00853d); + color: var(--sd-ui-comments-resolved-text, #00853d); margin-bottom: 4px; } .resolved-badge__icon { @@ -858,7 +858,7 @@ watch(editingCommentId, (commentId) => { } .show-more-toggle { font-size: 12px; - color: var(--sd-action-primary, #1355ff); + color: var(--sd-ui-action, #1355ff); cursor: pointer; font-weight: 500; margin-top: 4px; @@ -875,7 +875,7 @@ watch(editingCommentId, (commentId) => { gap: 6px; padding: 8px 0; font-size: 12px; - color: var(--sd-action-primary, #1355ff); + color: var(--sd-ui-action, #1355ff); font-weight: 500; cursor: pointer; user-select: none; @@ -890,7 +890,7 @@ watch(editingCommentId, (commentId) => { --sd-comment-avatar-size: 20px; --sd-comment-avatar-font-size: 8px; margin-left: -4px; - border: 2px solid var(--sd-comment-bg-active, #ffffff); + border: 2px solid var(--sd-ui-comments-card-active-bg, #ffffff); } .collapsed-avatars .mini-avatar:first-child { margin-left: 0; @@ -898,10 +898,10 @@ watch(editingCommentId, (commentId) => { /* ── New comment input ── */ .new-comment-input-wrapper { - border: 1.5px solid var(--sd-border-default, #dbdbdb); + border: 1.5px solid var(--sd-ui-comments-input-border, #dbdbdb); border-radius: 12px; padding: 8.5px 10.5px; - background: var(--sd-surface-card, #ffffff); + background: var(--sd-ui-comments-input-bg, #ffffff); margin-top: 4px; max-height: 150px; overflow-y: auto; @@ -945,10 +945,10 @@ watch(editingCommentId, (commentId) => { margin-top: 10px; } .reply-input-wrapper { - border: 1.5px solid var(--sd-border-default, #dbdbdb); + border: 1.5px solid var(--sd-ui-comments-input-border, #dbdbdb); border-radius: 12px; padding: 8.5px 10.5px; - background: var(--sd-surface-card, #ffffff); + background: var(--sd-ui-comments-input-bg, #ffffff); max-height: 150px; overflow-y: auto; } @@ -983,21 +983,21 @@ watch(editingCommentId, (commentId) => { border: none; font-size: 13px; font-weight: 500; - color: var(--sd-color-gray-700, #666666); + color: var(--sd-ui-text-muted, #666666); cursor: pointer; padding: 0; font-family: inherit; transition: color 150ms; } .reply-btn-cancel:hover { - color: var(--sd-color-gray-900, #212121); + color: var(--sd-ui-text, #212121); } .reply-btn-primary { - background: var(--sd-action-primary, #1355ff); + background: var(--sd-ui-action, #1355ff); border: none; font-size: 13px; font-weight: 600; - color: #ffffff; + color: var(--sd-ui-action-text, #ffffff); cursor: pointer; padding: 6px 16px; border-radius: 9999px; @@ -1005,7 +1005,7 @@ watch(editingCommentId, (commentId) => { transition: background 150ms; } .reply-btn-primary:hover { - background: var(--sd-action-primary-hover, #0f44cc); + background: var(--sd-ui-action-hover, #0f44cc); } .reply-btn-primary.is-disabled { background: var(--sd-color-gray-400, #dbdbdb); diff --git a/packages/superdoc/src/components/CommentsLayer/CommentHeader.vue b/packages/superdoc/src/components/CommentsLayer/CommentHeader.vue index f7f74a692b..77bd5084b3 100644 --- a/packages/superdoc/src/components/CommentsLayer/CommentHeader.vue +++ b/packages/superdoc/src/components/CommentsLayer/CommentHeader.vue @@ -209,9 +209,9 @@ const getCurrentUser = computed(() => { flex-direction: column; } .user-name { - font-size: var(--sd-comment-author-size, 14px); - font-weight: var(--sd-comment-author-weight, 600); - color: var(--sd-comment-author-color, #212121); + font-size: var(--sd-ui-comments-author-size, 14px); + font-weight: var(--sd-ui-comments-author-weight, 600); + color: var(--sd-ui-comments-author-text, #212121); line-height: 1.2em; } .imported-tag { @@ -220,8 +220,8 @@ const getCurrentUser = computed(() => { font-weight: 600; text-transform: uppercase; letter-spacing: 0.04em; - color: var(--sd-comment-imported-tag-color, var(--sd-color-gray-600, #888888)); - background: var(--sd-comment-imported-tag-bg, var(--sd-color-gray-200, #f2f2f2)); + color: var(--sd-ui-comments-tag-text, #888888); + background: var(--sd-ui-comments-tag-bg, #f2f2f2); border-radius: 3px; padding: 1px 4px; margin-left: 6px; @@ -230,8 +230,8 @@ const getCurrentUser = computed(() => { } .user-timestamp { line-height: 1.2em; - font-size: var(--sd-comment-time-size, 12px); - color: var(--sd-comment-time-color, #888888); + font-size: var(--sd-ui-comments-timestamp-size, 12px); + color: var(--sd-ui-comments-timestamp-text, #888888); } .overflow-menu { flex-shrink: 1; @@ -259,7 +259,7 @@ const getCurrentUser = computed(() => { transition: all 250ms ease; } .overflow-menu__icon:hover { - background-color: var(--sd-comment-separator, #dbdbdb); + background-color: var(--sd-ui-comments-separator, #dbdbdb); } .overflow-menu__icon :deep(svg) { width: 100%; diff --git a/packages/superdoc/src/components/CommentsLayer/CommentsDropdown.vue b/packages/superdoc/src/components/CommentsLayer/CommentsDropdown.vue index d3aca11bb2..a6bbd3d911 100644 --- a/packages/superdoc/src/components/CommentsLayer/CommentsDropdown.vue +++ b/packages/superdoc/src/components/CommentsLayer/CommentsDropdown.vue @@ -176,15 +176,16 @@ onBeforeUnmount(() => { .comments-dropdown__menu { min-width: 120px; border-radius: 8px; - border: 1px solid var(--sd-border-default, #dbdbdb); - background: #fff; - box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12); + border: 1px solid var(--sd-ui-comments-dropdown-border, #dbdbdb); + background: var(--sd-ui-comments-dropdown-bg, #fff); + box-shadow: var(--sd-ui-comments-dropdown-shadow, 0 8px 24px rgba(0, 0, 0, 0.12)); padding: 4px; box-sizing: border-box; } .comments-dropdown__option { - font-size: 14px; + font-size: var(--sd-ui-comments-option-size, 14px); + color: var(--sd-ui-comments-option-text, #212121); display: flex; align-items: center; gap: 8px; @@ -196,7 +197,8 @@ onBeforeUnmount(() => { } .comments-dropdown__option:hover { - background-color: #f3f3f5; + background-color: var(--sd-ui-comments-option-hover-bg, #f3f3f5); + color: var(--sd-ui-comments-option-hover-text, #212121); } .comments-dropdown__option.disabled { diff --git a/packages/superdoc/src/components/CommentsLayer/InternalDropdown.vue b/packages/superdoc/src/components/CommentsLayer/InternalDropdown.vue index 0af31611ed..38ecc5c3f3 100644 --- a/packages/superdoc/src/components/CommentsLayer/InternalDropdown.vue +++ b/packages/superdoc/src/components/CommentsLayer/InternalDropdown.vue @@ -23,13 +23,13 @@ const options = [ label: 'Internal', key: 'internal', iconString: superdocIcons.internal, - backgroundColor: '#CDE6E6', + backgroundColor: 'var(--sd-ui-comments-internal-bg, #CDE6E6)', }, { label: 'External', key: 'external', iconString: superdocIcons.external, - backgroundColor: '#F5CFDA', + backgroundColor: 'var(--sd-ui-comments-external-bg, #F5CFDA)', }, ]; 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..449b360b1a --- /dev/null +++ b/packages/superdoc/src/core/theme/create-theme.ts @@ -0,0 +1,166 @@ +export interface ThemeColors { + /** Action/accent color (buttons, links, active states). Default: #1355ff */ + action?: string; + /** Action hover state. Default: #0f44cc */ + actionHover?: string; + /** Text color on action-colored buttons. Default: #ffffff */ + actionText?: 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', + actionText: '--sd-ui-action-text', + 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 safeName = name ? name.replace(/[^a-zA-Z0-9-_]/g, '-') : null; + const className = `sd-theme-${safeName || `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/dev/components/SuperdocDev.vue b/packages/superdoc/src/dev/components/SuperdocDev.vue index 467f7770fd..c2dc34e6ed 100644 --- a/packages/superdoc/src/dev/components/SuperdocDev.vue +++ b/packages/superdoc/src/dev/components/SuperdocDev.vue @@ -1,6 +1,7 @@