Skip to the document

The .theme package

What a .theme file contains, every --fx-* token, the classes and hooks a theme can style, the template syntax, and how a theme reaches the block sandboxes.

A .theme is a zip file, built with plicine pack <dir> from a source folder. Unlike blocks, nothing in a theme runs as code: it's theme.json plus CSS and HTML template files.

Layout

There's no fixed file list beyond theme.json; everything else is whatever theme.json points at, plus any other files (fonts, images, imported stylesheets) those files reference by relative path. A typical theme:

theme.json
tokens.css
screen.css
print.css
templates/
  cover.html
  header.html
  footer.html
assets/
  logo.svg
fonts/
  ...woff2

theme.json

{
  "id": "acme",
  "name": "Acme",
  "version": "1.0.0",
  "page": "A4",
  "styles": { "tokens": "tokens.css", "screen": "screen.css", "print": "print.css" },
  "templates": { "cover": "templates/cover.html", "header": "templates/header.html", "footer": "templates/footer.html" }
}
Field Type Default Rule
id string required Must match /^[a-z][a-z0-9-]*$/
name string required Shown in the theme picker
version string required Semver, same pattern as a block's version
page "A4" or "Letter" none Used when neither a document's frontmatter nor vault.json sets a page size
credit boolean false true prints the "Made with Plicine · plicine.com" line at the foot of a document's last page. Only the built-in theme sets it. See The credit line
styles.tokens string (path) "tokens.css" --fx-* custom properties and @font-face rules; loaded on screen and print, and inside every block sandbox
styles.screen string (path) none Extra CSS for the interactive app view only
styles.print string (path) none Extra CSS for the print/PDF view, processed by Paged.js
templates.cover string (path) none HTML for the cover page; omit to have no cover
templates.header string (path) none HTML for the running page header
templates.footer string (path) none HTML for the running page footer

Every path under styles and templates must exist inside the archive; loading a theme whose theme.json points at a missing file fails with theme.json references missing file <path> or ... missing template <path>. All four sections (styles, templates, and their sub-fields) are optional except styles.tokens, which defaults to tokens.css even if you don't mention styles at all, so a theme with no tokens.css file will fail to load.

The --fx-* tokens

Set in styles.tokens. A theme only has to set the ones it changes: every token has a default, listed below, which loads wherever a theme's tokens do (the app, the print page, an exported site and every block frame). The defaults are plain on purpose, system fonts and grey ink on white with a link blue, and nothing of the built-in theme's look comes through them. The built-in theme isn't loaded underneath a custom one. The defaults sit in :where(:root), which has no specificity, so a theme's :root wins whatever order the stylesheets load in. Both the built-in default theme (packages/core/theme-default/tokens.css) and the example theme, Acme (themes/acme/tokens.css), set all of them except --fx-width-wide and the three print tokens for outsets and the sheet's edge.

Token Used by Default
--fx-font-body Body text font in .fx-doc (screen and print), the sandbox frame's default body style, and theme CSS (e.g. Acme's footer) ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif
--fx-font-heading Headings in .fx-doc, and any heading-styled theme element (e.g. Acme's cover title) var(--fx-font-body), so the theme's body font if it sets one
--fx-font-mono Code and <pre> in .fx-doc ui-monospace, "SF Mono", Menlo, Consolas, monospace
--fx-font-size Base font size in .fx-doc and the sandbox frame body 16px
--fx-print-font-size Base font size of .fx-print only (the paginated PDF view), separate from screen so a theme can set point sizes for print and pixel sizes for screen 10.5pt
--fx-line-height Line height in .fx-doc and the sandbox frame body 1.6
--fx-color-text Body text colour in .fx-doc and the sandbox frame #1c1d21
--fx-color-muted Secondary text: captions, muted labels, page counters, headers/footers #666a73
--fx-color-bg The app's own page background; not read by .fx-doc or print CSS None. The page is white, and the app's chrome keeps its own light or dark canvas
--fx-color-surface Background for code blocks and similar raised areas in .fx-doc #f2f3f5
--fx-color-border Rules and dividers: <hr>, table borders, blockquote borders, TOC dotted lines #d9dbe0
--fx-color-accent Links and blockquote border in .fx-doc; theme-specific accents (e.g. Acme's ## headings) #2257c5
--fx-color-accent-contrast Set by the built-in and Acme tokens for text on an accent background, but nothing built in reads it: available for theme templates and block CSS to use #ffffff
--fx-color-accent-soft Set by both bundled themes for a tinted accent background, read only by block CSS (the quote-table block's summary row) rather than any core stylesheet #e4ebfa
--fx-radius Corner radius for code blocks, block placeholders and error boxes in .fx-doc, and used by several reference blocks 6px
--fx-image-radius Corner radius of pictures in the document: image blocks and markdown images, on screen, in print and on an exported site. Images in a theme's own templates (a cover or footer logo) are left alone. The built-in theme sets 3px and Acme 2px; a theme that wants square corners sets 0 3px
--fx-width-wide The widest a {width="wide"} block gets on screen, in the app's infinite layout and on an exported site. Wide is otherwise halfway between the text column and the full width of the view. It also sets narrow, which is the column less what wide adds at its widest. Not set by either bundled theme 960px
--fx-print-wide-outset How far a {width="wide"} block reaches into each side margin on the pages, and how far a {width="narrow"} one comes in, the same on both sides None: half of each page's own margin
--fx-print-full-outset The same for a {width="full"} block None: each margin less --fx-print-edge
--fx-print-edge How close to the sheet's edge a {width="full"} block comes when --fx-print-full-outset isn't set. Most printers can print to the default 6mm
--fx-space Set by both bundled themes' tokens but not read by any built-in stylesheet; a spacing unit for theme or block CSS to use 8px

A theme can define custom properties of its own in tokens.css too. Acme adds --acme-color-band, read by its own stylesheets. The built-in theme adds --px-color-card, --px-color-accent-light and --px-font-label. Because they live in tokens.css they do reach block sandboxes (see "How themes reach block sandboxes" below), but a block can only count on the documented --fx-* names being set, so a block that reads a theme's own token should always give a fallback.

Imports

tokens.css can @import other files from the same theme by relative path. They load wherever tokens.css does: the infinite layout, the print page (as an ordinary stylesheet, not through Paged.js) and every block frame. Acme and the built-in theme use this to keep one type.css for both the infinite layout and print, instead of a screen.css and a copy of the same rules in print.css. Keep @page rules, margin boxes and anything else only Paged.js understands in styles.print, and anchor imported rules to document markup (Acme's rules for markdown all start with :is(.fx-md, .fx-section) >) so they don't style block output.

Classes and hooks

Screen (interactive view)

Class Where Meaning
.fx-doc Root of the rendered document, and of every sandboxed block's own root implicitly through shared tokens Base typography: headings, paragraphs, lists, tables, code, blockquotes (doc.css)
.fx-md Each markdown segment Wraps one run of sanitised markdown HTML between blocks

A theme's styles.screen file can add to or override .fx-doc for the interactive app. Acme has none: its typography comes from type.css, imported by tokens.css (see "Imports" above).

Class / hook Where Meaning
.fx-print Root <article> of the print document Switches to --fx-print-font-size; .fx-print h1 sets the fxTitle string used by header/footer templates
.fx-cover The cover section, if templates.cover is set and frontmatter sets cover: true Given its own @page cover (no margin boxes) and a forced page break after it
.fx-width A wrapper around a block written with {width="wide"} or {width="full"}, carrying data-fx-width with the value; also on an exported site. In the infinite layout the attribute is on the .block-shell itself, and a markdown element under a <!-- {width="..."} --> comment carries data-fx-width on its own outer element everywhere print-base.css gives it negative side margins from --pagedjs-margin-left and -right; style [data-fx-width] rather than the class to cover both layouts
.fx-has-cover On .fx-print itself, only when a cover was rendered Lets a theme hide the duplicate h1 on the first content page (Acme does this)
.fx-section One per page-break-separated part of the document break-before: page on every section after the first
.fx-section-first The first .fx-section Carries the running header/footer markup, so it doesn't force a break before itself
.fx-running-header / .fx-running-footer Wraps the rendered header/footer template, placed inside .fx-section-first position: running(fxHeader) / running(fxFooter), the CSS Generated Content for Paged Media mechanism Paged.js implements; a theme's print.css pulls them into @page margin boxes with content: element(fxHeader) / element(fxFooter)
string(fxTitle) Any @page margin box in theme print CSS The text of the document's first <h1>, via CSS string-set on .fx-print h1; use as content: string(fxTitle)
counter(page) / counter(pages) Any @page margin box, or a pseudo-element inside header/footer template markup The current page number and the page count; Acme's footer uses .acme-page::after { content: counter(page); }
.fx-toc The toc core block .fx-toc a::after in print-base.css adds a right-floated page number via target-counter
.fx-children The children core block A nav holding .fx-children-title and nested ul lists of links, one level per folder. A subfolder with pages under it is a details whose summary holds its link, the arrow drawn by summary::before; .fx-children-tree is on the nav when any subfolder folds, and .fx-children-empty is the line shown when nothing is below the page
.fx-image The image core block .fx-align-left/.fx-align-right/(default centred) modifiers
a:not([href]) In a PDF, a link to another document or to a file in the vault, which is printed as text (Links). It keeps its element, its classes and its data-doc, and loses its href. The paginated layout in the app leaves the href on print-base.css sets color: inherit and text-decoration: none on .fx-print a:not([href]); style the same selector in print.css to set these apart
.fx-wikilink A [[link]], whether it points at a document or a file in assets/ An ordinary a otherwise, so it takes the theme's link colour; .fx-wikilink-unresolved is a span for a link that matches nothing in the vault (grey with a dotted underline by default)
.fx-embed An image embedded with ![[image.png]] An img, with width/height when the link gave a size
.fx-callout A callout, from > [!type] markdown or the callout core block .fx-callout-<type> for each of Obsidian's types, data-callout with the type as written, a details element with data-callout-fold when it folds; .fx-callout-title and .fx-callout-body inside. Recolour a type by setting --fx-callout-color on it; the icon comes from --fx-callout-icon (a mask-image)
.fx-columns The columns core block A CSS grid with one .fx-column-item per markdown item: .fx-columns-rows or .fx-columns-columns for the fill order, or .fx-columns-staggered, where the items are grouped into a .fx-column stack per column. Spacing comes from --fx-columns-gap-y and --fx-columns-gap-x, which both layouts read, so set those rather than gap
.fx-card The card core block, alone or inside a columns item A box around rendered markdown; style the headings and paragraphs inside it to give cards their look (the built-in theme does)
.fx-block A package or inline block's rendered output, print or interactive Wraps sanitised block HTML, with data-block set to the fence name; print also scopes any <style> the block emitted with @scope so it can't leak onto the rest of the page
.fx-block-placeholder A block that couldn't run (not approved yet, or denied) Dashed border box with an explanatory sentence
.fx-block-error A block that failed validation or crashed Red-bordered box listing the validation issues or error message
.fx-doc-link The QR code and URL at the end of a document with a link Only rendered when neither the cover, header nor footer template already used {{qr}}
.fx-credit The "Made with Plicine" line on the last page, only in a theme whose theme.json sets "credit": true A p holding the text and one a. It's a child of the last page's .pagedjs_pagebox, outside .fx-print, placed by top, left and width once the pages are laid out. print-base.css sets its face, 7pt size and muted colour; restyle those, and leave its position alone
.fx-page-break The page-break core block, screen only A labelled horizontal rule marking where the page will break in print; renders as nothing in Print mode, since the break itself is structural, not a visible element

print-base.css (loaded before any theme print CSS) also sets break-inside: avoid on .fx-block, figure, pre, blockquote, table rows and .fx-doc-link, and orphans/widows: 3 on paragraphs. The layout core blocks keep their own items together instead of the whole block: doc.css sets break-inside: avoid on each .fx-column-item, .fx-card and .fx-callout, so a long block splits between cards rather than jumping whole to the next page. The built-in theme's type.css restyles callouts and cards (label face on callout titles, the display italic in a quote callout, paper-coloured cards), and Acme's gives cards a square box with a band along the top. Both are worked examples of what a theme can do with these hooks.

.fx-print goes through Paged.js (so @page, margin boxes, string()/target-counter() and named pages all work there); .fx-doc on the interactive screen view does not, since there's no pagination on screen. A theme's styles.print file is loaded alongside print-base.css and processed by the same Paged.js pass; styles.screen is an ordinary stylesheet loaded into the live app page and never touches Paged.js.

Templates

templates.cover, templates.header and templates.footer are HTML fragments filled in with the document's frontmatter plus a few computed values, then sanitised with the same DOMPurify profile used for block output.

Placeholder Value
{{title}} The document's title
{{page}} The resolved page size, "A4" or "Letter"
{{link}} The document's resolved link URL, or empty
{{path}} The document's path inside the vault
{{theme.id}} / {{theme.name}} The active theme's id and name
{{qr}} An inline SVG QR code of link, or empty if there's no link
{{asset:relative/path}} Rewritten to /_theme/<theme-hash>/relative/path, for the theme's own images and fonts
{{anyValue}} Any key of the vault's values or the document's frontmatter, e.g. {{client}}, escaped as text. The frontmatter wins where both set one
{{#key}}...{{/key}} Keeps the enclosed HTML only when key looks up to a value that isn't null, undefined, false or ""
<div class="acme-cover-body">
  {{#type}}<p class="acme-cover-kicker">{{type}}</p>{{/type}}
  <h1 class="acme-cover-title">{{title}}</h1>
</div>
<div class="acme-footer"><span><b>Acme Ltd</b> · hello@acme.example</span><span>{{#client}}Prepared for {{client}} · {{/client}}Page <span class="acme-page"></span> of <span class="acme-pages"></span></span></div>

Section keys can be dotted ({{#frontmatter.client}}) since the lookup walks .-separated segments through the same variable object, but bare keys ({{client}}) are also available directly, not just under frontmatter.client, and they include the vault's values; frontmatter holds only the document's own. Everything except {{qr}} and {{asset:...}} is HTML-escaped before insertion.

Stylesheets never see frontmatter. To let a frontmatter key change the styling, a template can emit a marker element for CSS to find with :has(): Acme's footer adds <span class="acme-density-{{density}}" hidden> inside {{#density}}...{{/density}}, and print.css matches html:has(.acme-density-compact). This only reaches print, since templates aren't rendered in the infinite layout.

The credit line

Plicine's built-in theme ends a PDF with one line, "Made with Plicine · plicine.com", linked to https://plicine.com/?ref=pdf. A theme gets it by asking:

{
  "id": "acme",
  "name": "Acme",
  "version": "1.0.0",
  "credit": true
}

Leave credit out, as the Solunify Creations theme does, and documents in the theme never carry the line, whatever vault.json says. The app doesn't add it to a theme that hasn't asked. With "credit": true, the line prints unless the vault or the document sets credit: false (see vault.json and frontmatter).

The text and the link are fixed, so a theme decides whether the line is there and how it looks, not what it says. It isn't a template and it isn't in the flow: print.tsx adds it to the last page after Paged.js has finished, so it can't push anything onto a new page. It goes at the foot of the page's content area. If the content ends too close to that, it moves down into the gap between the content area and whatever the theme runs in the bottom margin, and if it wouldn't fit there either it's left off with a warning. A theme with a bottom margin of 20mm and a one-line footer, like the built-in one, always has room. It never goes on a page named cover.

Style it through .fx-credit in styles.print:

.fx-credit {
  font-family: var(--fx-font-heading);
  font-style: italic;
}

An exported site's credit is part of the site's own frame, not the theme's, so theme.json has no say over it.

Named pages

page-break (see Core blocks) can carry a page name: page: landscape. In print, the section following that break gets style="page: landscape" (with anything other than letters, digits, _ and - stripped from the name), which is the CSS Paged Media page property. A theme's print CSS then styles that named page with its own @page landscape { ... } rule, for example to change margins or orientation for one section. The built-in cover page name is reserved: it's what .fx-cover uses to get its own borderless page.

Fonts

Themes embed fonts with ordinary @font-face rules in tokens.css, referencing files by relative path (typically under fonts/). Both bundled themes use static woff2 files, one per weight and style, rather than a single variable font, because Chrome embeds variable fonts in exported PDFs as Type 3 fonts, which render and search worse than a matching set of static TrueType/OpenType faces. Each @font-face sets font-display: block so text doesn't flash in a fallback font before the real one loads, which matters more for a print render than for a live page.

How themes reach block sandboxes

Every block's sandboxed iframe loads the active theme's styles.tokens file as a <link rel="stylesheet"> before the block's own compiled CSS, so a block can use var(--fx-color-accent) and the rest of the token list and pick up the vault's active theme automatically, without ever seeing screen.css, print.css or any template. Anything tokens.css imports loads in the frame too, which is why imported rules should be anchored to document markup. Blocks should rely on the documented --fx-* tokens, with a fallback for any theme-specific one: a theme's other CSS and its -sx-prefixed classes are never loaded inside a block frame. The frame also gets a small fixed base style (packages/app/src/server/server.ts's frameStyle) setting body colour, font and line height from --fx-color-text, --fx-font-body, --fx-font-size and --fx-line-height, so a block that sets none of its own typography still looks reasonably at home.