Guides

Themes (Obsidian)

Render an Obsidian theme on the whole app, choose, upload and delete themes, and know the limits.

themesFeature() (@skaile/forge-editor/themes/vue or /themes/react, part of advancedEditor(); the simple editor stays backend-free) renders an Obsidian theme (theme.css plus manifest.json) on the whole app: dashboard, navbars, toolbars, the settings panel, modals and the editor content. <html> carries obsidian-theme theme-dark or obsidian-theme theme-light according to html.dark (Nuxt colour mode, or your own dark class on React; without a dark/light class the system preference), observed live, so toggling the mode switches the palette without a reload. Every Nuxt UI --ui-* token (on React the shadcn tokens, through themes-bridge.css in advanced.css) is bridged to the theme's variables, so components follow without per-component CSS; the chrome uses the theme's interface font (the page's root font size stays untouched) and the editor content its text font. The Default appearance („Standard“) applies no Theme: it is the plain Nuxt UI (or shadcn) look. Builtin themes: Nord, Things, Minimal, Catppuccin, Border, Blue Topaz, Tokyo Night, Flexoki, Cupertino, Prism, iA Writer, Shimmering Focus, Gruvbox, Rosé Pine and Solarized. The runtime starts with the first mounted editor surface (the feature's setup hook) or the first pin, so a page with neither is not themed.

Choosing a theme. themesFeature() contributes the settings tab „Theme“ (English label “Theme”; SettingsAppearanceTab, id appearance, shown by EditorSettingsPanel/EditorSettingsModal like any tab; the simple editor has none). Its select lists „Standard“ (English label “Default appearance”) plus every theme of GET /api/editor/themes, labelled name – author; the choice is appearance.theme, applies to the whole app at once and persists with the other settings (per browser, and to the server when remote settings are on). The app's navbars carry a <UColorModeButton /> that toggles light/dark for the app and the theme together. Programmatically:

// `PUT /api/editor/settings` with `appearance: { theme: 'nord' }`, or in the browser console (settings are stored per browser until synced):
const key = 'tiptap-editor:settings'
localStorage.setItem(key, JSON.stringify({ ...JSON.parse(localStorage.getItem(key) ?? '{}'), appearance: { theme: 'nord' } }))
// `null` (the default) is the Default appearance.

Setup: @skaile/forge-editor/styles/themes.css after core.css and the feature sheets (the Nuxt module adds it last; React hosts of the advanced editor get it, with the shadcn bridge, from react/styles/advanced.css), and an adapter with listThemes/loadTheme (EditorPorts) serving GET /api/editor/themes and GET /api/editor/themes/:id. A custom list of features uses themesFeature() from /themes/vue or /themes/react (call the Vue one during component setup); the framework-free themesFeatureCore(ui), startObsidianTheme(ctx) (the runtime; returns its release), scopeThemeCss(css, scope?) and the Vue-free ThemeRecord/THEME_ID/themeManifestSchema (/themes/schema) are exported for hosts.

A theme per page (pin). pinObsidianTheme(ctx, id) (@skaile/forge-editor/themes; ctx is the app's EditorContext or a FeatureContext) shows theme id (null = Default appearance) instead of appearance.theme until its release runs. The setting is not touched: nothing is persisted, and the rest of the app keeps the user's choice. Pins stack per app (the most recent wins, releasing it falls back to the one before, the last release to the setting), and a pin holds the theme runtime, so it applies on a page without an editor surface too. Taking the next page's pin before releasing the previous one in the same task loads only the next theme. The kitchen-sink layout of this repository pins each page's theme from its registry entry (app/editor/kitchen-sink/pages.ts), on the client only:

let release: (() => void) | undefined
watch(() => pageOf(route.path.split('/')[2] ?? '')?.theme, (theme) => {
  const previous = release
  release = theme ? pinObsidianTheme(appContext, theme) : undefined
  previous?.()
}, { immediate: true })
onUnmounted(() => release?.())

How it works. The runtime (one per app, shared by every editor on the page; started by the feature's setup hook and reference-counted, so it starts as soon as the first surface mounts; on Vue it then lives until the Vue app unmounts, also on pages without an editor, on React until the last surface that started it unmounts) reads appearance.theme, fetches the stylesheet through the app's fetch and scopes it with scopeThemeCss: body, :root and html rules become the scope element (body.theme-dark → .obsidian-theme.theme-dark), .theme-dark/.theme-light rules the scope element with that class, Obsidian's content-root classes (.markdown-rendered, .markdown-preview-view, .markdown-source-view, .cm-s-obsidian, …) .tiptap, everything else becomes a descendant selector; every emitted selector starts with .obsidian-theme. The scope element is <html>: the runtime toggles obsidian-theme and theme-dark/theme-light on document.documentElement (client side only; the first server-rendered paint shows the Default appearance). The result sits in one <style data-tiptap-theme> in <head> (removed again for null) and is cached per theme for the session. @font-face, @keyframes, @media, @supports and @layer survive.

Brand tokens. One package carries the Skaile brand: @skaile/brand. It holds the raw tokens (@skaile/brand/theme.css: --color-success, --primary, …) and the Vue/Nuxt bridge: @skaile/brand/nuxt-ui.css maps those tokens onto Nuxt UI's --ui-* variables. React hosts import theme.css directly. All examples and the app pin ^0.7.0. When --color-success is defined, --sk-success follows it in every kit (nuxt-ui.css, react/styles/ui-vars.css), and the Obsidian bridge maps --ui-success to it before falling back to --color-green.

What is mapped. styles/themes.css defines Obsidian's variables with defaults (a theme overrides them) and maps them onto the whole app and the editor content:

ElementObsidian variables
Root <html> (background, text colour, interface font; no font size)--background-primary, --text-normal, --font-interface
.tiptap (text font and size, line height, caret) and ::selection--font-text, --font-text-size, --line-height-normal, --caret-color, --text-selection
p, h1–h6--p-spacing, --hN-size/-weight/-color/-line-height/-font/-variant/-style, --heading-spacing
strong, em, s, u--font-weight + --bold-modifier, --bold-color, --italic-color
Links--link-color, --link-color-hover, --link-external-color, --link-decoration
code, pre--code-background, --code-normal, --code-size, --code-radius, --font-monospace, --code-white-space
blockquote, hr--blockquote-*, --hr-color, --hr-thickness
Lists, placeholder--list-indent, --list-marker-color, --list-spacing, --text-faint
.tag, .mention--tag-*, --text-accent, --background-modifier-hover
.composite, .media-block, img--embed-border-start, --embed-background, --radius-s
Every package feature stylesheet (authors, comments, transcript, composites, media, mentions)follows through the Nuxt UI tokens, which the bridge points at Obsidian's: --ui-text → --text-normal, --ui-bg → --background-primary, --ui-border → --background-modifier-border, --ui-primary → --interactive-accent, --ui-warning/--ui-error/--ui-success/--ui-info → --color-orange/red/green/blue, --font-mono → --font-monospace
Host callout (.callout[data-tone], app/editor/callout/callout.css)--callout-info, --callout-warning, … as RGB triplets (rgba(var(--callout-color), 0.1) background), --callout-radius, --callout-padding; the host owns this mapping for its own blocks

Cascade: the bridge is part of @layer components; the scoped theme is injected unlayered, so its rules beat the bridge and core.css. Selecting the Default appearance („Standard“, null) removes the style element and the three classes from <html>; the app then looks as before.

Add a builtin theme (host). In this repository a folder server/themes/<id>/ with manifest.json (name, optional version, author, authorUrl; the folder name must match the THEME_ID pattern ^[a-z0-9][a-z0-9-]{0,63}$), theme.css and the theme's LICENSE is a builtin: server/utils/themes.ts reads the folder once per process (restart the server after adding one) and GET /api/editor/themes marks it builtin: true. Seeded builtins (all MIT): Nord (nord), Things (things), Minimal (minimal), Catppuccin (catppuccin), Border (border), Blue Topaz (blue-topaz), Tokyo Night (tokyo-night), Flexoki (flexoki), Cupertino (cupertino), Prism (prism), iA Writer (ia-writer), Shimmering Focus (shimmering-focus), Gruvbox (gruvbox), Rosé Pine (rose-pine) and Solarized (solarized). Another host implements the adapter methods (listThemes/loadTheme, plus storeTheme/removeTheme for uploads) over its own storage.

Upload and delete. The „Theme“ tab has a row „Theme hochladen“ with two file inputs, theme.css (required) and manifest.json (optional), and an upload button; below it the Theme-Bibliothek lists every theme with name, version and author. A successful upload joins the library for every user, is selected for the uploader and shows the toast „Theme „X“ hochgeladen“. Uploads carry a trash button (DELETE; deleting the selected theme resets appearance.theme to null, the Default appearance „Standard“); builtins carry the badge „mitgeliefert“ and no delete button. Failures appear as toasts with the server's German message.

RouteRequestAnswers
POST /api/editor/themesmultipart: css (File, required), manifest (File, optional), name (string, optional)201 + ThemeRecord (builtin: false); 400 missing css, „Ungültiges manifest.json“, or CSS that is not UTF-8 text or starts with markup (<script, <?xml within the first 512 bytes); 413 „Theme ist größer als 2 MB“
DELETE /api/editor/themes/:id–204; 404 unknown id; 403 „Mitgelieferte Themes können nicht gelöscht werden“ for builtins; 400 for an id that does not fit THEME_ID

Both routes authenticate like the read routes and answer 501 when the host has no themes port. Limits: theme.css up to 2 MB (MAX_THEME_BYTES); the manifest is parsed with themeManifestSchema (name 1–120 characters; version, author, authorUrl optional). The name comes from the manifest, else the name field, else the CSS file's base name; the id is its slug ([^a-z0-9]+ → -, at most 64 characters), made unique with -2, -3 … against the library. In this repository uploads persist under .data/themes/<id>/ (manifest.json, theme.css; Nitro storage mount themes) and survive restarts; the adapter methods storeTheme(record, css) and removeTheme(id) are the seam for another host's storage (EditorPorts).

Failures. A theme that cannot be loaded (deleted, network error, adapter without theme methods) falls back to the Default appearance for the session with a console warning (tiptap-editor: Theme „<id>“ konnte nicht geladen werden …); the stored setting is not changed. An id that does not fit THEME_ID is rejected by the settings schema.

Limits.

  • Rules for Obsidian's own chrome (.workspace*, .nav-*, .cm-line, the sidebar, tabs, status bar) are scoped like the rest and match nothing in the app; they do no harm. The app chrome follows the theme through the Nuxt UI tokens only, not through an emulation of Obsidian's DOM.
  • Obsidian sets body classes depending on settings and plugins (is-mobile, is-translucent, …); only theme-dark/theme-light are set on <html>, so rules that need other body classes do not apply (Catppuccin and Minimal fall back to their default palettes).
  • @import is dropped (a theme never triggers remote requests); fonts must come as @font-face in the file, as the builtins do. Imported stylesheets and remote fonts are lost.
  • Theme CSS is user-supplied and unlayered: a rule like .obsidian-theme * { display: none } breaks the app for whoever selected that theme. Themes are chosen per user.
  • A theme's own body { font-size } rule lands on <html> after scoping and rescales rem, as Obsidian applies it to body; the bridge itself sets no root font size.
  • Scoping needs CSSStyleSheet.replaceSync (Chrome 73+, Firefox 101+, Safari 16.4+). Selectors are rewritten from the browser's serialized selectorText, so quotes and spacing are normalised; the meaning is unchanged.

bun run check:editor-entries proves the isolation with the themes fixture.

Copyright © 2026