Color mode

Themes in Depth

Your First Theme is a short walkthrough that gets a theme running. This page is its "in depth" companion: it collects everything you refer to while building a theme — options, tokens, hooks, the CSS cascade, and packaging.

If you are new, read your first theme first, and use this page when you want more detail. For the complete API surface, see Theme API.

1. What a theme can and cannot do

A theme is the presentation layer. It changes tokens, stylesheet rules, and theme-specific data-* attributes. It does not change content semantics or application structure.

Can Cannot
Override or add tokens Component replacement
Define stylesheet rules JSX injection
Declare theme-specific data-* attributes Route additions
Declare color mode / typography / layout presets Adding or removing plugins
Provide the final override through userCss Client script execution
Style stable hooks DOM transformation, island registration, filesystem access, ContentManager access

Keep the boundary: do not write a plugin just to change appearance, and do not extend a theme to add features. Features go in plugins, plain appearance in themes, and site-specific routes in the app.

2. The defineTheme contract

defineTheme is imported from @riebeckite/core. A theme's main contract has five areas.

Area Contents
Identity name
Factory options options
Styles styles[].moduleSpecifier
Common config colorMode, typography, articleLayout, tokens, userCss
Attributes safe data-* attributes
ts
import { defineTheme } from "@riebeckite/core";
 
export function exampleTheme() {
  return defineTheme({
    name: "example",
    options: { /* theme-specific options */ },
    styles: [
      { moduleSpecifier: "@riebeckite/theme-example/style.css" },
    ],
    attributes: { "data-example-flag": "on" },
  });
}

styles[].moduleSpecifier is a module specifier resolved by the host bundler. It is not a contract for copying filesystem paths into the application.

2-1. In-site themes

A theme does not have to be published. Compose an existing theme or define one directly with defineTheme, then pass it to theme.

ts
// site/extensions/local-theme.ts
import { defineTheme } from "@riebeckite/core";
 
export function localTheme() {
  return defineTheme({
    name: "site-local",
    styles: [
      { moduleSpecifier: "/extensions/theme.css" },
    ],
    attributes: { "data-site-local": "on" },
  });
}

An in-site theme's name, styles, attributes, and tokens go through the same resolveThemeConfig path (resolution, sanitization, application) as a published theme.

3. The common config options

3-1. colorMode

ts
type ThemeColorMode = "light" | "dark" | "system";

"system" follows the OS preference. Themes use the data-theme attribute and semantic tokens, and avoid hardcoding colors into individual components.

At runtime the palette is decided by three CSS states.

css
: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]) { /* follows the OS (system) */ }
}

The server emits data-theme on <html> unless the theme's colorMode is "system", in which case the attribute is omitted and the media query decides.

Runtime switching contract: set document.documentElement.dataset.theme to "light" or "dark", or remove the attribute for "system". Do not set data-theme=""; an empty attribute still matches [data-theme] and breaks the media query.

@riebeckite/plugin-color-mode is the reference implementation (see its package README) — an inline ColorModeScript that runs before render, a ColorModeToggle control, and an initColorMode client entry that saves the choice to localStorage.

3-2. typography

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

The preset is reflected in semantic font tokens such as body and heading.

3-3. articleLayout

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

A theme defines the presentation of a layout preset; it does not replace the route or the component tree.

3-4. tokens

Core's ThemeDesignTokens has the following semantic groups. In stylesheets they are handled as --rb-* semantic CSS custom properties.

Color:

Token CSS variable
paper --rb-color-paper
ink --rb-color-ink
muted --rb-color-muted
accent --rb-color-accent
border --rb-color-border
borderStrong --rb-color-border-strong
surface --rb-color-surface
surfaceHover --rb-color-surface-hover
overlay --rb-color-overlay
danger --rb-color-danger
success --rb-color-success
codeBackground --rb-color-code-background

Typography:

Token CSS variable
bodyFont --rb-font-body
headingFont --rb-font-heading
monoFont --rb-font-mono

Layout:

Token CSS variable
pageMaxWidth --rb-layout-page-max
articleMaxWidth --rb-layout-article-max
sidebarWidth --rb-layout-sidebar
contentGap --rb-layout-gap
css
@layer base {
  :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;
  }
}

Note that the CSS variable names are kebab-case versions of the token names (borderStrong → --rb-color-border-strong, and so on); the input token name and the CSS variable differ.

Why semantic tokens: if components or plugins reference a specific theme's color names directly, the theme can no longer be swapped.

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

Tokens with plugin-specific meaning are owned by the plugin as --rr-*, falling back to --rb-* when available.

3-5. userCss

userCss loads last (top of the cascade) and is the final override. It is for sites that want to tweak one thing without forking the theme. Theme stylesheets load before userCss, so userCss wins.

ts
theme: defaultTheme({
  userCss: ["/extensions/custom.css"],
}),

3-6. Theme root selector

Built-in themes do not target the bare :root. Every rule is scoped to the theme's identity name 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 one of minimal, gruvbox, sakura, tokyonight, rerurate.

  • On a real site the app sets data-theme-name on <html>, so the :root branch matches the document root.
  • In a preview such as a theme gallery, the same stylesheet styles any element carrying class="rb-theme-root" data-theme-name="<name>", so several themes can render side by side in one document.

Light, dark, system, typography, theme options, and element/pseudo rules all carry the same prefix:

css
: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]) { /* system */ }
}
:is(:root, .rb-theme-root)[data-theme-name="<name>"][data-typography="serif"] { /* typography */ }
:is(:root, .rb-theme-root)[data-theme-name="<name>"][data-tokyonight-neon="on"] { /* theme option */ }
:is(:root, .rb-theme-root)[data-theme-name="<name>"] :focus-visible { /* element/pseudo */ }

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

4. Color mode, attributes, and options in detail

Beyond the contract in 3-1, theme-specific options can be passed to CSS through safe data-* attributes.

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

The theme API is not designed to modify class, style, id, or lang freely. It keeps the namespace of framework-owned attributes 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.

5. Stable CSS hooks

Themes target documented stable hooks, not internal markup. There are two class namespaces.

  • rb-* — structural hooks and semantic design tokens provided by the framework. Structural hooks: .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, .rb-sidebar.
  • rr-<feature> — the root hook on the outermost element rendered by a plugin or feature. Examples: .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, .rr-garden-explorer.

A theme should style only these root hooks and the descendants a plugin documents. BEM elements (__…) and modifiers (--…) are internal implementation details. Generic helpers such as .sr-only are not plugin hooks. Plugins keep legacy classes for backward compatibility, so the same element can carry both .rr-<feature> and the old class; target rr-* from themes.

5-1. 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 app'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 above. Do not invent new rb-* / rr-* class names; .rr-* BEM parts are internal.
  • .rb-article-content is where the Markdown semantic baseline lives: the structural rules that keep Markdown readable after the CSS reset (list markers and indentation, headings, paragraph and block spacing, tables, figures, definitions, inline code, and preformatted blocks). A theme styles the appearance of that baseline through --rb-* tokens and character rules; it does not need to re-declare the structure. The baseline is layered, so a theme's unlayered character rules and utility classes both win over it.
  • .rb-article-body is the article body shell that hosts the header, metadata, rendered Markdown, and plugin slots. It carries no Markdown typography itself, so plugin components keep their own headings wherever they are placed.
  • A theme may ship self-hosted webfonts (Latin subsets) in 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 rather than shipping large font files.
css
/* Tokens stay layered. */
@layer base {
  :is(:root, .rb-theme-root)[data-theme-name="example"] {
    --rb-color-accent: #b45309;
  }
}
 
/* Character rules are unlayered, so they beat app and plugin CSS. */
:is(:root, .rb-theme-root)[data-theme-name="example"] .rb-article-header {
  border-bottom: var(--rb-rule-width) solid var(--rb-color-border);
}
 
@font-face {
  font-family: "Example Serif";
  src: url("./fonts/example-serif-latin.woff2") format("woff2");
  font-weight: 400 700;
  font-display: swap;
}

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

6. CSS cascade

Load order is a key contract for presentation extensions.

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

This order is guaranteed, 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). The site imports the framework stylesheet before the plugin stylesheet, and the plugin stylesheet before the theme stylesheet, so theme CSS always overrides plugin defaults and userCss is the final override.

Do not reorder these imports or edit the generated files. Each generated file notes its cascade position in a header comment. The cascade usually does not depend on !important.

7. Theme factory options

Resolve theme-specific options inside the theme package. Do not grow Core's ThemeConfig with them.

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" },
    ],
  });
}

Theme-specific options matter only when that theme is selected; they never leak into Core or other themes.

8. Packaging for distribution

Use packages/themes/minimal as a template.

text
packages/themes/minimal/
├─ src/index.ts      ← factory that calls defineTheme
├─ styles/theme.css  ← the theme stylesheet
├─ package.json      ← exports ./style.css
├─ README_ja.md
└─ README.md

A distributed theme depends only on @riebeckite/core and exports its stylesheet as ./style.css. Never reference monorepo paths. For the package surface and current constraints, see "Public packages and import paths" in Framework Reference.

9. Verify

sh
npm exec riebeckite check             # validate config and plugin resolution
npm exec riebeckite inspect config# inspect the resolved theme
npm exec riebeckite dev               # check the look locally
npm exec riebeckite build             # check the generated output

check / doctor / inspect are read-only. Swapping a theme does not change routes, the manifest, the graph, or client behavior. If the look is wrong, check the cascade order (userCss last) and whether you are targeting rr-* or rb-*.

Further reading

History

1 changesCollapseExpand
1 + # Themes in Depth
2 +
3 + [Your First Theme](../themes/writing-a-theme.en.md) is a short walkthrough that gets a theme running. This page is its "in depth" companion: it collects everything you refer to while building a theme — options, tokens, hooks, the CSS cascade, and packaging.
4 +
5 + If you are new, read [your first theme](../themes/writing-a-theme.en.md) first, and use this page when you want more detail. For the complete API surface, see [Theme API](../reference/theme-api.en.md).
6 +
7 + ## 1. What a theme can and cannot do
8 +
9 + A theme is the **presentation layer**. It changes tokens, stylesheet rules, and theme-specific `data-*` attributes. It does not change content semantics or application structure.
10 +
11 + | Can | Cannot |
12 + | --- | --- |
13 + | Override or add tokens | Component replacement |
14 + | Define stylesheet rules | JSX injection |
15 + | Declare theme-specific `data-*` attributes | Route additions |
16 + | Declare color mode / typography / layout presets | Adding or removing plugins |
17 + | Provide the final override through `userCss` | Client script execution |
18 + | Style stable hooks | DOM transformation, island registration, filesystem access, ContentManager access |
19 +
20 + Keep the boundary: do not write a plugin just to change appearance, and do not extend a theme to add features. Features go in plugins, plain appearance in themes, and site-specific routes in the app.
21 +
22 + ## 2. The defineTheme contract
23 +
24 + `defineTheme` is imported from `@riebeckite/core`. A theme's main contract has five areas.
25 +
26 + | Area | Contents |
27 + | --- | --- |
28 + | Identity | `name` |
29 + | Factory options | `options` |
30 + | Styles | `styles[].moduleSpecifier` |
31 + | Common config | `colorMode`, `typography`, `articleLayout`, `tokens`, `userCss` |
32 + | Attributes | safe `data-*` attributes |
33 +
34 + ```ts
35 + import { defineTheme } from "@riebeckite/core";
36 +
37 + export function exampleTheme() {
38 + return defineTheme({
39 + name: "example",
40 + options: { /* theme-specific options */ },
41 + styles: [
42 + { moduleSpecifier: "@riebeckite/theme-example/style.css" },
43 + ],
44 + attributes: { "data-example-flag": "on" },
45 + });
46 + }
47 + ```
48 +
49 + `styles[].moduleSpecifier` is a module specifier resolved by the host bundler. It is not a contract for copying filesystem paths into the application.
50 +
51 + ### 2-1. In-site themes
52 +
53 + A theme does not have to be published. Compose an existing theme or define one directly with `defineTheme`, then pass it to `theme`.
54 +
55 + ```ts
56 + // site/extensions/local-theme.ts
57 + import { defineTheme } from "@riebeckite/core";
58 +
59 + export function localTheme() {
60 + return defineTheme({
61 + name: "site-local",
62 + styles: [
63 + { moduleSpecifier: "/extensions/theme.css" },
64 + ],
65 + attributes: { "data-site-local": "on" },
66 + });
67 + }
68 + ```
69 +
70 + An in-site theme's `name`, `styles`, `attributes`, and `tokens` go through the same `resolveThemeConfig` path (resolution, sanitization, application) as a published theme.
71 +
72 + ## 3. The common config options
73 +
74 + ### 3-1. colorMode
75 +
76 + ```ts
77 + type ThemeColorMode = "light" | "dark" | "system";
78 + ```
79 +
80 + `"system"` follows the OS preference. Themes use the `data-theme` attribute and semantic tokens, and avoid hardcoding colors into individual components.
81 +
82 + At runtime the palette is decided by three CSS states.
83 +
84 + ```css
85 + :is(:root, .rb-theme-root)[data-theme-name="<name>"] { /* light */ }
86 + :is(:root, .rb-theme-root)[data-theme-name="<name>"][data-theme="dark"] { /* dark */ }
87 + @media (prefers-color-scheme: dark) {
88 + :is(:root, .rb-theme-root)[data-theme-name="<name>"]:not([data-theme]) { /* follows the OS (system) */ }
89 + }
90 + ```
91 +
92 + The server emits `data-theme` on `<html>` unless the theme's `colorMode` is `"system"`, in which case the attribute is omitted and the media query decides.
93 +
94 + **Runtime switching contract**: set `document.documentElement.dataset.theme` to `"light"` or `"dark"`, or **remove the attribute** for `"system"`. Do not set `data-theme=""`; an empty attribute still matches `[data-theme]` and breaks the media query.
95 +
96 + `@riebeckite/plugin-color-mode` is the reference implementation (see its package README) — an inline `ColorModeScript` that runs before render, a `ColorModeToggle` control, and an `initColorMode` client entry that saves the choice to `localStorage`.
97 +
98 + ### 3-2. typography
99 +
100 + ```ts
101 + type ThemeTypographyPreset = "system" | "serif" | "sans";
102 + ```
103 +
104 + The preset is reflected in semantic font tokens such as body and heading.
105 +
106 + ### 3-3. articleLayout
107 +
108 + ```ts
109 + type ThemeArticleLayoutPreset = "article" | "sidebar" | "full-width";
110 + ```
111 +
112 + A theme defines the presentation of a layout preset; it does not replace the route or the component tree.
113 +
114 + ### 3-4. tokens
115 +
116 + Core's `ThemeDesignTokens` has the following semantic groups. In stylesheets they are handled as `--rb-*` semantic CSS custom properties.
117 +
118 + **Color:**
119 +
120 + | Token | CSS variable |
121 + | --- | --- |
122 + | `paper` | `--rb-color-paper` |
123 + | `ink` | `--rb-color-ink` |
124 + | `muted` | `--rb-color-muted` |
125 + | `accent` | `--rb-color-accent` |
126 + | `border` | `--rb-color-border` |
127 + | `borderStrong` | `--rb-color-border-strong` |
128 + | `surface` | `--rb-color-surface` |
129 + | `surfaceHover` | `--rb-color-surface-hover` |
130 + | `overlay` | `--rb-color-overlay` |
131 + | `danger` | `--rb-color-danger` |
132 + | `success` | `--rb-color-success` |
133 + | `codeBackground` | `--rb-color-code-background` |
134 +
135 + **Typography:**
136 +
137 + | Token | CSS variable |
138 + | --- | --- |
139 + | `bodyFont` | `--rb-font-body` |
140 + | `headingFont` | `--rb-font-heading` |
141 + | `monoFont` | `--rb-font-mono` |
142 +
143 + **Layout:**
144 +
145 + | Token | CSS variable |
146 + | --- | --- |
147 + | `pageMaxWidth` | `--rb-layout-page-max` |
148 + | `articleMaxWidth` | `--rb-layout-article-max` |
149 + | `sidebarWidth` | `--rb-layout-sidebar` |
150 + | `contentGap` | `--rb-layout-gap` |
151 +
152 + ```css
153 + @layer base {
154 + :is(:root, .rb-theme-root)[data-theme-name="<name>"] {
155 + --rb-color-paper: #fafafa;
156 + --rb-color-ink: #202020;
157 + --rb-color-accent: #555;
158 + --rb-font-body: system-ui, sans-serif;
159 + --rb-layout-article-max: 48rem;
160 + }
161 + }
162 + ```
163 +
164 + Note that the CSS variable names are kebab-case versions of the token names (`borderStrong` → `--rb-color-border-strong`, and so on); the input token name and the CSS variable differ.
165 +
166 + **Why semantic tokens**: if components or plugins reference a specific theme's color names directly, the theme can no longer be swapped.
167 +
168 + ```css
169 + /* good */
170 + .rr-example {
171 + color: var(--rb-color-ink);
172 + background: var(--rb-color-surface);
173 + }
174 +
175 + /* avoid */
176 + .rr-example {
177 + color: #171717;
178 + background: #f6efe2;
179 + }
180 + ```
181 +
182 + Tokens with plugin-specific meaning are owned by the plugin as `--rr-*`, falling back to `--rb-*` when available.
183 +
184 + ### 3-5. userCss
185 +
186 + `userCss` loads last (top of the cascade) and is the final override. It is for sites that want to tweak one thing without forking the theme. Theme stylesheets load before `userCss`, so `userCss` wins.
187 +
188 + ```ts
189 + theme: defaultTheme({
190 + userCss: ["/extensions/custom.css"],
191 + }),
192 + ```
193 +
194 + ### 3-6. Theme root selector
195 +
196 + Built-in themes do not target the bare `:root`. Every rule is scoped to the
197 + theme's identity name so the same stylesheet can style the real document and
198 + an embedded preview:
199 +
200 + ```css
201 + :is(:root, .rb-theme-root)[data-theme-name="<name>"]
202 + ```
203 +
204 + `<name>` is the theme's identity name: `riebeckite` for the default theme,
205 + otherwise one of `minimal`, `gruvbox`, `sakura`, `tokyonight`, `rerurate`.
206 +
207 + - On a real site the app sets `data-theme-name` on `<html>`, so the `:root`
208 + branch matches the document root.
209 + - In a preview such as a theme gallery, the same stylesheet styles any
210 + element carrying `class="rb-theme-root" data-theme-name="<name>"`, so
211 + several themes can render side by side in one document.
212 +
213 + Light, dark, system, typography, theme options, and element/pseudo rules all
214 + carry the same prefix:
215 +
216 + ```css
217 + :is(:root, .rb-theme-root)[data-theme-name="<name>"] { /* light */ }
218 + :is(:root, .rb-theme-root)[data-theme-name="<name>"][data-theme="dark"] { /* dark */ }
219 + @media (prefers-color-scheme: dark) {
220 + :is(:root, .rb-theme-root)[data-theme-name="<name>"]:not([data-theme]) { /* system */ }
221 + }
222 + :is(:root, .rb-theme-root)[data-theme-name="<name>"][data-typography="serif"] { /* typography */ }
223 + :is(:root, .rb-theme-root)[data-theme-name="<name>"][data-tokyonight-neon="on"] { /* theme option */ }
224 + :is(:root, .rb-theme-root)[data-theme-name="<name>"] :focus-visible { /* element/pseudo */ }
225 + ```
226 +
227 + The app provides `data-theme-name`; a preview container only needs the
228 + `.rb-theme-root` hook and the matching name.
229 +
230 + ## 4. Color mode, attributes, and options in detail
231 +
232 + Beyond the contract in 3-1, theme-specific options can be passed to CSS through safe `data-*` attributes.
233 +
234 + ```ts
235 + return defineTheme({
236 + name: "newspaper",
237 + attributes: {
238 + "data-newspaper-density": "compact",
239 + },
240 + });
241 + ```
242 +
243 + The theme API is not designed to modify `class`, `style`, `id`, or `lang` freely. It keeps the namespace of framework-owned attributes separate from theme-specific ones.
244 +
245 + ## Theme Root and themeRootAttributes
246 +
247 + The framework provides `ThemeRoot`, a UI primitive that handles setting theme attributes on the `<html>` element.
248 +
249 + ```tsx
250 + import { ThemeRoot } from "@riebeckite/honox/ui";
251 +
252 + <ThemeRoot
253 + theme={config.theme}
254 + lang={c.get("htmlLanguage") ?? config.site.locale}
255 + >
256 + {children}
257 + </ThemeRoot>
258 + ```
259 +
260 + `ThemeRoot` renders the `<html>` element with the following attributes:
261 +
262 + ```html
263 + <html
264 + lang="en"
265 + data-theme-name="minimal"
266 + data-theme="dark"
267 + data-typography="system"
268 + data-article-layout="article"
269 + >
270 + ```
271 +
272 + The framework uses `themeRootAttributes(theme)` to emit the theme's own `attributes` plus reserved attributes:
273 +
274 + - `data-theme`: Color Mode state (`"light"` / `"dark"` / omitted for "system")
275 + - `data-theme-name`: Theme identity name
276 + - `data-typography`: Typography preset value
277 + - `data-article-layout`: Article layout preset value
278 +
279 + To add your own `<html>` attributes, use the `themeRootAttributes` helper directly instead of `ThemeRoot`:
280 +
281 + ```tsx
282 + import { themeRootAttributes } from "@riebeckite/honox/ui";
283 +
284 + <html
285 + lang={c.get("htmlLanguage") ?? config.site.locale}
286 + {...themeRootAttributes(config.theme)}
287 + data-custom-attr="..."
288 + >
289 + ...
290 + </html>
291 + ```
292 +
293 + However, the framework-reserved `data-theme`, `data-theme-name`, `data-typography`, and `data-article-layout` cannot be overwritten by the theme's `attributes`.
294 +
295 + 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.
296 +
297 + ## 5. Stable CSS hooks
298 +
299 + Themes target documented stable hooks, not internal markup. There are two class namespaces.
300 +
301 + - **`rb-*`** — structural hooks and semantic design tokens provided by the framework. Structural hooks: `.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`, `.rb-sidebar`.
302 + - **`rr-<feature>`** — the root hook on the outermost element rendered by a plugin or feature. Examples: `.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`, `.rr-garden-explorer`.
303 +
304 + A theme should style only these root hooks and the descendants a plugin documents. BEM elements (`__…`) and modifiers (`--…`) are internal implementation details. Generic helpers such as `.sr-only` are not plugin hooks. Plugins keep legacy classes for backward compatibility, so the same element can carry both `.rr-<feature>` and the old class; target `rr-*` from themes.
305 +
306 + ### 5-1. Character layer
307 +
308 + A theme is not limited to tokens. Within the theme boundary it may style the
309 + stable hooks directly to give a site a visual character.
310 +
311 + - Put token definitions inside `@layer base`; put visual character rules
312 + **unlayered**. The app's structural CSS and plugin CSS are unlayered, so
313 + unlayered theme rules win over them without `!important`. Never use
314 + `!important`.
315 + - Target only stable hooks: `.rb-site`, `.rb-article`, `.rb-article-layout`,
316 + `.rb-article-header`, `.rb-article-body`, `.rb-article-content`,
317 + `.rb-article-meta`, `.rb-article-footer`, `.rb-sidebar`, and the `rr-*` plugin
318 + roots above. Do not invent new `rb-*` / `rr-*` class names; `.rr-*` BEM parts
319 + are internal.
320 + - `.rb-article-content` is where the Markdown semantic baseline lives: the
321 + structural rules that keep Markdown readable after the CSS reset (list
322 + markers and indentation, headings, paragraph and block spacing, tables,
323 + figures, definitions, inline code, and preformatted blocks). A theme styles
324 + the appearance of that baseline through `--rb-*` tokens and character rules;
325 + it does not need to re-declare the structure. The baseline is layered, so a
326 + theme's unlayered character rules and utility classes both win over it.
327 + - `.rb-article-body` is the article body shell that hosts the header, metadata,
328 + rendered Markdown, and plugin slots. It carries no Markdown typography itself,
329 + so plugin components keep their own headings wherever they are placed.
330 + - A theme may ship self-hosted webfonts (Latin subsets) in its package under
331 + `styles/fonts/`, reference them with relative `url()`, and include the font
332 + license file. Japanese and other CJK text should fall back to system font
333 + stacks rather than shipping large font files.
334 +
335 + ```css
336 + /* Tokens stay layered. */
337 + @layer base {
338 + :is(:root, .rb-theme-root)[data-theme-name="example"] {
339 + --rb-color-accent: #b45309;
340 + }
341 + }
342 +
343 + /* Character rules are unlayered, so they beat app and plugin CSS. */
344 + :is(:root, .rb-theme-root)[data-theme-name="example"] .rb-article-header {
345 + border-bottom: var(--rb-rule-width) solid var(--rb-color-border);
346 + }
347 +
348 + @font-face {
349 + font-family: "Example Serif";
350 + src: url("./fonts/example-serif-latin.woff2") format("woff2");
351 + font-weight: 400 700;
352 + font-display: swap;
353 + }
354 + ```
355 +
356 + Character rules are still presentation-only: they must not change content,
357 + structure, or behavior.
358 +
359 + ## 6. CSS cascade
360 +
361 + Load order is a key contract for presentation extensions.
362 +
363 + ```text
364 + framework structural CSS
365 + → base / app structural CSS
366 + → Plugin default CSS
367 + → Theme CSS
368 + → config token inline style
369 + → userCss
370 + ```
371 +
372 + This order is guaranteed, 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). The site imports the framework stylesheet before the plugin stylesheet, and the plugin stylesheet before the theme stylesheet, so theme CSS always overrides plugin defaults and `userCss` is the final override.
373 +
374 + Do not reorder these imports or edit the generated files. Each generated file notes its cascade position in a header comment. The cascade usually does not depend on `!important`.
375 +
376 + ## 7. Theme factory options
377 +
378 + Resolve theme-specific options inside the theme package. Do not grow Core's `ThemeConfig` with them.
379 +
380 + ```ts
381 + type NewspaperOptions = {
382 + density?: "compact" | "comfortable";
383 + };
384 +
385 + export function newspaperTheme(options: NewspaperOptions = {}) {
386 + return defineTheme({
387 + name: "newspaper",
388 + options,
389 + attributes: {
390 + "data-newspaper-density": options.density ?? "comfortable",
391 + },
392 + styles: [
393 + { moduleSpecifier: "@riebeckite/theme-newspaper/style.css" },
394 + ],
395 + });
396 + }
397 + ```
398 +
399 + Theme-specific options matter only when that theme is selected; they never leak into Core or other themes.
400 +
401 + ## 8. Packaging for distribution
402 +
403 + Use `packages/themes/minimal` as a template.
404 +
405 + ```text
406 + packages/themes/minimal/
407 + ├─ src/index.ts ← factory that calls defineTheme
408 + ├─ styles/theme.css ← the theme stylesheet
409 + ├─ package.json ← exports ./style.css
410 + ├─ README_ja.md
411 + └─ README.md
412 + ```
413 +
414 + A distributed theme depends only on `@riebeckite/core` and exports its stylesheet as `./style.css`. Never reference monorepo paths. For the package surface and current constraints, see "Public packages and import paths" in [Framework Reference](../reference/README.en.md).
415 +
416 + ## 9. Verify
417 +
418 + ```sh
419 + npm exec riebeckite check # validate config and plugin resolution
420 + npm exec riebeckite inspect config# inspect the resolved theme
421 + npm exec riebeckite dev # check the look locally
422 + npm exec riebeckite build # check the generated output
423 + ```
424 +
425 + `check` / `doctor` / `inspect` are read-only. Swapping a theme does not change routes, the manifest, the graph, or client behavior. If the look is wrong, check the cascade order (`userCss` last) and whether you are targeting `rr-*` or `rb-*`.
426 +
427 + ## Further reading
428 +
429 + - [Your First Theme](../themes/writing-a-theme.en.md) — a step-by-step introduction
430 + - [Theme System](./theme-system.en.md) — the conceptual contracts
431 + - [Plugin System](./plugin-system.en.md) — the boundary with themes (features = plugins)
432 + - [Framework Reference](../reference/README.en.md) — public APIs like `defineTheme`
433 +
434 +
435 +
436 +