Preview Themes — markdown プレビューのテーマ機構
markdown プレビューは、アプリ本体のカラースキーム(コア UI トークン --theme-*)から完全に分離された専用のテーマ機構を持ちます。プレビューの見た目は --ztp-*(zudo-text text preview)トークン契約だけで決まり、本体の配色を切り替えてもプレビューは変化しません。逆に、プレビューのテーマを切り替えても本体 UI は変化しません。
このページは --ztp-* 契約、base / theme の分離、テーマ内部で使う grouped-token 規約(Tier 1 パレット / Tier 2 セマンティック)、data-preview-theme スコープキャリア、そして「テーマの追加方法」を説明します。
なぜ分離するのか
プレビューはエディタ本体とは独立した「読み物の見た目」を持つべきものです。本体のカラースキーム(ターミナル由来の 16 色パレットから解決される --theme-*)にプレビューを縛り付けると、「GitHub 風の白いプレビュー」と「ダークなエディタ UI」を同時に選ぶ、といった組み合わせができません。そこでプレビュー側に専用のトークン層 --ztp-* を設け、テーマファイルがその値を供給します。
鉄則: プレビューの CSS(tauri-、directives.css、TOC)は --ztp-* だけを参照し、コアの --theme-* を一切参照しません。
# 受け入れチェック(何も返らなければ OK)
grep -rn -- "--theme-" \
tauri-app/renderer/styles/markdown.css \
tauri-app/renderer/styles/directives.cssファイル構成
tauri-app/renderer/styles/
├── markdown.css # --ztp-* を消費する「色・装飾」レイヤ(構造は持たない)
├── directives.css # ディレクティブ(admonition / card / badge …)の色・装飾レイヤ
└── preview-themes/
├── index.css # アンブレラ。_base → 各テーマ → markdown/directives の順で import
├── _base.css # 構造のみ(余白・行間・--display-scale 計算)+ admonition の色デフォルト
├── 01-foundry.css # テーマ #1(リファレンス実装:GitHub 風ライト)
├── 02-ledger.css # 既存 10 テーマ(フラットな --ztp-* 値。grouped-token
├── … # 規約への再構築は #3676 / #3677 が担当)
└── 10-avenue.css プレビューを描画する箇所では、個別の markdown.css / directives.css ではなく preview- を import します。読み込み順が重要なので、必ずアンブレラ経由にしてください。
base / theme の分離
_base.css— 構造だけ。見出しの余白、flow-space(lobotomized owl)による縦リズム、行間、--display-scaleを掛けた px 計算。色は持ちません(唯一の例外として、後述の admonition セマンティック色のデフォルト値だけは base に置きます)。各テーマファイル —
--ztp-*の値だけを宣言します。余白・行間・flow は再宣言しません(_base.cssに一度だけ存在させるのが鉄則)。
この分離により、後続のテーマ実装者は「自分の色」だけを書けばよく、構造は全テーマで共有されます。
--ztp-* トークン契約
テーマは .markdown-body[data-preview-theme="<slug>"](および .preview-pane[...])に対して次のトークンを宣言します。
Color
--ztp-bg, --ztp-fg, --ztp-heading-fg, --ztp-heading-2-fg, --ztp-link, --ztp-link-hover, --ztp-accent, --ztp-strong-fg, --ztp-em-fg, --ztp-code-fg, --ztp-code-bg, --ztp-code-border, --ztp-pre-bg, --ztp-pre-border, --ztp-blockquote-fg, --ztp-blockquote-bar, --ztp-blockquote-bg, --ztp-border, --ztp-table-header-bg, --ztp-table-zebra-bg
Background pattern(任意)
--ztp-bg-pattern, --ztp-bg-pattern-size — テーマ付きスクロールサーフェスの背景に模様(ドットグリッド、方眼、罫線など)を敷きたいテーマだけが宣言するオプショントークン。詳細は下記「Grouped-token 規約」を参照。
Type
--ztp-font-body, --ztp-font-heading, --ztp-font-mono
TOC
- Grouped-token 規約(Tier 1 / Tier 2)
- data-preview-theme スコープキャリア
- テーマの追加方法(レシピ)
- リファレンス:Foundry(テーマ #1)
- Custom themes (Theme Creator)
--ztp-toc-fg, --ztp-toc-active-fg, --ztp-toc-active-bg, --ztp-toc-accent
Directives(admonition セマンティック)
info / tip / warning / danger(および note)の 3 値ずつ:--ztp-admonition-<kind>-fg / -border / -bg。これらは _base.css に妥当なデフォルトがあり、テーマは必要に応じて上書きするだけで構いません。
inlineStyles(色ではない)
i / em / strong の font-style・font-weight は、エディタの editor.inlineStyles 設定から applyInlineStyles() が --ztp-italic-font-style / --ztp-italic-font-weight / --ztp-emphasis-* / --ztp-strong-* に書き込みます。色(--ztp-em-fg / --ztp-strong-fg)はテーマが供給し、字形(style/weight)は設定が供給するという役割分担です。
Grouped-token 規約(Tier 1 / Tier 2)
上記の --ztp-* トークンは、各テーマの CSS ファイル内部では素の値を並べるのではなく、2 階層の命名規約に従って宣言します(zudo-doc の「ramps + semantic map」概念の移植)。テーマは引き続きプレーンな CSS テキストのままで、この規約は新しいファイル形式ではなく命名パターンとして存在します。
Tier 1 — パレット(--ztp-palette-{group}-{n})
生の色値を持てるのはこの層だけです。OKLCH で記述します。
base — グレースケールの背骨。8 段階(
0〜7)。0は常に「ページ背景側の端」(ライトテーマでは白に近く、ダークテーマでは黒に近い — ランプの向きは反転するが index の意味は変わらない)。最大の index が本文色(ink)側の端。わずかな色温度を帯びさせるのが推奨(彩度ゼロの純グレーは単調に見える)。accent — テーマの主調色(リンク・強調)。3 段階(
0〜2)、0が最も明るい。色相を固定し、明度をランプに沿って変える。accent2(任意)— 2 つ目の独立した色調(例: ホットなハイライト)。accent と同じ 3 段階規約。
Tier 2 — セマンティック
markdown.css / directives.css / _base.css(admonition のデフォルト由来分)が参照する既存のセマンティックトークン(--ztp-bg、--ztp-link など)は、必ず Tier 1 の --ztp-palette-* 変数、またはそれらの color-mix() を参照します。3 つ目の生の色値を追加してはいけません — 色が必要になったら、まず Tier 1 のランプに加えます。
.markdown-body[data-preview-theme="THEME_SLUG"],
.preview-pane[data-preview-theme="THEME_SLUG"] {
/* Tier 1 */
--ztp-palette-base-0: oklch(97% 0.005 85);
/* … base-1..7, accent-0..2 … */
/* Tier 2 */
--ztp-bg: var(--ztp-palette-base-0);
--ztp-link: var(--ztp-palette-accent-1);
}背景パターン
--ztp-bg-pattern / --ztp-bg-pattern-size は、markdown.css のテーマ付きスクロールサーフェスで一度だけ消費される Tier 2 トークンです:
.markdown-body[data-preview-theme],
.preview-pane[data-preview-theme] {
background-color: var(--ztp-bg);
background-image: var(--ztp-bg-pattern, none);
background-size: var(--ztp-bg-pattern-size, auto);
}これらのトークンを宣言しないテーマ(現行の 01〜10 を含む)は none / auto にフォールバックし、これまでと完全に同じ見た目でレンダリングされます。パターンを持つテーマは、パレットから color-mix() で導出した低コントラストのインクを使い(例: color-mix(in oklch, var(--ztp-palette-base-3) 35%, transparent))、--ztp-bg-pattern-size の px 値は他のすべての視覚 px と同じ golden rule に従って --display-scale に追従させます(calc(22px * var(--display-scale, 1)))。パターンは「紙」を identity とする一部のテーマ限定の演出で、常にごく低コントラストに留めます。
この grouped-token 規約は本ページで確立されるもので、既存 10 テーマの規約への再構築と背景パターンを持つ新規テーマの追加は後続 Wave(#3676 / #3677)が担当します。
data-preview-theme スコープキャリア
テーマトークンは data-preview-theme="<slug>" 属性を持つ要素に宣言され、CSS カスタムプロパティの継承で配下に届きます。
.preview-paneラッパに属性を付けると、.markdown-bodyと<Toc>サイドバーの両方がトークンを継承します(TOC の--ztp-toc-*解決に必要)。単独で
.markdown-bodyを描画する箇所(authoring help、inline command panel など、.preview-paneラッパを持たない surface)では、.markdown-body自体に属性を付けます。
アクティブなテーマは settings.preview.theme に結線済みです(preview-)。値が空、または存在しないカスタムテーマ ID を指している場合は DEFAULT_PREVIEW_THEME_SLUG("foundry")にフォールバックします。
テーマの追加方法(レシピ)
preview-themes/NN-<slug>.cssを新規作成する。.markdown-body[data-preview-theme="<slug>"] { … }ブロックに、上記の grouped-token 規約(Tier 1 パレット → Tier 2 セマンティック)に沿って Color / Type / TOC トークンを一通り宣言する。構造(Tier 1 のパレットブロック → Tier 2 のセマンティックブロック)は本ページ「Grouped-token 規約」のコード例を参照(01-foundry.cssを含む既存 10 テーマは、この規約への再構築が完了するまではフラットな値のままの旧スタイル実装)。「紙」を identity とするテーマなら、任意で
--ztp-bg-pattern/--ztp-bg-pattern-sizeをパレット由来のcolor-mix()で追加する。必要なら admonition セマンティック色を上書きする(しなければ
_base.cssのデフォルトが効く)。余白・行間・flow は書かない(
_base.cssの責務)。署名的な装飾(見出しの罫線、ゼブラ表など)が必要なら、.markdown-body[data-preview-theme="<slug>"] <selector>でそのテーマ限定のルールとして足す。新スラッグを
index.cssの import 列に追加する。すべての px 値は
calc(Npx * var(--display-scale, 1))で--display-scaleに追従させる(design-system の golden rule)。
リファレンス:Foundry(テーマ #1)
01-foundry.css は GitHub 風のニュートラルなライトテーマで、従来(テーマ機構導入前)のプレビューと視覚的に同一になるように作られた基準実装です。ページ #ffffff、本文/見出し #1f2328、リンク/アクセント #0969da、コード背景 #f6f8fa、罫線 #d1d9e0。署名的装飾は H1/H2 のヘアライン下罫線、ゼブラ表、角丸のインラインコードチップ。TOC はグレーのラベルとアクティブ時の #0969da + 2px 左アクセントバー。
Custom themes (Theme Creator)
The Theme Creator dialog (launched from the command palette as Open Theme Creator) lets users author, save, and select their own preview themes at runtime, without any code change. See the user guide at Theme Creator for the UI walkthrough.
Storage model
Custom themes are persisted as JSON files in the global per-app config directory:
~/.config/zudotext/<appname>/themes/<theme-id>.jsonThis mirrors the inline-command skills loader (~/.config/zudotext/<appname>/skills/): one file per item, the directory is shared across all workspaces for a given app, and the frontend loads all items at startup.
Each file contains:
{
"id": "custom-a1b2c3d4e5f6",
"name": "My custom theme",
"appearance": "light",
"css": "/* scoped CSS */",
"createdAt": "2025-06-16T00:00:00.000Z",
"updatedAt": "2025-06-16T00:00:00.000Z"
}Theme ids use the custom- prefix followed by a 12-character hex string. The prefix is the canonical way to distinguish user themes from built-in slugs throughout the codebase (isCustomThemeId() in @takazudo/preview-themes).
The Rust backend exposes four commands for CRUD (list_user_themes, read_user_theme, write_user_theme, delete_user_theme) implemented in tauri-, with write operations using an atomic write-to-temp-then-rename strategy to prevent partial writes.
CSS scoping via scopePreviewThemeCss
When the user saves a theme, the CSS is scoped to that theme's id before being written to disk. scopePreviewThemeCss(css, id) (exported from @takazudo/preview-themes) rewrites every data-preview-theme="…" attribute value in the CSS string to the target id:
// packages/preview-themes/src/index.ts
export function scopePreviewThemeCss(css: string, id: string): string {
return css
.replace(/data-preview-theme="[^"]*"/g, `data-preview-theme="${id}"`)
.replace(/data-preview-theme='[^']*'/g, `data-preview-theme='${id}'`);
}The seed CSS shown in the editor when starting from a built-in preset is already fully scoped (all selectors include a data-preview-theme="<slug>" predicate). The rewriter converts those predicates to the new theme's id on save.
Accepted limitation — bare selectors are not sanitized. scopePreviewThemeCss only rewrites existing data-preview-theme attribute values; it does not auto-scope bare selectors the user may write (e.g., .markdown-body h1 { color: red; }). A bare selector in saved custom CSS leaks into the global <style> element and applies across all preview surfaces regardless of which theme is active. Custom CSS is intentionally not sanitized — this is a local single-user app where the user authors the CSS themselves. The Help dialog in the Theme Creator warns about this.
Runtime injection via CustomThemesStyleInjector
At app startup, useUserThemesLoader reads all theme JSON files from the backend, parses and validates their shape, and populates the user-themes-store (a module-level observable store at tauri-).
The CustomThemesStyleInjector component (mounted once in app.tsx) manages a single global <style id="ztp-custom-themes"> element in document.head. On mount and on every store change, it:
Iterates all loaded user themes.
Calls
scopePreviewThemeCss(theme.css, theme.id)on each.Joins the results and writes them to the element's
textContent.
Only ids that pass isCustomThemeId() are emitted — built-in slug overrides from user-authored CSS are silently skipped, preventing the custom style block from shadowing bundled theme files.
The <style id="ztp-custom-themes"> element is updated with useEffect (not useLayoutEffect) to avoid blocking browser paint on theme list changes.
Integration with the theme selector
Custom themes are made available everywhere built-in themes appear:
Preview-pane toolbar picker — reads built-in slugs plus
useUserThemes()from the store.Settings → Preview → Theme dropdown — same source.
Theme Creator pulldown — groups built-ins and user themes separately, with a "Create new theme" entry at the bottom.
Next/prev-theme cycling — the ordered list interleaves built-ins and user themes by display order.
The settings.preview.theme field stores either a built-in slug ("foundry") or a custom id ("custom-a1b2c3d4e5f6"). The preview pane writes this value as the data-preview-theme attribute; the CSS cascade resolves it against either the bundled theme files or the injected custom block transparently.