Color mode

Theme System

Riebeckite Themes are the presentation layer. They change appearance through shared design contracts rather than replacing application or plugin functionality.

Scope before styling

Theme code may change tokens, stylesheet rules, and theme-owned data-* attributes. It cannot change content meaning or application structure. Put interactive behavior in a plugin or application, and put a visual adjustment in a theme. This separation lets a site exchange themes without changing its routes, manifest, graph, or client behavior.

Minimal theme

ts
function minimalTheme() {
  return defineTheme({
    name: "minimal",
    styles: [{ moduleSpecifier: "@riebeckite/theme-minimal/style.css" }],
  });
}

On the consuming side, hand it to the config:

ts
export default defineConfig({
  theme: minimalTheme(),
});

Contract

A theme can provide identity, factory options, stylesheet module specifiers, common configuration, design tokens, user CSS, and safe data-* attributes.

Common presets include:

ts
type ThemeColorMode = "light" | "dark" | "system";
type ThemeTypographyPreset = "system" | "serif" | "sans";
type ThemeArticleLayoutPreset = "article" | "sidebar" | "full-width";

Typography

A typography preset is reflected in the semantic font tokens for body and heading text:

ts
type ThemeTypographyPreset = "system" | "serif" | "sans";

Article Layout

A theme defines the presentation of each layout preset; it never replaces routes or the component tree:

ts
type ThemeArticleLayoutPreset = "article" | "sidebar" | "full-width";

Design tokens

ThemeDesignTokens groups semantic values for colors, typography, and layout. Themes expose these through shared --rb-* CSS custom properties.

Color

paper, ink, muted, accent, border, borderStrong, surface, surfaceHover, overlay, danger, success, and codeBackground.

Typography

bodyFont, headingFont, and monoFont.

Layout

pageMaxWidth, articleMaxWidth, sidebarWidth, and contentGap.

css
@layer base {
  /* <name> is the theme's identity name, for example "minimal". */
  :is(:root, .rb-theme-root)[data-theme-name="<name>"] {
    --rb-color-paper: #fafafa;
    --rb-color-ink: #202020;
    --rb-color-accent: #555;
    --rb-font-body: system-ui, sans-serif;
    --rb-layout-article-max: 48rem;
  }
}

Components and plugins should consume semantic tokens instead of hard-coding a specific theme palette:

css
/* good */
.rr-example {
  color: var(--rb-color-ink);
  background: var(--rb-color-surface);
}
 
/* avoid */
.rr-example {
  color: #171717;
  background: #f6efe2;
}

Plugin-specific semantics remain owned by the plugin and may fall back to --rb-* tokens.

Theme root selector

Built-in themes do not target the bare :root selector. Each theme scopes its rules to a theme root so the same stylesheet can style the real document and an embedded preview:

css
:is(:root, .rb-theme-root)[data-theme-name="<name>"]

<name> is the theme's identity name: riebeckite for the default theme, otherwise minimal, gruvbox, sakura, tokyonight, or rerurate.

  • On a real site the application sets data-theme-name on <html>, so the :root branch matches the document root.
  • In a preview (for example a theme gallery), the same stylesheet styles any element that carries class="rb-theme-root" data-theme-name="<name>". Several themes can therefore render side by side in one document.

Every selector a theme declares carries the same prefix:

css
/* light */
:is(:root, .rb-theme-root)[data-theme-name="<name>"] { /* ... */ }
 
/* dark */
:is(:root, .rb-theme-root)[data-theme-name="<name>"][data-theme="dark"] {
  /* ... */
}
 
/* system */
@media (prefers-color-scheme: dark) {
  :is(:root, .rb-theme-root)[data-theme-name="<name>"]:not([data-theme]) {
    /* ... */
  }
}
 
/* typography preset */
:is(:root, .rb-theme-root)[data-theme-name="<name>"][data-typography="serif"] {
  /* ... */
}
 
/* theme option */
:is(:root, .rb-theme-root)[data-theme-name="<name>"][data-tokyonight-neon="on"] {
  /* ... */
}

Element and pseudo-element rules use the same prefix so they stay inside the preview container:

css
:is(:root, .rb-theme-root)[data-theme-name="<name>"] :focus-visible { /* ... */ }
:is(:root, .rb-theme-root)[data-theme-name="<name>"] ::selection { /* ... */ }
:is(:root, .rb-theme-root)[data-theme-name="<name>"] * { /* ... */ }

The framework emits data-theme-name; a preview container only needs the .rb-theme-root hook and the matching name.

Styles

Theme styles are bundler-resolved module specifiers; this is not a contract for copying filesystem paths into the application:

ts
styles: [
  { moduleSpecifier: "@riebeckite/theme-example/style.css" },
]

Attributes

Theme-specific options can reach CSS through safe data-* attributes:

ts
return defineTheme({
  name: "newspaper",
  attributes: {
    "data-newspaper-density": "compact",
  },
});

Do not turn class, style, id, or lang into settable theme attributes, and keep the framework-owned attribute namespace separate from theme-specific ones.

Theme Root and themeRootAttributes

The framework provides ThemeRoot, a UI primitive that handles setting theme attributes on the <html> element.

tsx
import { ThemeRoot } from "@riebeckite/honox/ui";
 
<ThemeRoot
  theme={config.theme}
  lang={c.get("htmlLanguage") ?? config.site.locale}
>
  {children}
</ThemeRoot>

ThemeRoot renders the <html> element with the following attributes:

html
<html
  lang="en"
  data-theme-name="minimal"
  data-theme="dark"
  data-typography="system"
  data-article-layout="article"
>

The framework uses themeRootAttributes(theme) to emit the theme's own attributes plus reserved attributes:

  • data-theme: Color Mode state ("light" / "dark" / omitted for "system")
  • data-theme-name: Theme identity name
  • data-typography: Typography preset value
  • data-article-layout: Article layout preset value

To add your own <html> attributes, use the themeRootAttributes helper directly instead of ThemeRoot:

tsx
import { themeRootAttributes } from "@riebeckite/honox/ui";
 
<html
  lang={c.get("htmlLanguage") ?? config.site.locale}
  {...themeRootAttributes(config.theme)}
  data-custom-attr="..."
>
  ...
</html>

However, the framework-reserved data-theme, data-theme-name, data-typography, and data-article-layout cannot be overwritten by the theme's attributes.

If a plugin or theme previously implemented its own themeAttributes(), consider migrating to the framework-provided ThemeRoot / themeRootAttributes(). This clarifies the separation between framework-owned and theme-specific attribute namespaces.

Theme factory options

Resolve theme-specific options inside the theme package:

ts
type NewspaperOptions = {
  density?: "compact" | "comfortable";
};
 
export function newspaperTheme(options: NewspaperOptions = {}) {
  return defineTheme({
    name: "newspaper",
    options,
    attributes: {
      "data-newspaper-density": options.density ?? "comfortable",
    },
    styles: [
      { moduleSpecifier: "@riebeckite/theme-newspaper/style.css" },
    ],
  });
}

Keep theme-specific concepts inside the theme package rather than expanding Core ThemeConfig.

Color mode at runtime

Themes derive their palette from three CSS states:

  • :is(:root, .rb-theme-root)[data-theme-name="<name>"] — light
  • :is(:root, .rb-theme-root)[data-theme-name="<name>"][data-theme="dark"] — dark
  • @media (prefers-color-scheme: dark) { :is(:root, .rb-theme-root)[data-theme-name="<name>"]:not([data-theme]) } — follow the OS ("system")

The server writes data-theme on <html> unless the theme's colorMode is "system", in which case the attribute is omitted and the media query picks the palette. Runtime switching follows the same contract: set document.documentElement.dataset.theme to "light" or "dark", or remove the attribute for "system". An empty attribute is not equivalent — an empty data-theme still matches [data-theme] selectors and defeats the media query.

Only these two mechanisms produce a dark palette: the explicit [data-theme="dark"] on the theme root, and the prefers-color-scheme: dark media query while data-theme is absent. The framework never adds a .dark class, so a .dark selector is outside the contract. Both states resolve the same --rb-* semantic tokens, so a component that consumes only those tokens renders the same under explicit and system dark and does not need to detect which one is active.

@riebeckite/plugin-color-mode is the reference implementation of this contract: ColorModeScript (a before-paint inline script), ColorModeToggle (a control), and an initColorMode client entry that persists the choice in localStorage. See its package README.

Stable CSS hooks

Themes target documented stable hooks instead of internal markup. Riebeckite uses two class namespaces:

  • rb-* — framework structural hooks and semantic design tokens. Structural hooks include .rb-theme-root (the theme root container), .rb-site, .rb-article, .rb-article-layout, .rb-article-header, .rb-article-body, .rb-article-content, .rb-article-meta, .rb-article-footer, and .rb-sidebar. Navigation uses .rb-site-header, .rb-nav, .rb-nav__list, .rb-nav__item, .rb-nav__link, .rb-nav__link--active, .rb-nav__children, .rb-nav__mobile, .rb-nav__toggle, and .rb-site-footer.
  • rr-<feature> — the root hook a plugin or feature emits on the outermost element it renders, for example .rr-search, .rr-callout, .rr-table-of-contents, .rr-backlinks, .rr-local-graph, .rr-code, .rr-code-tabs, .rr-lightbox, .rr-excalidraw, .rr-mermaid, .rr-query, .rr-cardlink, .rr-diff-history, .rr-attachment, .rr-media, .rr-recent-posts, and .rr-garden-explorer.

The root hook is the supported styling surface: a theme restyles a feature by targeting .rr-<feature> and its documented descendants. BEM element (__...) and modifier (--...) classes remain internal implementation details unless a plugin documents them, and generic helper classes such as .sr-only are not plugin hooks. Plugins keep their historical classes for backward compatibility, so .rr-<feature> may appear alongside a legacy class on the same element; a theme should target the rr-* hook.

Plugins may also expose plugin-owned custom properties under --rr-* and fall back to the semantic --rb-* tokens. See Plugin System for the plugin-side rule.

Character layer

A theme is not limited to tokens. Within the theme boundary it may style the stable hooks directly to give a site a visual character.

  • Put token definitions inside @layer base; put visual character rules unlayered. The application's structural CSS and plugin CSS are unlayered, so unlayered theme rules win over them without !important. Never use !important.
  • Target only stable hooks: .rb-site, .rb-article, .rb-article-layout, .rb-article-header, .rb-article-body, .rb-article-content, .rb-article-meta, .rb-article-footer, .rb-sidebar, and the rr-* plugin roots listed under Stable CSS hooks. Do not invent new rb-* / rr-* class names; .rr-* BEM parts are internal.
  • .rb-article-content carries the Markdown semantic baseline (list markers and indentation, headings, paragraph and block spacing, tables, figures, definitions, inline code, preformatted blocks). That baseline is layered, so a theme expresses appearance through --rb-* tokens and unlayered character rules rather than re-declaring the structure. .rb-article-body is the shell that contains it; plugin components keep their own headings wherever placed. The public ArticleBody primitive is what emits the .rb-article-content wrapper.
  • A theme may ship self-hosted webfonts (Latin subsets) inside its package under styles/fonts/, reference them with relative url(), and include the font license file. Japanese and other CJK text should fall back to system font stacks instead of shipping large font files.

Character rules are still presentation-only: they must not change content, structure, or behavior.

Cascade

text
framework structural CSS
→ base / app structural CSS
→ Plugin default CSS
→ Theme CSS
→ config token inline style
→ userCss

The order is stable, not incidental. @riebeckite/honox generates .riebeckite/framework-styles.css (framework structural CSS), .riebeckite/plugin-styles.css (plugin styles in resolved plugin order), and .riebeckite/theme-styles.css (theme styles). A site imports the framework stylesheet before the plugin stylesheet, and the plugin stylesheet before the theme stylesheet, so the theme CSS always wins the plugin/theme cascade while preserving userCss as the final user override. Do not reorder those imports, and do not edit the generated files by hand; each carries a header comment stating its position in the cascade. The cascade normally does not rely on !important.

Theme vs Plugin

Themes own appearance, semantic tokens, stable-hook styling, and presentation attributes. Plugins own transformations, renderers, client behavior, endpoints, diagnostics, and SEO extensions.

Themes must not replace components, inject JSX, add routes, add/remove plugins, execute client scripts, transform the DOM, register islands, access the filesystem, or use ContentManager.

Do not create a plugin merely to change appearance, and do not extend a theme to add functionality.

Suggested package layout

text
packages/themes/example/
├─ index.ts
├─ package.json
├─ style.css
├─ README_ja.md
└─ README.md

Distributing a Theme outside this repository

An external Theme package depends only on @riebeckite/core, uses defineTheme, and exposes its stylesheet through a ./style.css export. Do not reference monorepo paths. See Public packages and import paths for the supported package surface and current constraints.

Following the shared contract keeps themes replaceable without changing application logic.

Site-local themes

A theme can also live in the site. Compose an existing theme or define one directly with defineTheme, then set it as theme:

ts
// site/extensions/local-theme.ts
import { defineTheme } from "@riebeckite/core";
import { defaultTheme } from "@riebeckite/theme-default";
 
export function localTheme() {
  const base = defaultTheme({ colorMode: "dark" });
  return defineTheme({
    name: "site-local",
    styles: [
      ...(base.styles ?? []),
      { moduleSpecifier: "/extensions/theme.css" },
    ],
    config: { ...base.config, tokens: { color: { accent: "#c2410c" } } },
    attributes: { "data-site-local": "on" },
  });
}

A site-local theme is resolved, sanitized, and applied through the same resolveThemeConfig path as a packaged theme, including its own stylesheet and data-* attributes.

Following the shared contract keeps themes replaceable without changing application logic. A theme-specific option is meaningful only for that theme and never leaks into Core or another theme.

History

1 changesCollapseExpand
1 + # Theme System
2 +
3 + Riebeckite Themes are the presentation layer. They change appearance
4 + through shared design contracts rather than replacing application or
5 + plugin functionality.
6 +
7 + ## Scope before styling
8 +
9 + Theme code may change tokens, stylesheet rules, and theme-owned `data-*`
10 + attributes. It cannot change content meaning or application structure. Put
11 + interactive behavior in a plugin or application, and put a visual adjustment
12 + in a theme. This separation lets a site exchange themes without changing its
13 + routes, manifest, graph, or client behavior.
14 +
15 + ## Minimal theme
16 +
17 + ``` ts
18 + function minimalTheme() {
19 + return defineTheme({
20 + name: "minimal",
21 + styles: [{ moduleSpecifier: "@riebeckite/theme-minimal/style.css" }],
22 + });
23 + }
24 + ```
25 +
26 + On the consuming side, hand it to the config:
27 +
28 + ``` ts
29 + export default defineConfig({
30 + theme: minimalTheme(),
31 + });
32 + ```
33 +
34 + ## Contract
35 +
36 + A theme can provide identity, factory options, stylesheet module
37 + specifiers, common configuration, design tokens, user CSS, and safe
38 + `data-*` attributes.
39 +
40 + Common presets include:
41 +
42 + ``` ts
43 + type ThemeColorMode = "light" | "dark" | "system";
44 + type ThemeTypographyPreset = "system" | "serif" | "sans";
45 + type ThemeArticleLayoutPreset = "article" | "sidebar" | "full-width";
46 + ```
47 +
48 + ## Typography
49 +
50 + A typography preset is reflected in the semantic font tokens for body
51 + and heading text:
52 +
53 + ``` ts
54 + type ThemeTypographyPreset = "system" | "serif" | "sans";
55 + ```
56 +
57 + ## Article Layout
58 +
59 + A theme defines the presentation of each layout preset; it never replaces
60 + routes or the component tree:
61 +
62 + ``` ts
63 + type ThemeArticleLayoutPreset = "article" | "sidebar" | "full-width";
64 + ```
65 +
66 + ## Design tokens
67 +
68 + `ThemeDesignTokens` groups semantic values for colors, typography, and
69 + layout. Themes expose these through shared `--rb-*` CSS custom
70 + properties.
71 +
72 + ### Color
73 +
74 + `paper`, `ink`, `muted`, `accent`, `border`, `borderStrong`, `surface`,
75 + `surfaceHover`, `overlay`, `danger`, `success`, and `codeBackground`.
76 +
77 + ### Typography
78 +
79 + `bodyFont`, `headingFont`, and `monoFont`.
80 +
81 + ### Layout
82 +
83 + `pageMaxWidth`, `articleMaxWidth`, `sidebarWidth`, and `contentGap`.
84 +
85 + ``` css
86 + @layer base {
87 + /* <name> is the theme's identity name, for example "minimal". */
88 + :is(:root, .rb-theme-root)[data-theme-name="<name>"] {
89 + --rb-color-paper: #fafafa;
90 + --rb-color-ink: #202020;
91 + --rb-color-accent: #555;
92 + --rb-font-body: system-ui, sans-serif;
93 + --rb-layout-article-max: 48rem;
94 + }
95 + }
96 + ```
97 +
98 + Components and plugins should consume semantic tokens instead of
99 + hard-coding a specific theme palette:
100 +
101 + ``` css
102 + /* good */
103 + .rr-example {
104 + color: var(--rb-color-ink);
105 + background: var(--rb-color-surface);
106 + }
107 +
108 + /* avoid */
109 + .rr-example {
110 + color: #171717;
111 + background: #f6efe2;
112 + }
113 + ```
114 +
115 + Plugin-specific semantics remain owned by the plugin and may fall back to
116 + `--rb-*` tokens.
117 +
118 + ## Theme root selector
119 +
120 + Built-in themes do not target the bare `:root` selector. Each theme scopes
121 + its rules to a *theme root* so the same stylesheet can style the real
122 + document and an embedded preview:
123 +
124 + ``` css
125 + :is(:root, .rb-theme-root)[data-theme-name="<name>"]
126 + ```
127 +
128 + `<name>` is the theme's identity name: `riebeckite` for the default theme,
129 + otherwise `minimal`, `gruvbox`, `sakura`, `tokyonight`, or `rerurate`.
130 +
131 + - On a real site the application sets `data-theme-name` on `<html>`, so the
132 + `:root` branch matches the document root.
133 + - In a preview (for example a theme gallery), the same stylesheet styles any
134 + element that carries `class="rb-theme-root" data-theme-name="<name>"`.
135 + Several themes can therefore render side by side in one document.
136 +
137 + Every selector a theme declares carries the same prefix:
138 +
139 + ``` css
140 + /* light */
141 + :is(:root, .rb-theme-root)[data-theme-name="<name>"] { /* ... */ }
142 +
143 + /* dark */
144 + :is(:root, .rb-theme-root)[data-theme-name="<name>"][data-theme="dark"] {
145 + /* ... */
146 + }
147 +
148 + /* system */
149 + @media (prefers-color-scheme: dark) {
150 + :is(:root, .rb-theme-root)[data-theme-name="<name>"]:not([data-theme]) {
151 + /* ... */
152 + }
153 + }
154 +
155 + /* typography preset */
156 + :is(:root, .rb-theme-root)[data-theme-name="<name>"][data-typography="serif"] {
157 + /* ... */
158 + }
159 +
160 + /* theme option */
161 + :is(:root, .rb-theme-root)[data-theme-name="<name>"][data-tokyonight-neon="on"] {
162 + /* ... */
163 + }
164 + ```
165 +
166 + Element and pseudo-element rules use the same prefix so they stay inside the
167 + preview container:
168 +
169 + ``` css
170 + :is(:root, .rb-theme-root)[data-theme-name="<name>"] :focus-visible { /* ... */ }
171 + :is(:root, .rb-theme-root)[data-theme-name="<name>"] ::selection { /* ... */ }
172 + :is(:root, .rb-theme-root)[data-theme-name="<name>"] * { /* ... */ }
173 + ```
174 +
175 + The framework emits `data-theme-name`; a preview container only needs the
176 + `.rb-theme-root` hook and the matching name.
177 +
178 + ## Styles
179 +
180 + Theme styles are bundler-resolved module specifiers; this is not a
181 + contract for copying filesystem paths into the application:
182 +
183 + ``` ts
184 + styles: [
185 + { moduleSpecifier: "@riebeckite/theme-example/style.css" },
186 + ]
187 + ```
188 +
189 + ## Attributes
190 +
191 + Theme-specific options can reach CSS through safe `data-*` attributes:
192 +
193 + ``` ts
194 + return defineTheme({
195 + name: "newspaper",
196 + attributes: {
197 + "data-newspaper-density": "compact",
198 + },
199 + });
200 + ```
201 +
202 + Do not turn `class`, `style`, `id`, or `lang` into settable theme
203 + attributes, and keep the framework-owned attribute namespace separate
204 + from theme-specific ones.
205 +
206 + ## Theme Root and themeRootAttributes
207 +
208 + The framework provides `ThemeRoot`, a UI primitive that handles setting theme
209 + attributes on the `<html>` element.
210 +
211 + ```tsx
212 + import { ThemeRoot } from "@riebeckite/honox/ui";
213 +
214 + <ThemeRoot
215 + theme={config.theme}
216 + lang={c.get("htmlLanguage") ?? config.site.locale}
217 + >
218 + {children}
219 + </ThemeRoot>
220 + ```
221 +
222 + `ThemeRoot` renders the `<html>` element with the following attributes:
223 +
224 + ```html
225 + <html
226 + lang="en"
227 + data-theme-name="minimal"
228 + data-theme="dark"
229 + data-typography="system"
230 + data-article-layout="article"
231 + >
232 + ```
233 +
234 + The framework uses `themeRootAttributes(theme)` to emit the theme's own
235 + `attributes` plus reserved attributes:
236 +
237 + - `data-theme`: Color Mode state (`"light"` / `"dark"` / omitted for "system")
238 + - `data-theme-name`: Theme identity name
239 + - `data-typography`: Typography preset value
240 + - `data-article-layout`: Article layout preset value
241 +
242 + To add your own `<html>` attributes, use the `themeRootAttributes` helper
243 + directly instead of `ThemeRoot`:
244 +
245 + ```tsx
246 + import { themeRootAttributes } from "@riebeckite/honox/ui";
247 +
248 + <html
249 + lang={c.get("htmlLanguage") ?? config.site.locale}
250 + {...themeRootAttributes(config.theme)}
251 + data-custom-attr="..."
252 + >
253 + ...
254 + </html>
255 + ```
256 +
257 + However, the framework-reserved `data-theme`, `data-theme-name`,
258 + `data-typography`, and `data-article-layout` cannot be overwritten by the theme's
259 + `attributes`.
260 +
261 + If a plugin or theme previously implemented its own `themeAttributes()`, consider
262 + migrating to the framework-provided `ThemeRoot` / `themeRootAttributes()`. This
263 + clarifies the separation between framework-owned and theme-specific
264 + attribute namespaces.
265 +
266 + ## Theme factory options
267 +
268 + Resolve theme-specific options inside the theme package:
269 +
270 + ``` ts
271 + type NewspaperOptions = {
272 + density?: "compact" | "comfortable";
273 + };
274 +
275 + export function newspaperTheme(options: NewspaperOptions = {}) {
276 + return defineTheme({
277 + name: "newspaper",
278 + options,
279 + attributes: {
280 + "data-newspaper-density": options.density ?? "comfortable",
281 + },
282 + styles: [
283 + { moduleSpecifier: "@riebeckite/theme-newspaper/style.css" },
284 + ],
285 + });
286 + }
287 + ```
288 +
289 + Keep theme-specific concepts inside the theme package rather than
290 + expanding Core `ThemeConfig`.
291 +
292 + ## Color mode at runtime
293 +
294 + Themes derive their palette from three CSS states:
295 +
296 + - `:is(:root, .rb-theme-root)[data-theme-name="<name>"]` — light
297 + - `:is(:root, .rb-theme-root)[data-theme-name="<name>"][data-theme="dark"]` — dark
298 + - `@media (prefers-color-scheme: dark) { :is(:root, .rb-theme-root)[data-theme-name="<name>"]:not([data-theme]) }` — follow the OS ("system")
299 +
300 + The server writes `data-theme` on `<html>` unless the theme's `colorMode` is
301 + `"system"`, in which case the attribute is omitted and the media query picks
302 + the palette. Runtime switching follows the same contract: set
303 + `document.documentElement.dataset.theme` to `"light"` or `"dark"`, or **remove**
304 + the attribute for `"system"`. An empty attribute is not equivalent — an empty
305 + `data-theme` still matches `[data-theme]` selectors and defeats the media
306 + query.
307 +
308 + Only these two mechanisms produce a dark palette: the explicit
309 + `[data-theme="dark"]` on the theme root, and the `prefers-color-scheme: dark`
310 + media query while `data-theme` is absent. The framework never adds a `.dark`
311 + class, so a `.dark` selector is outside the contract. Both states resolve the
312 + same `--rb-*` semantic tokens, so a component that consumes only those tokens
313 + renders the same under explicit and system dark and does not need to detect
314 + which one is active.
315 +
316 + `@riebeckite/plugin-color-mode` is the reference implementation of this
317 + contract: `ColorModeScript` (a before-paint inline script), `ColorModeToggle`
318 + (a control), and an `initColorMode` client entry that persists the choice in
319 + `localStorage`. See its
320 + package README.
321 +
322 + ## Stable CSS hooks
323 +
324 + Themes target documented stable hooks instead of internal markup. Riebeckite
325 + uses two class namespaces:
326 +
327 + - `rb-*` — framework structural hooks and semantic design tokens. Structural
328 + hooks include `.rb-theme-root` (the theme root container), `.rb-site`,
329 + `.rb-article`, `.rb-article-layout`, `.rb-article-header`,
330 + `.rb-article-body`, `.rb-article-content`, `.rb-article-meta`,
331 + `.rb-article-footer`, and `.rb-sidebar`. Navigation uses `.rb-site-header`,
332 + `.rb-nav`,
333 + `.rb-nav__list`, `.rb-nav__item`, `.rb-nav__link`,
334 + `.rb-nav__link--active`, `.rb-nav__children`, `.rb-nav__mobile`,
335 + `.rb-nav__toggle`, and `.rb-site-footer`.
336 + - `rr-<feature>` — the root hook a plugin or feature emits on the outermost
337 + element it renders, for example `.rr-search`, `.rr-callout`,
338 + `.rr-table-of-contents`, `.rr-backlinks`, `.rr-local-graph`, `.rr-code`,
339 + `.rr-code-tabs`, `.rr-lightbox`, `.rr-excalidraw`, `.rr-mermaid`,
340 + `.rr-query`, `.rr-cardlink`, `.rr-diff-history`, `.rr-attachment`,
341 + `.rr-media`, `.rr-recent-posts`, and `.rr-garden-explorer`.
342 +
343 + The root hook is the supported styling surface: a theme restyles a feature by
344 + targeting `.rr-<feature>` and its documented descendants. BEM element
345 + (`__...`) and modifier (`--...`) classes remain internal implementation
346 + details unless a plugin documents them, and generic helper classes such as
347 + `.sr-only` are not plugin hooks. Plugins keep their historical classes for
348 + backward compatibility, so `.rr-<feature>` may appear alongside a legacy class
349 + on the same element; a theme should target the `rr-*` hook.
350 +
351 + Plugins may also expose plugin-owned custom properties under `--rr-*` and
352 + fall back to the semantic `--rb-*` tokens. See [Plugin System](./plugin-api.en.md#css-hooks)
353 + for the plugin-side rule.
354 +
355 + ## Character layer
356 +
357 + A theme is not limited to tokens. Within the theme boundary it may style the
358 + stable hooks directly to give a site a visual character.
359 +
360 + - Put token definitions inside `@layer base`; put visual character rules
361 + **unlayered**. The application's structural CSS and plugin CSS are
362 + unlayered, so unlayered theme rules win over them without `!important`.
363 + Never use `!important`.
364 + - Target only stable hooks: `.rb-site`, `.rb-article`, `.rb-article-layout`,
365 + `.rb-article-header`, `.rb-article-body`, `.rb-article-content`,
366 + `.rb-article-meta`, `.rb-article-footer`, `.rb-sidebar`, and the `rr-*`
367 + plugin roots listed under [Stable CSS hooks](#stable-css-hooks). Do not
368 + invent new `rb-*` / `rr-*` class names; `.rr-*` BEM parts are internal.
369 + - `.rb-article-content` carries the Markdown semantic baseline (list markers and
370 + indentation, headings, paragraph and block spacing, tables, figures,
371 + definitions, inline code, preformatted blocks). That baseline is layered, so
372 + a theme expresses appearance through `--rb-*` tokens and unlayered character
373 + rules rather than re-declaring the structure. `.rb-article-body` is the shell
374 + that contains it; plugin components keep their own headings wherever placed.
375 + The public `ArticleBody` primitive is what emits the `.rb-article-content`
376 + wrapper.
377 + - A theme may ship self-hosted webfonts (Latin subsets) inside its package
378 + under `styles/fonts/`, reference them with relative `url()`, and include the
379 + font license file. Japanese and other CJK text should fall back to system
380 + font stacks instead of shipping large font files.
381 +
382 + Character rules are still presentation-only: they must not change content,
383 + structure, or behavior.
384 +
385 + ## Cascade
386 +
387 + ``` text
388 + framework structural CSS
389 + → base / app structural CSS
390 + → Plugin default CSS
391 + → Theme CSS
392 + → config token inline style
393 + → userCss
394 + ```
395 +
396 + The order is stable, not incidental. `@riebeckite/honox` generates
397 + `.riebeckite/framework-styles.css` (framework structural CSS),
398 + `.riebeckite/plugin-styles.css` (plugin styles in resolved plugin order), and
399 + `.riebeckite/theme-styles.css` (theme styles). A site imports the framework
400 + stylesheet before the plugin stylesheet, and the plugin stylesheet before the
401 + theme stylesheet, so the theme CSS always wins the plugin/theme cascade while
402 + preserving `userCss` as the final user override.
403 + Do not reorder those imports, and do not edit the generated files by hand;
404 + each carries a header comment stating its position in the cascade. The
405 + cascade normally does not rely on `!important`.
406 +
407 + ## Theme vs Plugin
408 +
409 + Themes own appearance, semantic tokens, stable-hook styling, and
410 + presentation attributes. Plugins own transformations, renderers, client
411 + behavior, endpoints, diagnostics, and SEO extensions.
412 +
413 + Themes must not replace components, inject JSX, add routes, add/remove
414 + plugins, execute client scripts, transform the DOM, register islands,
415 + access the filesystem, or use ContentManager.
416 +
417 + Do not create a plugin merely to change appearance, and do not extend a
418 + theme to add functionality.
419 +
420 + ## Suggested package layout
421 +
422 + ``` text
423 + packages/themes/example/
424 + ├─ index.ts
425 + ├─ package.json
426 + ├─ style.css
427 + ├─ README_ja.md
428 + └─ README.md
429 + ```
430 +
431 + ## Distributing a Theme outside this repository
432 +
433 + An external Theme package depends only on `@riebeckite/core`, uses `defineTheme`,
434 + and exposes its stylesheet through a `./style.css` export. Do not reference
435 + monorepo paths. See
436 + [Public packages and import paths](./README.en.md#public-packages-and-import-paths)
437 + for the supported package surface and current constraints.
438 +
439 + Following the shared contract keeps themes replaceable without changing
440 + application logic.
441 +
442 + ### Site-local themes
443 +
444 + A theme can also live in the site. Compose an existing theme or define one
445 + directly with `defineTheme`, then set it as `theme`:
446 +
447 + ``` ts
448 + // site/extensions/local-theme.ts
449 + import { defineTheme } from "@riebeckite/core";
450 + import { defaultTheme } from "@riebeckite/theme-default";
451 +
452 + export function localTheme() {
453 + const base = defaultTheme({ colorMode: "dark" });
454 + return defineTheme({
455 + name: "site-local",
456 + styles: [
457 + ...(base.styles ?? []),
458 + { moduleSpecifier: "/extensions/theme.css" },
459 + ],
460 + config: { ...base.config, tokens: { color: { accent: "#c2410c" } } },
461 + attributes: { "data-site-local": "on" },
462 + });
463 + }
464 + ```
465 +
466 + A site-local theme is resolved, sanitized, and applied through the same
467 + `resolveThemeConfig` path as a packaged theme, including its own stylesheet and
468 + `data-*` attributes.
469 +
470 + Following the shared contract keeps themes replaceable without changing
471 + application logic. A theme-specific option is meaningful only for that
472 + theme and never leaks into Core or another theme.
473 +
474 + ## Related
475 +
476 + - [Architecture](../framework/architecture.en.md)
477 + - [Plugin System](./plugin-api.en.md)
478 + - [Configuration](./configuration.en.md)
479 + - [Framework Reference](./README.en.md)
480 +
481 +