Synergy has one frontend color contract shared by the Web app, reusable UI components, Desktop-hosted Web surfaces, and plugin themes. A selectable theme changes the values behind that contract; it does not introduce another palette or a component-specific theme path.
| Concern | Canonical source |
|---|---|
| Public token names, seed contract, resolver, schema, and types | packages/plugin/src/theme/ and @ericsanchezok/synergy-plugin/theme |
| Built-in Synergy seed and override values | packages/ui/src/theme/themes/synergy.json |
| Runtime selection and application | packages/ui/src/theme/context.tsx and application.ts |
| Plugin theme registration | packages/ui/src/theme/plugin-theme-registry.ts and apps/web/src/plugin/ui-assets.ts |
| Web/Desktop shell snapshot | packages/ui/src/theme/shell-skin.ts and apps/desktop/src/theme.ts |
The UI package keeps compatibility re-exports for the public contract. The generated static CSS, Tailwind mappings, JSON Schema, Web boot fallback, and Desktop fallback skin are outputs. Never edit them by hand. Regenerate them with:
bun run --cwd packages/ui generate:themeUse the semantic meaning of a color, not its hue. Product code should use classes such as bg-surface-raised-base, text-text-on-critical-base, and border-border-weak-base, or the corresponding var(--token-name) custom property.
- Do not add Tailwind palette utilities such as
text-blue-500,bg-black/40, orborder-zinc-700. - Do not add arbitrary literal color utilities such as
bg-[rgba(...)]ortext-[#...]. - Do not create a component-local light/dark palette, hue map, or aliases such as
surface-muted,panel-soft, orchart-blue. - Do not use status colors as decoration. Choose
success,warning,critical, orinfoonly when that meaning is present. - Use ordinal
chart-series-*tokens for categorical visualization colors. The default series palette is derived from the theme'sprimaryseed in OKLCH and can be overridden token by token. Do not borrow status or avatar tokens because their hue looks convenient. - Reuse an existing token only when its semantic role matches. If no role exists, add one canonical token and resolve it for every theme instead of inventing a consumer-only variable.
- Pair filled status surfaces with their matching foreground token, such as
surface-critical-solidwithtext-on-critical-solid; do not combine independent status ramp strengths and assume they remain readable. - Keep the polarity invariant: dark-mode content and selected surfaces step brighter than their containers; light-mode raised/card surfaces step brighter than the canvas while chrome (sidebar/navbar) sits one step darker, and hover/active states step darker than their resting surface in both modes.
- Text/background and status foreground/surface pairs must meet WCAG AA contrast in both modes.
Literal colors belong only at the theme-authoring boundary, in color-generation math, or in genuinely user/external-authored content. A new product color outside those boundaries is a theme-contract change and needs a semantic token.
The default neutral recipe belongs to themes/synergy.json, including navigation (background-weak), canvas (background-stronger), input (input-base) and popup (surface-raised-stronger-non-alpha). Input and raised content may share a color; do not add a nested surface step merely to make them different. Necessary captions use a readable text role without reducing the enclosing opacity. Verify actual text/surface pairs after workbench and portal overrides; changing a theme token alone does not prove the rendered result. The shared-column, input-anchor and interaction rules live in the Web product contract.
text-permission-full-access is the public foreground role for the Full Access permission selector. Its consumer class is text-text-permission-full-access, shared by the trigger icon, trigger short label and menu icons. The default Synergy skin overrides it in packages/ui/src/theme/themes/synergy.json with light #C4481C and dark #FF8A50; it does not recolor general warning roles.
Without an override, every theme resolves this role to var(--text-on-warning-base), including existing input themes and seed-only skins. Existing Theme JSON needs no migration. An explicit override of the new role requires a host and Plugin Kit version that recognizes the token: versions whose canonical token set omits it reject that override as unknown. The role adds no stricter global contrast gates; existing required-pair validation remains unchanged, and actual selector contrast still needs rendered verification. See the decision record for the alternatives and trade-offs.
The TypeScript output contract is additive but exhaustive consumers need a source update: ResolvedTheme and Record<ThemeTokenName, …> now require this key, and exhaustive switches must handle the new union member. Prefer constructing complete output with resolveTheme() or resolveThemeVariant(). Handwritten resolved maps can add "text-permission-full-access": "var(--text-on-warning-base)" to preserve their previous behavior.
Canvas, Chart.js, Monaco, terminal engines, SVG renderers, and isolated documents cannot assume that a CSS custom-property string is directly usable. Read the active resolved theme and flatten references with resolveThemeColor:
import { resolveThemeColor, useTheme } from "@ericsanchezok/synergy-ui/theme"
const theme = useTheme()
const foreground = () => resolveThemeColor(theme.tokens(), "text-base")Keep this read inside Solid reactivity so switching between two themes with the same light/dark mode still updates the renderer. Existing App charts use useChartTheme() for series, canvas, axes, grids, legends, tooltips, points, hover, borders, and state colors. Note code blocks use the shared CSS-variable Shiki theme, while Mermaid uses base plus resolved themeVariables and rerenders through the same theme-change event.
An isolated consumer that cannot use the Solid context may listen for THEME_CHANGE_EVENT. This event fires for both color-scheme changes and same-mode theme changes. Do not infer theme identity only from data-color-scheme or a dark-mode media query.
Themes define complete light and dark variants. Each variant supplies opaque three- or six-digit hex seeds. The nine semantic seeds anchor the product contract:
| Seed | Role |
|---|---|
neutral |
Canvas, surfaces, borders, and ordinary text |
primary |
Product brand surfaces |
interactive |
Links, selection, focus, and primary interaction |
success |
Successful or healthy state |
warning |
Caution and pending attention |
error |
Critical, destructive, or failed state |
info |
Informational state |
diffAdd |
Added diff content |
diffDelete |
Deleted diff content |
Four optional syntax seeds (syntaxString, syntaxKeyword, syntaxType, syntaxProperty) drive the resolver's syntax highlight tokens. They are optional in serialized themes for compatibility with the original nine-seed plugin format. parseTheme validates and preserves the author-facing seed shape; the resolver normalizes missing syntax seeds at its boundary (resolveThemeVariant derives syntaxString ← success, syntaxKeyword ← primary, syntaxType ← info, syntaxProperty ← interactive), so a nine-seed theme round-trips unchanged through parsing and resolves with the full thirteen-seed semantics.
Choose seeds as palette anchors, not as final component colors. The resolver generates the full ramp and semantic contract. Start with seeds only, inspect both modes, and add overrides only for intentional semantic exceptions. Rebuilding most tokens through overrides defeats the shared resolver and makes a theme harder to maintain.
Overrides may use a supported hex value or a reference to another canonical token, for example:
References must be canonical and acyclic. Theme loading rejects incomplete variants, translucent seeds, unknown tokens, unsupported syntax, cycles, mismatched manifest/asset IDs, and required text/surface pairs below WCAG AA contrast.
The public contract is available without Solid:
import { parseTheme, resolveTheme, resolveThemeColor, ThemeSchema } from "@ericsanchezok/synergy-plugin/theme"Plugin Kit build, validate, and dev use this exact parser for both source and packaged Theme JSON. Declared asset hashes participate in the plugin generation, so a Theme JSON-only edit changes the generation URL and invalidates caches. A failed dev rebuild leaves the last valid generation pointer untouched.
Color scheme and skin identity are separate. system, light, and dark choose the effective variant; the selected namespaced theme ID chooses the resolved colors. The validated synergy-skin-cache-v1 Web snapshot stores the raw Theme JSON plus flattened light/dark shell colors. The preloader reads only named shell fields after hex validation, while ThemeProvider reparses the raw theme before using it.
Desktop persists DesktopSkinStateV2 with the source, theme ID, and both shell variants. Saves serialize per destination in invocation order and replace the validated file atomically; a failed save rejects its caller without blocking later updates. BrowserWindow background, startup overlay, diagnostic page, and temporary window controls use the persisted effective variant before the Web renderer loads. Source-only legacy state migrates to V2 with the generated Synergy fallback. Theme application sends a strict full-skin IPC update; OS appearance changes select a variant without replacing the skin.
All selectable themes — built-in or plugin — resolve through one shared registry and one resolver pipeline. Built-in curated skins pre-register from packages/ui/src/theme/default-themes.ts; the default Synergy skin keeps its curated overrides in themes/synergy.json (the product's shipped visual contract), while the additional built-in skins are defined seeds-only. Plugin themes register into the same registry through the public extension boundary and appear in Settings → General → Appearance after installation. A plugin theme may not shadow a built-in skin id. Theme selection is a global user preference (config general.theme), so plugin theme registration is global too: the Web host's GlobalPluginThemesRegistrar pulls the server-wide plugin.listGlobalThemeContributions aggregate (every scope's enabled ui.theme contributions), publishes one atomic registry generation, and refetches when the active scope or the plugin host's contribution list changes — theme registration does not follow the Scope-scoped plugin-host reload cycle. When the ready registry is missing the selected theme (a scope was not activated yet after a server restart, or a theme asset failed to load), the ThemeProvider keeps the selection and renders degraded default tokens without overwriting synergy-skin-cache-v1; the selection re-applies automatically once the theme re-enters the registry.
The recommended path for new distributable themes is a structured plugin theme. It uses the public extension boundary, remains independently distributable, and requires no core changes:
Create the theme plugin from the current template:
bunx @ericsanchezok/synergy-plugin-kit create ocean-theme --template theme-icon
cd ocean-theme
bun installtheme-icon is the scaffold name for declarative theme and optional SVG icon assets; it is not a separate theme format. For a theme-only plugin, remove the generated icon(...) contribution and SVG file.
Then:
- Edit the generated
themes/default.jsonwith complete light and dark seeds. - Keep the theme asset
ididentical to the sourcetheme({ id: ... })contribution ID. - Add overrides only after seed-only visual inspection shows a real semantic exception.
- Validate, build, and package the plugin:
synergy-plugin build
synergy-plugin validate
synergy-plugin packInstall or refresh the local project with synergy plugin add file:///absolute/path/to/ocean-theme, then switch to it in Appearance. See Plugin UI contributions for the current contribution contract.
Edit the seed palettes and curated overrides in themes/synergy.json for the default Synergy skin, or the seed palettes in packages/ui/src/theme/default-themes.ts for the other built-in skins, only when changing a curated visual contract. Keep non-default built-in skins seeds-only so the shared resolver and its contrast assertions guarantee quality; the Synergy default may carry overrides because it is the shipped product contract. Do not hardcode the adjustment in a consuming component. After the edit, regenerate the theme artifacts and verify the product polarity and contrast in both modes.
Add a token only when the product has a durable semantic role that existing tokens cannot represent:
- Add the name to
packages/plugin/src/theme/tokens.ts. - Assign it in
packages/plugin/src/theme/resolve.tsfor every resolved variant. - Regenerate the CSS mappings and JSON schema.
- Replace consumer-local literals or aliases with the canonical token.
- Add or update behavioral contract tests and documentation when the role is durable.
Avoid hue-based token names for product semantics. A name such as surface-critical-weak remains meaningful across themes; red-weak does not.
Run the focused contract checks first:
bun test --cwd packages/ui test/theme.test.ts test/theme-application.test.ts test/theme-generation.test.ts
bun test --cwd apps/web test/testing/color-token-contract.test.ts
bun test --cwd apps/web test/components/note/theme-adapters.test.ts test/components/visualization/use-chart-theme.test.ts
bun test --cwd apps/desktop test/theme.test.ts test/startup-page.test.ts
bun test --cwd packages/plugin-kit test/create.test.ts test/build.test.ts
bun run --cwd packages/ui typecheck
bun run --cwd apps/web typecheckFor a platform change, also run:
bun run --cwd packages/ui test
bun run --cwd apps/web test
bun dev build app
bun run deadcode
bun run quality:quickInspect light and dark mode, switch between two themes without changing mode, and check DOM surfaces plus Note code/Mermaid, every Chart.js surface, terminal, Monaco, rendered HTML, overlays, status colors, focus, hover, selected, disabled, and error states. Reload Web and fully restart Desktop; neither startup path may show a Synergy fallback frame before the selected custom skin.
{ "overrides": { "surface-brand-base": "#0E7490", "syntax-comment": "var(--text-weaker)", }, }