Freeframe

Documentation

Ledger

v1.0.1Updated Oct 9, 2026An editorial magazine for long-form writing

Overview#

Ledger is an editorial magazine for long-form writing, built as a WordPress block theme. The demo is The Ledger, a fictional independent magazine of essays about software, design, craft and attention. It has a front page with a featured essay, topic chips, the latest essays, a quote, short notes and most read lists and a newsletter sign-up; a long-form article layout with an "In this essay" outline, reading progress, footnotes, a chart, sharing, tags, the writer's biography and "Keep reading"; an archive page with live search and topic filters; a topics page; topic, tag and writer archives; an about page with the writers, masthead and values; a contact page with a pitch form; and a 404 page. Typography (Newsreader and Inter) is tuned for comfortable reading in light and dark mode. It is version 1.0.1 (see template.json and style.css), works with WordPress 7.1 and PHP 7.4 or newer, and is the WordPress version of the HTML template of the same name. See it live at freeframe.dev/templates/ledger.

How the WordPress version is organised#

The HTML version has one example article and lists the other essays by hand (and in a JSON list for the archive). The WordPress version is a real blog:

  • Essays are posts. An essay's topic is its category, its writer is the post author (a WordPress user, with their biographical info and picture), its dek (the line under the title) is the post's excerpt, its cover is the featured image. Three post fields add the reading time, the cover's credit and the "Keep reading" choices (see Essay fields).
  • What lists essays is built from the posts: the front page's featured essay and "Latest" grid (Query Loop blocks), the archive page's search and filters (inc/content.php prints the essays' data), the article page (templates/single.html), and the topic, tag, writer and search archives (templates/archive.html, templates/search.html). Publish an essay and it appears everywhere at once.
  • Editorial choices stay content: the front page's "Short notes" and "Most read" lists, the quote, the topic chips and the Topics page's cards are blocks in the pages, which you edit like any text (WordPress doesn't know which essays are read most).
  • The pages (Home, Archive, Topics, About, Contact) are WordPress pages made of patterns, and the header and footer are template parts.

Colours (a light and a dark value for each), the three fonts and font sizes are design tokens in theme.json: change them in Appearance > Editor > Styles and the whole site follows.

Quick start#

Requirements: WordPress 7.1 or newer and PHP 7.4 or newer. No plugins are needed.

  1. Download ledger.zip from freeframe.dev/templates/ledger (the WordPress tab).

  2. In your WordPress admin go to Appearance > Themes > Add New Theme > Upload Theme, choose the zip and click Install Now, then Activate. The theme is listed as Freeframe Ledger.

  3. Go to Appearance > Ledger setup and click Create the demo content. This creates:

    • four writers (users with the Author role, jonah-reyes, ana-ferreira, priya-nair and tomas-lindgren, with an @example.com address and a random password), with their biographical info and pictures;
    • five topics (categories: Design, Craft, Writing, Culture, Engineering, with descriptions) and two tags;
    • twelve sample essays (posts) with their topic, writer, date, excerpt, cover (put in the media library, in uploads/freeframe-ledger/), reading time and cover credit; the newest, "The case for slower software", has the full article text, the others a short placeholder;
    • the Home, Archive, Topics, About and Contact pages from the theme's page patterns, Home as the front page (Settings > Reading) and the two header menus.

    Anything that already exists (same login or slug) is left alone, so it is safe to click it again.

  4. In Settings > General set the Site Title (the demo uses "The Ledger"; it is in every browser tab title) and the Tagline. Use pretty permalinks (Settings > Permalinks, any option except Plain).

  5. Write your own essays (see Writing essays), then delete the sample essays (Posts) and, if you like, the sample writers (Users; give their posts to someone else first).

You can also try the theme without installing anything: the Try it in WordPress button on freeframe.dev opens it in WordPress Playground (WordPress running in your browser) with the demo content already set up. The demo gives the featured essay the address /article/ (so its address is the same as in the other versions' demos); Ledger setup gives it /the-case-for-slower-software/.

To work on the theme's files locally you need Node.js 24 and pnpm; WordPress itself runs in WordPress Playground, so there is no PHP, web server or database to install:

sh
pnpm install
pnpm dev     # builds the CSS, then starts WordPress with the theme at http://127.0.0.1:9400

pnpm dev signs you in as admin (password password), sets the site title to "The Ledger" and the tagline to "Essays on tools, craft and attention", switches on pretty permalinks and creates the demo content (blueprint.json). The site is rebuilt from scratch every time you start it; your edits to theme files are live, edits made in the WordPress admin are not kept.

Pages and templates#

Page Slug Made of (patterns in patterns/)
Home (front page) / page-home.php: home-featured (the newest essay, a Query Loop), home-topics (topic chips), home-latest (the next six essays, a Query Loop), home-quote, home-notes (short notes and most read), newsletter (anchor #newsletter)
Archive /archive/ archive (search box, topic filter and the list, Custom HTML)
Topics /topics/ topics (a card per topic and "Have an idea?")
About /about/ about-intro, writers, masthead (masthead and values), write-for-us
Contact /contact/ contact (contact form, pitch guidelines, email addresses, newsletter)
Template Used for
single.html an essay (post): the essay pattern, see The article page
archive.html a topic, tag or writer's archive: its name and description, then the list of its essays
search.html search results (WordPress's search, /?s=) with a search box
index.html the blog page, if you choose one in Settings > Reading
page.html pages (their patterns are full-width sections)
page-with-title.html ("Page with title") plain text pages (privacy policy...): the title, then the text in the article style
404.html missing pages (404 pattern, with a search box that searches the archive page)

Template parts: Header (parts/header.html: the top bar with the tagline, RSS and newsletter links, and the sticky header) and Footer (parts/footer.html). The essay lists of the archives use the posts pattern; their headings are archive-heading and search-heading.

Project structure#

freeframe-ledger/
  style.css                 theme header (name, version, requirements, license); no styles
  theme.json                design tokens: palette (light and dark), fonts, font sizes, spacing,
                            layout widths, button; the custom template and the template parts
  functions.php             loads the files in inc/
  inc/setup.php             stylesheet and script, the script's translations, the dark mode script,
                            editor styles, head meta (theme colours, favicon), title separator,
                            pattern categories
  inc/content.php           the essay fields, the "freeframe-ledger/essay" block bindings source,
                            the writers' pictures, "Keep reading", the archive page's data
  inc/block-styles.php      the Outline, Light (on dark) and Text link button styles
  inc/icons.php             registers assets/icons/*.svg for the Icon block
  inc/icon-labels.php       the icons' names shown in the editor
  inc/site-setup.php        Appearance > Ledger setup: writers, topics, essays, pages and menus
  templates/                single.html, archive.html, search.html, index.html, page.html,
                            page-with-title.html, 404.html
  parts/                    header.html and footer.html
  patterns/                 the header, footer, front page, article layout, essay card and list,
                            every page's sections, and the featured essay's text
  assets/css/theme.css      the compiled stylesheet (do not edit: built from src/)
  assets/css/editor.css     the same for the block editor (generated by src/editor-css.mjs)
  assets/js/main.js         the site's JavaScript (see below)
  assets/icons/             SVG icons for the Icon block (arrow-right, check)
  assets/img/               cover art, writers' portraits, favicon, social image
  assets/fonts/             Newsreader, Inter and JetBrains Mono files and their licenses (OFL.txt)
  src/theme.css             the Tailwind CSS source of assets/css/theme.css
  src/icons.css             icons drawn before or after text with CSS (generated)
  src/editor-css.mjs        writes assets/css/editor.css after each build
  blueprint.json            WordPress Playground setup used by `pnpm dev`
  screenshot.png            the picture shown in Appearance > Themes
  package.json              the CSS build and the local WordPress (Node.js tooling)
  readme.txt, README.md, DOCS.md, CHANGELOG.md, LICENSE.txt

assets/js/main.js starts with a "WordPress:" adapter (it turns the classes blocks keep back into attributes), then the HTML template's script: theme toggle, mobile menu, scroll reveal, toast, the article's reading time, progress bar, outline and sharing, the archive's search and filter, form validation; and finally the back-to-top button.

Writing essays#

Write an essay in Posts > Add New:

Where What it is Shown
Title the essay's title everywhere
Text paragraphs, headings (Heading 2), lists, quotes, Code blocks, pictures... the article page
Excerpt (post settings) the dek, one or two sentences under the title on the article page, on the cards and in the archive
Categories the essay's topic (choose one) the topic line ("Design"), the topic chips, the archive filter
Tags keywords chips at the end of the essay, tag archives
Featured image the cover (3:2 pictures fit best) the article page (wide), the cards
Author the writer name, picture and biography on the article page, writer archive
Publish date the cards, the article page, the archive (grouped by year)

Every essay belongs to one topic. Posts left in the default category (Uncategorized, like WordPress's sample "Hello world!" post) are not essays: they don't appear on the front page or the archive page (they still have their own address and appear in search results). Give every essay a topic, or rename the default category to a topic in Posts > Categories.

The article page's text is styled for reading (prose-ledger): a paragraph with the class lede gets the large first letter; a Quote block with the class pull is a centred pull quote with its citation; a Group with the tag aside and the class callout is a coloured note; a Code block can contain the theme's highlighting spans (tok-c, tok-k, tok-s, tok-f, in its HTML view). Look at the featured essay to see them all. The featured essay's notes are a Group with the class not-prose (it keeps its own small type) holding a heading with the class ffd-toc-skip (left out of the outline) and a list whose items have HTML anchors (fn1, fn2); the references in the text are links to them in <sup> (<sup><a href="#fn1" id="ref1">1</a></sup>, written in the paragraph's HTML view).

Essay fields#

Three post fields (Custom Fields; switch the panel on in the editor's Preferences > General > Advanced) complete an essay:

Field Value Used for
reading_time 5 min read the cards and the archive. Empty: counted from the text (230 words a minute). The article page always counts it from the text.
cover_credit Illustration by Hana Sato for The Ledger. the caption under the cover on the article page
related slugs of up to three essays, separated by commas "Keep reading" on the article page. Empty: the latest essays on the same topic.

Writers#

Writers are WordPress users (Users > Add New, role Author or Editor). On the article page the theme shows their Display name, their Biographical Info (Users > Profile) and their picture, and "More from <first name>" links to their archive (their First Name). Pictures come from Gravatar, except for users with the freeframe_ledger_avatar user field (a file name in the theme's assets/img, set by Ledger setup for the sample writers); a plugin such as Simple Local Avatars lets you upload pictures instead.

Customising#

Brand and colours#

Every colour has a light and a dark value in the palette (theme.json, settings.color.palette, and Appearance > Editor > Styles > Colors > Palette). The stylesheet maps them to the HTML template's variables, which switch with the dark class, and Tailwind tokens read those (in src/theme.css):

Colour (light slug / dark slug) Token Used for
Paper base / base-dark paper the page background, text on dark bands
Ink contrast / contrast-dark ink text, rules, dark bands, buttons
Surface surface / surface-dark surface cards, form fields, chips
Muted muted / muted-dark muted secondary text
Line line / line-dark line borders and dividers
Accent accent / accent-dark accent topic lines, links in essays, the progress bar, the drop cap, focus rings
Accent soft accent-soft / accent-soft-dark accent-soft callouts, inline code, form messages

For a blue accent, change Accent and Accent soft in Styles (both themes), or in theme.json. The code block colours (#191814 and the tok-* colours), the error red and the accent of the "From the archive" kicker on the dark band (.kicker.on-ink) are written in src/theme.css; the browser UI colours (#faf7f2, #14130f) in freeframe_ledger_head_meta() (inc/setup.php).

Typography#

Three fonts are bundled in assets/fonts/ (variable files, one per character subset) and declared in theme.json, so no request goes to Google:

Font Slug Used for
Inter (400 to 700) inter interface text, labels (the site's Text font)
Newsreader (400 to 700, italic 400 to 600) newsreader headlines, deks and the essays' text (the font-serif class)
JetBrains Mono (400 to 500) jetbrains-mono code

To use another font, install it in Appearance > Editor > Styles > Typography > Fonts and select it, or replace the fontFace entries in theme.json. The utilities read the fonts through their slugs (--font-serif is --wp--preset--font-family--newsreader in src/theme.css), so rename a slug only together with src/theme.css. The chart in the featured essay names Inter in its SVG.

Dark mode#

A small script printed first in <head> (freeframe_ledger_theme_script() in inc/setup.php) adds the dark class to <html> before the page paints: from the reader's saved choice ("dark" or "light", key ledger-theme in localStorage), or, when there is none or the stored value is anything else, from the system setting. The theme switch in the header (data-theme-toggle, with aria-pressed) toggles it and saves the choice. To switch dark mode off, delete the theme switch's Custom HTML block from the header and add to a child theme's functions.php:

add_filter( 'freeframe_ledger_dark_mode', '__return_false' );

Edit the header and footer in Appearance > Editor > Patterns > Header / Footer. The header has a top bar (from 640 px wide: the tagline and RSS and Newsletter links) and the sticky header: the masthead ("The Ledger." with an accent full stop), the menu (from 1024 px), a search link to the archive, the theme switch, Subscribe (to /#newsletter) and, below 1024 px, the menu button that opens the mobile menu (#mobile-menu). The menus are two Navigation blocks showing the Main menu and the Mobile menu (Home, Archive, Topics, About, Contact), created by Ledger setup; edit them in Appearance > Editor > Navigation. WordPress marks the current page's link (an accent underline on desktop, accent text in the mobile menu).

The footer has the masthead and its line, the Topics, Publication and Follow link lists (List blocks; the topic links go to the archive page's filter, /archive/?topic=Design) and the copyright line, which prints the current year when the footer comes from the theme's files. The RSS, Mastodon and Bluesky links point at # in the demo: set your addresses (your feed is at /feed/). The toast used by "Copy link" is a Custom HTML block at the end of the footer.

Content in pages#

Edit the pages in Pages. Some content is in Custom HTML blocks, because blocks can't express it: edit those in the block's HTML view.

Pattern Custom HTML
header the search link, the theme switch, the menu button
archive the search and filter bar, the list (filled by the script), the empty state
newsletter, contact the forms and their thank-you messages
essay (article page) the outline and share box, the share buttons
essay-slower-software (the featured essay's text) the chart
footer the toast
404 the search form

"Short notes" and "Most read" on the front page are hand-picked: each item is a Group (class link-card) whose title links to the essay. The topic chips and the Topics page show essay counts and each topic's latest essay as text: update them when you publish. WordPress turns straight apostrophes and quotes typed in text blocks into curly ones (wptexturize).

Classes the script reads#

Blocks keep classes, not data-* or ARIA attributes, so the theme writes the HTML template's attributes as classes: data-x="y" is the class ffd-x--y, hidden is ff-hidden, and role, aria-* and tabindex on blocks are ffa-<name>--<value>. The adapter at the top of assets/js/main.js turns them back into attributes. Keep them when you edit:

Class Where What it does
ffd-article the essay's Post Content block (essay pattern) the text whose reading time, progress and headings the script reads
ffd-reading-time the reading time in the article header written by the script ("5 min read")
ffd-progress the bar in the reading progress Group grows as you read
ffd-toc-skip a heading in an essay's text (the notes' heading) left out of the outline
ffa-aria-labelledby--…, ffa-role--region, ffa-aria-hidden--true sections and decorative bands the accessible names and roles of the HTML template
ffa-tabindex--0 the Code block in the featured essay keyboard scrolling

Theme classes: link-card (a card whose title link covers it), cover (a picture box), kicker, prose-ledger, not-prose, lede, pull, callout, chip, ff-chips, ff-inline (blocks laid out as one line of text), ff-plain-list, ff-cover-link, ff-kicker-links, ff-text-link, ff-rss, site-nav, site-nav-mobile, reveal.

Features#

The front page#

The featured essay is a Query Loop of one post, the newest essay; "Latest" is a Query Loop of six posts that skips the newest. Both leave out posts in the default category. Change the number of essays or add a topic filter in the Query Loop block's settings.

The article page#

templates/single.html shows the essay pattern for every post: the topic and "· Essay" (Post Terms), the title, the excerpt, the writer's picture and name, the date and the reading time; the cover (Featured Image) and its credit; the outline and share box; the text (Post Content); the topic and tags as chips and the share buttons; the writer's box ("Written by", name, Biographical Info, "More from Jonah"); "Keep reading" (a Query Loop of three essays: the post's related field, or the latest on the same topic); and "Liked this essay?". The reading time, the cover credit and "More from …" are paragraphs and a button bound to the theme's block bindings source freeframe-ledger/essay (inc/content.php: keys reading-time, cover-credit, url, author-more, author-url).

Reading progress and outline#

The bar at the top of the article page grows as you read through the text. The outline ("In this essay": a box on wide screens, a collapsible box on narrow ones that closes after you pick a heading) lists the text's Heading 2 blocks (they get an id from their text) and highlights the section you are reading. The reading time is counted from the text (230 words a minute): the words a reader reads, without SVG text (a chart's title, description and labels), hidden elements or the notes (the Notes group or the Footnotes block); captions and code count.

Sharing#

Links with data-share="x", "linkedin" or "email" get share URLs for the page and title. Copy link copies the page's address and shows a "Link copied to clipboard" toast.

Archive search and filters#

On the Archive page, typing in the search box matches titles, excerpts, writers and topics; the topic chips narrow by topic; essays are grouped by year with a count and an empty state with a reset button. The URL keeps the state (/archive/?topic=Design&q=notes), so it can be shared. Press / to jump to the search box. The essays come from your published posts (every post with a topic, newest first), printed as JSON by freeframe_ledger_archive_data() in inc/content.php (filter freeframe_ledger_archive_data); the topics are the essays' categories. The 404 page's search box searches the archive.

Scroll reveals, mobile menu, back to top#

Elements with the class reveal fade up when they scroll into view (only while JavaScript runs; at once with reduced motion). The menu button opens the mobile menu, swaps its icon and label, and closes with Esc, a link or on wide screens. A back-to-top button appears after about one screen of scrolling (delete its part of assets/js/main.js and the .back-to-top styles to remove it).

Buttons#

The Button block's default style is the dark pill. In the block settings' Styles panel there are also (inc/block-styles.php) Outline (the template's btn-ghost), Light (on dark) (for dark bands: "Pitch us", "Subscribe to the letter") and Text link ("More from Jonah").

Forms#

Form Pattern Fields
Newsletter newsletter (front page), contact (sidebar) email
Contact contact name (2+ characters), email, subject, message (30+ characters), consent

Forms with data-validate are checked in the browser: errors appear in the element whose id is the field's id plus -error (field labels for the messages are in data-label), fields get aria-invalid, and the first invalid field is focused. When everything is valid, the form's [data-form-success] panel is shown and focused, and the form is reset.

Nothing is sent anywhere: they are demo forms until you connect them, with a form plugin (replace the form's Custom HTML block with the plugin's block; add the classes input, label and btn to its fields and button) or a form service such as Formspree: add the endpoint as the form's action and method="post", and in assets/js/main.js, at the comment "Send the data to your form endpoint here", send it with fetch(form.action, { method: 'POST', body: new FormData(form), headers: { Accept: 'application/json' } }) before showing the panel (in a child theme's copy of the script). Also rewrite the thank-you texts, which say that nothing was sent.

Saved data (localStorage)#

Key Value Purpose
ledger-theme "dark" or "light" The reader's theme choice; any other value is ignored and the system setting is used

SEO and sharing#

Page titles come from WordPress: the page or essay title, an em dash and the site name; the front page shows the site name and tagline. The theme doesn't output meta descriptions or Open Graph tags; use an SEO plugin such as Yoast SEO, Rank Math or The SEO Framework (they use the excerpt and the featured image of each essay), and give it assets/img/og-image.png as the default social image. WordPress generates a sitemap at /wp-sitemap.xml and an RSS feed at /feed/.

Accessibility#

Built in: a skip link to the main content (added by WordPress, styled by the theme), landmarks (a labelled top bar, header, navigation with labels, main, labelled sections, the article's outline as a labelled aside, footer), one h1 per page, visible focus rings, the theme switch with aria-pressed, the mobile menu button with aria-expanded and Esc to close, the archive's topic chips with aria-pressed in a labelled group and the count announced politely, footnotes linked both ways, an accessible chart (title and description), form errors linked with aria-describedby, aria-invalid on wrong fields, thank-you panels that receive focus, a keyboard-scrollable code block, and reduced-motion support for reveals, smooth scrolling and the back-to-top button.

When you edit: keep one Heading 1 per page and use Heading 2 for an essay's sections (they make the outline), give covers and pictures alt text in the media library (or leave it empty for decorative art), and check the contrast of any colour you change, in both themes (WCAG AA).

Building and deploying#

You only need this if you change the theme's files. The stylesheet assets/css/theme.css is compiled with Tailwind CSS from src/theme.css and the classes used in patterns/, parts/, templates/, inc/ and assets/js/:

sh
pnpm install
pnpm build   # compile assets/css/theme.css and assets/css/editor.css
pnpm watch   # recompile assets/css/theme.css on every change (run pnpm build before you commit)

Run pnpm build after adding a utility class to a pattern, a template or assets/js/main.js. Classes you type in the editor's Additional CSS class(es) field are only styled if the stylesheet already contains them. pnpm start starts WordPress without rebuilding the CSS; pnpm dev does both.

To deploy, zip the theme folder (named freeframe-ledger, without node_modules/) and upload it in Appearance > Themes, or copy the folder to wp-content/themes/ on your server.

Translations#

The theme is translation-ready with the text domain freeframe-ledger. All text in patterns (including the Custom HTML blocks), templates and PHP goes through WordPress's translation functions; languages/freeframe-ledger.pot is the template. The script's strings (theme switch and menu labels, "N min read", the archive's count, months, "All", "By …", form errors, "Link copied to clipboard", "Back to top") are translated in PHP (freeframe_ledger_script_strings() in inc/setup.php, printed as window.freeframeLedgerL10n). Create a translation with Loco Translate, or with WP-CLI and Poedit:

sh
wp i18n make-pot wp-content/themes/freeframe-ledger wp-content/themes/freeframe-ledger/languages/freeframe-ledger.pot

Content created by Ledger setup (essays, topics, writers' biographies, pages) is copied in the site's language at that moment; translate it in the editor like any other content. Dates follow the site's language (Settings > General); the archive page's month names are translated strings.

Child themes#

Use a child theme for changes you want to keep when Ledger is updated. Create wp-content/themes/ledger-child/style.css:

css
/*
Theme Name: Ledger Child
Template: freeframe-ledger
*/

Templates, parts, patterns and theme.json in the child theme override the parent's files of the same name. The parent's stylesheet only contains the classes the parent uses: if your child theme uses new utility classes, compile a stylesheet for the child (copy package.json and src/theme.css, point the @source lines at the child's folders) and enqueue it. Changes to the script (sending the forms) belong in a copy of assets/js/main.js in the child theme, loaded in place of the parent's (keeping the handle keeps its translations):

add_action( 'wp_enqueue_scripts', function () {
	wp_scripts()->registered['freeframe-ledger']->src = get_stylesheet_directory_uri() . '/assets/js/main.js';
}, 30 );

Troubleshooting#

  • An essay doesn't appear on the front page or the archive page. It must be published and have a topic other than the default category (Uncategorized).
  • The featured essay is not the one I want. It is the newest essay: change the date, or select the featured Query Loop on the front page and filter it (for example by a tag).
  • "Keep reading" shows other essays. Fill the essay's related field with up to three slugs.
  • The reading time on the cards is wrong. Set the reading_time field, or leave it empty to count it from the text.
  • No outline on an essay. The outline lists Heading 2 blocks in the text.
  • The pages look unstyled or new classes do nothing. Run pnpm build after editing patterns.
  • "This block contains unexpected or invalid content". A pattern or page was edited by hand with markup that doesn't match the block. Click Attempt recovery, or re-insert the section from Patterns > Ledger.
  • Ledger setup says the pages exist, but they are empty. It never overwrites existing content. Delete (and empty the trash of) the pages with those slugs, or insert the page pattern into each.
  • A form or the chart is gone after someone saved the page. Custom HTML needs the unfiltered_html capability, which administrators have on a single site; edit those pages as an administrator, or use a form plugin.
  • The forms "send" but nothing arrives. They are demo forms; see Forms.

License#

MIT, which is compatible with the GPL that WordPress requires (see LICENSE.txt in the theme folder). Free for personal and commercial use. The cover art, portraits and icons are included under the same license. Newsreader (assets/fonts/newsreader/OFL.txt), Inter (assets/fonts/inter/OFL.txt) and JetBrains Mono (assets/fonts/jetbrains-mono/OFL.txt) are licensed under the SIL Open Font License 1.1.

Search documentation

Search the guides and every template's documentation