---
title: "Themes (Obsidian)"
description: "Render an Obsidian theme on the whole app, choose, upload and delete themes, and know the limits."
canonical_url: "https://skaile-ai.github.io/forge-editor/guides/themes"
---
# 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](https://obsidian.md) 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:

```ts
// `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](https://skaile-ai.github.io/forge-editor/guides/editor-ports)) 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:

```ts
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:

<table>
<thead>
  <tr>
    <th>
      Element
    </th>
    
    <th>
      Obsidian variables
    </th>
  </tr>
</thead>

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

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.

<table>
<thead>
  <tr>
    <th>
      Route
    </th>
    
    <th>
      Request
    </th>
    
    <th>
      Answers
    </th>
  </tr>
</thead>

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

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](https://skaile-ai.github.io/forge-editor/guides/editor-ports)).

**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.


## Sitemap

See the full [sitemap](https://skaile-ai.github.io/forge-editor/sitemap.md) for all pages.
