Writing a theme
A theme is a folder with a theme.json, some CSS and, if you want a cover or a running footer, a few small HTML templates. Nothing in it runs as code. This guide builds one from an empty folder with the plicine command that comes inside the app (Plicine > Install Command-Line Tool… puts it in your terminal), then goes through each part using Acme, a small theme made for an invented company, as the worked example. Every snippet is complete, so you can copy what you need.
Start from an empty folder
Make a folder and give it a theme.json. An id is lowercase letters, digits and hyphens:
{
"id": "my-theme",
"name": "My theme",
"version": "0.1.0",
"styles": { "tokens": "tokens.css" }
}
Add a tokens.css next to it with the two or three things you want to change first:
:root {
--fx-font-body: Georgia, serif;
--fx-color-accent: #1d4ed8;
--fx-radius: 2px;
}
That's a whole theme. Pack it into a vault to try it:
plicine pack my-theme --into <vault>
Packed theme my-theme@0.1.0 into <vault>/.vault/themes/my-theme.theme (ca06d89df856, 1.0 KB)
Pick it from the theme list at the top of the app's window to preview it, or set it as the vault's Default theme in Vault settings. A document can ask for it on its own with theme: my-theme in its frontmatter.
Re-run pack after every change. A .theme file is a packed snapshot, not something the app reads live from my-theme/, so editing the source folder changes nothing until you pack again. The app notices the new file and redraws.
A theme grows by adding files and naming them in theme.json. Acme's looks like this:
{
"id": "acme",
"name": "Acme",
"version": "1.0.0",
"page": "A4",
"styles": { "tokens": "tokens.css", "print": "print.css" },
"templates": { "cover": "templates/cover.html", "footer": "templates/footer.html" }
}
acme/
theme.json
tokens.css colours, fonts, and an import of type.css
type.css document typography, shared by screen and print
print.css page margins, the running footer, the cover
templates/
cover.html
footer.html
assets/logo.svg
fonts/ the .woff2 files and their licence
Tokens
tokens.css sets the --fx-* custom properties that everything else, the app's screen view, the printed page, and every block running inside its sandbox, reads for colour, type and spacing:
:root {
--fx-font-body: "Zilla Slab", Rockwell, Georgia, serif;
--fx-font-heading: "Zilla Slab", Rockwell, Georgia, serif;
--fx-color-accent: #1d4ed8;
--fx-radius: 2px;
--fx-image-radius: 2px;
}
Change the values here and everything using them, including blocks you didn't write, picks up the new look without any other file changing.
Set only the tokens you want to change. Each one has a plain default (system fonts, grey ink on white, a link blue), and headings follow your body font until you give them one of their own. Your theme starts from those defaults, never from the built-in theme's cream and green. The token list has every token with its default.
A theme can add tokens of its own next to these. Acme adds --acme-color-band, the near-black of the band under its titles and along the top of its cover. A theme's own tokens reach block sandboxes with the rest of tokens.css, but only a block written for that theme knows to read them, and the same block will run under other themes too, so it always gives a fallback:
borderTop: "4px solid var(--acme-color-band, var(--fx-color-text, #14161a))",
Typography for screen and print
A theme gets two optional stylesheets, screen.css for the infinite layout and print.css for the paginated layout, and neither is loaded in the other place. Most document typography should look the same in both, so rather than keep two copies in step, Acme has no screen.css at all. Its typography lives in type.css, imported from the top of tokens.css:
@import url("type.css");
tokens.css is the one stylesheet loaded in the infinite layout, the print page and every block frame, so whatever it imports loads there too. Anchor imported rules to the document's own markdown: every Acme rule for markdown starts with :is(.fx-md, .fx-section) >. That does two jobs. The rules match nothing inside a block frame, and in print they never reach into a block's output, which sits in the same .fx-section but inside a .fx-block wrapper.
type.css is also where a theme turns plain markdown into its document parts, without any syntax of its own. In Acme the h1 gets a thick band under it, an h2 is set in cobalt, an h4 is a small label in capitals and a blockquote gets a cobalt bar down its left side. The built-in theme goes further with sibling selectors: the paragraph straight after the h1 is a standfirst. Selectors carry all of it, so the same document still reads as ordinary markdown anywhere else.
Fonts
Acme ships its font, Zilla Slab, as static .woff2 files, one per weight, rather than a single variable font file:
@font-face { font-family: "Zilla Slab"; src: url("fonts/zilla-slab-latin-700-normal.woff2") format("woff2"); font-weight: 700; font-style: normal; font-display: block; }
This is deliberate, not an oversight: Chrome embeds variable fonts in exported PDFs as Type 3 fonts, which render and search worse than a normal TrueType or OpenType font. If your theme needs several weights or italics, include a static file for each one you actually use. Acme ships only the regular and the bold to stay small, so the browser slants its italics; a theme for real documents should add an italic file. Put the font's licence beside it, as fonts/LICENSE-ZillaSlab.txt does.
Print CSS: margin boxes and running elements
print.css is processed by Paged.js, so it can use @page margin boxes to place content in the page's margins, and CSS's running()/element() mechanism to repeat the same element on every page. Acme's footer works like this:
@page {
margin: 20mm 20mm 20mm;
background: #ffffff;
@bottom-center { content: element(fxFooter); vertical-align: bottom; padding-bottom: 9mm; }
}
.acme-page::after { content: counter(page); }
.acme-pages::after { content: counter(pages); }
fxHeader and fxFooter are running elements Plicine itself sets up (the header and footer templates below are wrapped in elements carrying position: running(fxHeader) and position: running(fxFooter)); your theme doesn't create these, it only references them by name in a margin box and styles whatever markup ends up inside them, in Acme's case an .acme-footer wrapper the theme defines itself in its footer template. Page counters work inside that markup as well as directly in a margin box, which is how Acme prints "Page 2 of 5" at the end of a line of other text.
Cover template
templates/cover.html is filled in with the document's frontmatter and title:
<div class="acme-cover">
<div class="acme-cover-brand"><img src="{{asset:assets/logo.svg}}" alt=""><span>Acme Ltd</span></div>
<div class="acme-cover-body">
{{#type}}<p class="acme-cover-kicker">{{type}}</p>{{/type}}
<h1 class="acme-cover-title">{{title}}</h1>
{{#client}}<p class="acme-cover-client">Prepared for {{client}}</p>{{/client}}
</div>
</div>
{{title}} inserts an escaped value, here the document's own title. {{#client}}...{{/client}} only renders the section between the tags when client is set in frontmatter, so a document without a client key just omits that line rather than showing "Prepared for". {{asset:assets/logo.svg}} resolves to the theme's own packaged file, not anything in the vault. The whole cover section is placed on its own page automatically (Plicine's own base print stylesheet assigns it the named page cover), so a theme's cover template doesn't need to worry about page breaks itself.
A document asks for the cover with cover: true in its frontmatter, and without it there is none; a theme with no cover entry in theme.json never shows one either way.
Header and footer templates
templates/header.html and templates/footer.html are small HTML fragments, filled in the same way as the cover, and placed inside the running elements described above. Acme has a footer and no header:
<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>
Style the wrapper class your own template uses (.acme-footer here), Plicine takes care of positioning it into the page margin via the running element.
The credit line
A PDF in Plicine's built-in theme ends with one small line on its last page: "Made with Plicine · plicine.com". Your theme doesn't have it, and the app won't add it. Documents in a theme you wrote say nothing about Plicine unless you decide they should.
If you do want it, set "credit": true in theme.json:
{
"id": "acme",
"name": "Acme",
"version": "1.0.0",
"credit": true
}
The line then prints at the foot of each document's last page, never on the cover and never in your footer, and anyone using the theme can still turn it off with credit: false in vault.json or a document's frontmatter. The wording and the link are fixed. The look is yours, through .fx-credit in print.css:
.fx-credit {
font-family: var(--fx-font-body);
color: var(--fx-color-muted);
}
Plicine positions the line after the pages are laid out, so set its type and colour and leave position, top, left and width alone. Keep it to one line: it's measured to fit between the end of the content and your footer. The .theme package has the details.
Reading frontmatter from CSS
Templates see frontmatter; stylesheets don't. When a frontmatter key should change the styling, have a template emit a marker element and match it with :has(). Acme's footer template does this for density: compact, which tightens the type so a dense document fits fewer pages:
{{#density}}<span class="acme-density-{{density}}" hidden></span>{{/density}}
html:has(.acme-density-compact) {
--fx-print-font-size: 10pt;
--fx-line-height: 1.45;
}
It only works in print, since templates aren't rendered in the infinite layout. The running footer is the first thing in the first section, so the marker is on the page before Paged.js lays out anything under it.
Hiding the duplicate heading
A document's first <h1> and the cover's own title say the same thing, so Acme, which has a cover, hides that first heading in print with:
.fx-has-cover .fx-section-first > h1:first-of-type {
display: none;
}
fx-has-cover is only present when the document actually rendered a cover (so a document without cover: true still shows its own <h1>), and fx-section-first is the first section of content, before any page-break, so a heading appearing later in the document is left alone.
Named pages
A page-break block can name the page template the following section should use:
```page-break
page: landscape
```
Define the named page in your theme's print.css:
@page landscape {
size: A4 landscape;
}
Plicine applies page: landscape as a CSS page property on that section, so everything after the break lays out under your @page landscape rule until the next page-break.
Previewing and iterating
Click Paginated in the app (see Writing and editing) to see a live paginated preview of the current document, using whichever theme is selected. Because a theme is a packed .theme file rather than a live folder, the loop while you're working on one is: edit the source files, plicine pack my-theme --into <vault>, then check the paginated layout again, the folder watcher picks up the repacked .theme file and the preview updates without restarting the app.