Documentation theme for sites built with Poops β two layouts, their self-contained styles, and the client scripts. Ships as a dependency so a site consumes it instead of copying files.
Requires Poops β₯ 2.5.0 β that is where the description filter arrived, which both
layouts use for <meta name="description">; an older Poops raises an unknown-filter error
rather than skipping it. (β₯ 2.0.0 was the floor before that: the dev server appends its own
livereload client from there on, so the layouts stopped carrying the snippet and the
livereload_port global it read is gone.)
docs |
prose |
|
|---|---|---|
| For | a real docs site | a small project β one page |
| Topbar | brand + docs pill + search + nav links + icon links + theme switcher |
brand + nav links + icon links + theme switcher |
| Body | skip link, sidebar nav, breadcrumb, TOC, prose, last updated + edit link | skip link, one prose article |
| Stylesheet | dist/css/docs.min.css |
dist/css/prose.min.css |
| Script | dist/js/docs.min.js |
dist/js/prose.min.js |
Pick one per page. The bundles are alternatives, not layers β docs.min.css already
contains everything prose.min.css has, and docs.min.js already contains
prose.min.js. Never load both.
| File | Role |
|---|---|
docs.html |
full docs layout β topbar, sidebar nav, breadcrumb, TOC, prose |
prose.html |
standalone layout β topbar, one prose body, no sidebar or search |
topbar.html |
shared topbar macro (both layouts import it) |
navtree.html |
recursive sidebar-nav macro (docs.html imports it) |
scss/_base.scss |
tokens + element base |
scss/_shell.scss |
the frame both layouts share β skip link, topbar, brand, icon buttons (GitHub + theme switcher), content column, footer |
scss/_chrome.scss |
docs-only chrome β docs pill, search, sidebar, breadcrumb, TOC, last-updated + edit link |
scss/docs.scss |
entry: base + shell + chrome + prose |
scss/prose-only.scss |
entry: base + shell + prose |
src/prose.ts |
copy buttons, theme toggle, the topbar's nav element and icon-link tooltips β everything a bare page needs |
src/docs.ts |
imports prose.ts, adds search, active nav, the sidebar drawer |
preview/src |
mock site for looking at the theme β see Preview |
npm install
npm run build # poops -b β dist/css/{docs,prose}.min.css, dist/js/{docs,prose}.min.js1. Install (published, or npm link for local dev).
2. Point pages at a layout β front matter:
---
layout: poops-docs-theme/docs # or poops-docs-theme/prose
---3. Build the styles + script. Two ways:
-
From source (fresh every build) β in the consumer's
poops.json:{ "styles": [ { "in": "node_modules/poops-docs-theme/scss/docs.scss", "out": "dist/css/docs.css", "options": { "minify": true, "justMinified": true } } ], "scripts": [ { "in": "node_modules/poops-docs-theme/src/docs.ts", "out": "dist/js/docs.js", "options": { "minify": true, "justMinified": true, "format": "iife", "target": "es2019" } } ] }Swap
scss/docs.scssβscss/prose-only.scssandsrc/docs.tsβsrc/prose.ts(out:dist/css/prose.css,dist/js/prose.js) for theproselayout. -
Prebuilt β
copythe theme'sdist/cssanddist/jsinto your output.
To render markdown outside either layout, load prose.min.css and give the markdown
container class="prose". The script is optional there β it only adds copy buttons and
the theme toggle.
docs.html links css/docs.min.css + js/docs.min.js and reads search-index.json
from the site root; prose.html links css/prose.min.css + js/prose.min.js and needs
no index. The consumer produces those files.
Both layouts read site for the header: brand (falls back to title), brandMark
(the emoji, defaults to π©), and repo (falls back to package.homepage) for the GitHub
button β omit both and the button disappears. Both render site.footer (html, unescaped)
if set; without it both fall back to the same line β brand, version, license, then credit to
Poops and this theme β read from the consuming site's package.json (homepage, version,
license β a missing license just drops that clause).
brandMark is also the tab icon, drawn as an inline svg β no favicon file needed.
The title is its own link and goes to the site root. site.brandUrl retargets it β a
site-relative url gets the page's path prefix, an absolute one opens in a new tab. On the
docs layout the docs pill next to it is a second link, back to docs/, so the title
leaves the docs section and the pill returns to it.
site.links adds nav links β a row at the right of the bar, against the search field and the
icon buttons rather than against the brand. Site-relative
urls get the page's path prefix; absolute ones open in a new tab. Every link shows on every
page, and the one you are inside is marked rather than hidden: aria-current="page" on the
page itself, aria-current="true" anywhere under it (docs/ stays lit through all of
/docs/), styled in the accent colour. An optional icon sets a mark before the label.
The row is <navbar-elemental>, which is
where its behaviour comes from. A link that stops fitting moves into a More panel, one
at a time as the room goes β measured rather than guessed, so it answers the labels you
actually wrote in the font that actually arrived β and when the window is under 40rem, or
only one link is left beside More, the whole row becomes a drawer behind a hamburger
that crosses into an X. One link and an overflow button is not a navigation bar, which is
what min-bar-items="2" says; the theme sets it only when you have given it more than one
link, since the threshold counts the links you have as well as the ones that fit β with a
single link it would be a drawer at every width.
It is the APG disclosure navigation pattern and not a menubar: the items stay links, Tab
reaches every one of them, the arrow keys walk the row, and Escape closes what is open.
aria-expanded, aria-controls and hidden are the element's to write. The breakpoint is
declared once, as the media attribute in topbar.html, and no stylesheet here repeats it.
When the row becomes a drawer the icon links and the theme switch go into it, as the row under the links, and come back onto the bar when the links do. They are moved and not copied, so there is never a second switch able to disagree with the theme on screen β and with scripting off, where there is no drawer either, they stay on the bar at every width. Search does stay: below 40rem the field shrinks to its icon and expands across the bar when you tap it. What it does once you type is Search.
The docs sidebar is the same idea with a different element: a rail above 60rem and a drawer
below it, <disclosure-elemental> with the
breakpoint as its media attribute, so a drawer left open cannot survive a rotation into a
layout that has no drawer. It is not modal β focus is not trapped, the article is not
inert, and tabbing past the last link leaves it, which is the disclosure pattern. What the
theme adds is the two ends that pattern does not owe you: opening the drawer hands focus to
the link for the page you are on (only as a drawer β a rail stealing focus because the window
got wider would be worse), and Escape or a click on the scrim closes it and gives focus back
to the toggle. Both are the drawer's only: above the breakpoint Escape leaves the rail
standing, because a rail it closed is one the reader has no toggle left to reopen. Closed, it
is hidden="until-found", so find-in-page still reaches a link inside it and opens it.
{
"markup": {
"site": {
"links": [
{ "title": "Docs", "url": "docs/" },
{ "title": "Changelog", "url": "https://github.com/you/repo/releases", "icon": "package" }
]
}
}
}site.iconLinks is the same shape without labels: buttons in the row next to GitHub, for
package registries, chat rooms, anything worth a permanent spot. On a phone they follow the
links into the drawer rather than staying on a bar with no room left.
A glyph names nothing to a reader who has not met it before, so title is both the link's
accessible name and the words in a tooltip β
<tooltip-elemental>,
which shows them on hover and on focus and takes Escape to dismiss. The GitHub button gets the
same treatment. There is no hover on a touch screen, and the element ignores pointer events
coming from one rather than half-handling them, so a tap opens the link instead of a bubble β
the name is on the link the whole time, which is what a screen reader reads either way. With
scripting off the browser's own tooltip does the job, from the title the element would
otherwise have taken over.
The look is the element's optional theme with --tooltip-elemental-surface,
--tooltip-elemental-color and --tooltip-elemental-border-color re-pointed at --fg and
--bg: its own default is CanvasText on Canvas, which follows the operating system rather
than the switch in this bar.
{
"markup": {
"site": {
"iconLinks": [
{ "title": "npm", "url": "https://www.npmjs.com/package/you-pkg", "icon": "npm" },
{ "title": "Packagist", "url": "https://packagist.org/packages/you/pkg", "icon": "package" },
{ "title": "Discord", "url": "https://discord.gg/xxxx", "icon": "π¬" }
]
}
}
}Both lists take the same icon values:
icon |
renders |
|---|---|
github |
the GitHub mark |
npm |
the npm mark |
package |
a generic package box β use it for Packagist, PyPI, crates.ioβ¦ |
| anything else | printed as given, so an emoji or a pasted <svg> works |
site.theme takes the choice away from the visitor. Any value drops the boot script and
the toggle button.
site.theme |
Behaviour |
|---|---|
| unset | follows the OS, visitor can toggle, choice kept in localStorage (default) |
"light" |
always light β data-theme="light" on <html> |
"dark" |
always dark β data-theme="dark" on <html> |
"system" |
always follows the OS, no toggle, nothing remembered |
{
"markup": {
"site": {
"theme": "light"
}
}
}"system" sets no attribute β the stylesheet's prefers-color-scheme block handles it,
so it works with JavaScript off. That same block is why an unset site.theme still tracks
the OS for a visitor whose browser blocked the inline script.
Every colour is a custom property on :root (see scss/_base.scss), so a site keeps its
own accent by overriding the handful it cares about after the theme. Build your own
entry instead of the theme's:
// src/scss/docs.scss β point poops.json at this
@use "poops-docs-theme/scss/docs";
:root,
:root[data-theme="light"] {
--accent: #9a6b00;
--link: #8a6414;
}
:root[data-theme="dark"] {
--accent: #f6c026;
--link: #f6c026;
}Sass resolves the bare poops-docs-theme/... specifier through includePaths, so the
consumer's poops.json needs "includePaths": ["node_modules"] (top level, not inside
styles). The theme's own stylesheets load book-of-elementals/... the same way β it is a
dependency of this package, so npm installs it, and the same includePaths is what finds
it. The full token set: --bg, --bg-alt, --bg-code, --fg, --fg-muted,
--border, --accent, --accent-fg, --link, --focus, --danger, --shadow, plus
--content-max, --radius, --topbar-h, --sidebar-w, --font-body, --font-mono.
Nothing in the theme paints an error, so --danger is there for elements you embed in a
page β Live samples is the case it was added for.
The field in the topbar of every docs page. It filters search-index.json β the file poops
writes from every page's front matter β on title, description and keywords, and shows
the first eight hits.
The index is fetched on the first query, not on page load: the field is on every page and most visits never type in it. So the first search of a visit waits for the network and gets a spinner; every one after it answers from memory. A load that fails is dropped rather than remembered, so the next keystroke tries again.
Two elements from book-of-elementals do the
work, and between them they are the whole keyboard.
<search-elemental>
owns the query β a 100 ms debounce, one AbortController per query, the sequence number that
drops the slow answer arriving after the fast one, and a role="status" region that says
5 results, No results or Search failed out loud, which a panel silently filling itself
does not.
<suggest-elemental>
owns the panel: the listbox roles, the cursor that is aria-activedescendant rather than
focus so typing carries on while you walk the list, β / β wrapping,
Home / End, and Enter to follow the row under the cursor. The
theme fetches, builds the rows and owns the rest:
/and βK / Ctrl+K put the cursor in the field. The modifier pair works from inside another field and selects what is already typed; the slash does not, because it is a character somebody may be mid-word in. Both are taken off the browser when they land, so Firefox's quick-find and Chrome's address-bar shortcut do not fire on a docs page.Ctrl+Shift+Kis left alone β the web console is not a docs site's to take. There is no visible hint beside the field: the shortcut is a shortcut, and at the small end that field is the width of the screen.- Escape empties the field, and the panel goes with it. The element's own staging is one press to close and a second to clear; a reader pressing Escape at a search box means the search.
- Focus leaving empties it too. This field is in the topbar rather than in the middle of a page, so what is typed in it outlives the results it fetched β and on a phone a field with something in it will not fold back into its icon.
- The cross at the end of the field is a real button.
<input type="search">gets one of its own in Chromium and Safari and none at all in Firefox, and the one it gets is drawn by the non-standard::-webkit-search-cancel-button: mouse-only, no tab stop, nothing in the accessibility tree. That one is taken off so there are not two, andbutton.search-clearreplaces it β the octicon x at the magnifier's weight, a 24px target, anaria-label, and a focus ring. It isvisibility: hiddenwhile the field is empty, which drops it out of the tab order too: a stop that clears an empty field is a keystroke that does nothing.
A result row is built as nodes, never as markup. The index is front matter verbatim, so a
title: reading <img src=x onerror=β¦> would otherwise run on every page of the site. Text
goes in as textContent, and a url is resolved and dropped unless its scheme is http: or
https: β href takes a javascript: url as readily as a path.
Nothing found and nothing fetched are different answers, and both are said twice: once in the live region, once in a box under the field. A site that never generated the index gets Search failed, not No results β a search that never happened is not a search that found nothing.
The DOM this produces, for a stylesheet that has to reach into it: the wrapper is
search-elemental.search carrying data-state (idle, pending, results, empty,
error), the field is #search-input with .search-icon and button.search-clear at its two
ends, the panel is suggest-elemental with [open] and [role="option"] on each row, and a
row is li > a holding .sr-title and .sr-desc. The two messages are
p.sr-note.sr-empty and p.sr-note.sr-error, shown by data-state alone. The panel's look
is the element's optional theme with --suggest-elemental-surface, --suggest-elemental-active,
--suggest-elemental-radius, --suggest-elemental-inset and --suggest-elemental-max-height
re-pointed at this theme's tokens and spacing; the spinner the pending state draws is
--search-elemental-spinner-*.
The message boxes sit outside the panel deliberately. A listbox may only own options, so a
"No results" row inside it is aria-required-children β critical, and script/a11y fails the
build on it β however reasonable it looks on screen.
Every <pre> in .prose gets a copy button in its corner. The script adds it, so a page
that loaded the stylesheet and nothing else has code blocks and no buttons, which is the
right way round.
The button is <copy-elemental>, and what
that brings is the half after the click. The icon becomes a tick and the same word reaches
a screen reader, through the live region the element appends β an icon swap on its own
announces nothing, which is
WCAG 2.2 SC 4.1.3 unmet.
A write the clipboard refuses turns the button red and says Copy failed in the same
corner tooltip, rather than looking like a button nobody pressed. And navigator.clipboard
does not exist on a page served over plain http: there the element takes the button away
instead of leaving a dead one, so the code is still there to select and nothing lies about
copying it.
What lands on the clipboard is the block's text with leading newlines and trailing whitespace stripped β a trailing newline pasted into a terminal runs the command the reader was still reading. Indentation is untouched, which Python and YAML need.
The DOM this produces, for a stylesheet that has to reach into it: the <pre> is wrapped in
.code-wrap, the button is copy-elemental > button with [data-state="copied"] and
[data-state="error"] on the element while there is something to report, and a <pre> with
no id of its own is given code-block-N β that is how for finds what to copy, and an id
the page already uses is stepped over rather than taken. The look is the element's optional
theme with --copy-elemental-surface, --copy-elemental-border-color,
--copy-elemental-hover and --copy-elemental-icon-size re-pointed at this theme's tokens.
A docs page that shows a sample usually wants to run it too.
<code-preview> is one way: it wraps a
code fence and renders it in an isolated iframe above the code that produced it.
Loading it is your site's job, not the theme's β it is in neither bundle and not a
dependency of the published package. A docs site with no live samples should not pay for
an editor and an iframe runtime, and the sites that do want it load the bundle only on the
pages that have a preview. Add code-preview-element yourself, build or copy its script
and stylesheet in your own poops.json, and wrap the fences however suits your pages.
The mock site does exactly that, on one page, and
live-samples.md is the whole of it β two tags
in the page body and a fence wrapped in the element.
What the theme does do is get out of the element's way. Three collisions, each one only this stylesheet can settle β the first two because the theme caused them, the third because the element has no palette of its own to reach for:
| Collision | What the theme does |
|---|---|
Two copy buttons β ours on every .prose pre, the element's own on the strip above |
hides ours inside code-preview and gives back the 3.7rem it reserved |
A gap between the frame and its code: .prose :is(figure, .code-wrap) is two classes against the package's one-class-one-type reset |
zeroes it β code-preview owns the spacing around itself, the block inside gets none |
The package's error red falls back to a fixed #cf222e, in no palette and dark-mode-blind |
ships --danger for it to find |
Keeping ours would be the worse button, not just a second one: it copies the single pre
it sits on, where the element's copies whichever pane is showing β and a sample written as
several fences has more than one.
Theming the preview itself is the package's business and needs nothing from here: its
stylesheet reads --border, --radius, --bg, --accent, --fg-muted and
--font-mono with fallbacks, so it wears whatever your :root says. Its frame is a
separate document and inherits none of that β pass the stylesheets it should load, and
theme-attribute="data-theme" to carry this theme's dark mode across the boundary.
All Poops built-ins, present in any Poops build:
docs |
prose |
|
|---|---|---|
| Filters | toc, breadcrumb, canonical, og, jsonld |
canonical, og, jsonld |
| Data | nav tree (markup.nav), search-index.json (markup.searchIndex) |
β |
Both read site config: title, description, lang, repo, branch (edit link, docs
only), plus the optional brand, brandMark, brandUrl, links and footer covered under
Topbar config.
docs prints a Last updated: <date> line at the left of the edit-link row whenever a page
has updated β written in front matter, or filled in for every page by Poops' own
markup.options.lastUpdated (Poops
β₯ 2.4.0), which dates a page from a committed index of content hashes. A page without one
renders no line, and the edit link stays where it was. Below 40rem the row stacks into a
centred column, date above button. The date is formatted MMM D, YYYY
and carries a machine-readable <time datetime> beside it; the format is the theme's, not a
config key.
The jsonld filter types a dateless page as WebPage. Documentation is TechArticle β
set it once for the whole site (Poops β₯ 1.9.7), rather than per page:
"markup": {
"site": {
"jsonld": { "@type": "TechArticle" }
}
}Precedence is defaults β site.jsonld β page front-matter jsonld, so an odd page out
(a FAQPage, a SoftwareSourceCode listing) still overrides it locally.
preview/src is a mock site β filler pages that exist only to render the layouts. It
builds with the theme, through the real docs.html and prose.html, so there is no
second copy of the markup to keep in sync.
npm run preview # poops: build + watch + serve on http://localhost:4040/ is the standalone prose layout demo; /docs/ is the docs layout. npm run build
produces both too, at preview/dist (gitignored, and not in the published files). Docs
pages: Introduction, Getting started, and a Guide section whose Kitchen
sink page carries every element _prose.scss styles β headings, lists, table,
blockquote, all five admonition flavours, highlighted code, keyboard keys, image.
The Live samples page beside it is the one page with a runtime dependency:
code-preview-element is a devDependency, copied into preview/dist/vendor by the
copy entry in poops.json and loaded by that page alone. It is filler with a job too β
the three collisions in Live samples are rules nothing else in the mock
exercises, so without it they are only claimed.
Inside Guide sits a Deeper section, and it is filler with a job: three levels of nav
render through a branch of navtree.html that two levels never reach, so a mock site
stopping at two leaves that branch unaudited while script/a11y reports green. Keep the
nesting when editing the mock.
Kitchen sink and Live samples sit under a Samples heading, and neither file
moved to get there: both carry navGroup: Samples in front matter, poops 2.3's way of
filing a page under a section the urls do not produce. The urls stay
docs/guide/kitchen-sink and docs/guide/live-samples, and so do the breadcrumbs β the
grouping is the sidebar's alone. It is filler with a job as well: a group is a section
node with no page of its own, so it renders with no Overview link, and every other
section in the mock has an index page that gives it one. Building the mock on a poops
older than 2.3 leaves the field unread and both pages sitting directly under Guide β the
grouping degrades to a flat list rather than failing, since the tree it renders arrives as
data either way. The peer range is >=2.5.0 for a different reason: the layouts call the
description filter, which 2.5.0 introduced, and an older poops raises an unknown-filter
error rather than skipping it.
To preview against real content instead of the mock, link the theme into a Poops docs site (e.g. the Poops example) and build there:
# in this repo
npm link
# in the consuming site
npm link poops-docs-theme