Imported from jdegreef/ochorus (
.claude/skills/page-design/SKILL.md). Install upstream withnpx skills add jdegreef/ochorus --skill page-design. Copyright stays with the author.
Ochorus page design — one anatomy per page kind
STYLE_GUIDE.md governs atoms: tokens, type scale, buttons, cards, fields.
This skill governs pages: which atoms go where, in what order, and which
existing page to copy. It exists because the atoms were already consistent in
September 2026 and the pages still weren't — the site had 40 public routes, and
a reader walking Books → Sermons → Quotes → Articles met four page-header
shapes, three top paddings, two <title> suffixes, three breadcrumb trails,
eight empty-state renderings and section headings at four sizes. Every one of
those pages used the right tokens. What they didn't share was an anatomy.
Rule 0 — copy the model page, don't compose from the guide
Every new page is one of four kinds. Open the model for its kind, copy its
skeleton (shell → header → controls → body → states → <Seo>), and only then
layer in what is special about the new page. A page built up from the style
guide's parts list will match the guide and still not match its neighbours.
| Kind | What it is | Model | Also look at |
|---|---|---|---|
| Browse shelf | An index of one content type: /books /sermons /topics /plans /biographies /quotes /scripture /articles /search |
routes/topics/+page.svelte for the bare shell; lib/components/BooksShelf.svelte for a filtered shelf |
Biographies for URL-backed filters + FilterSummary + EmptyState with a clear action |
| Leaf page | One item, reached from a shelf: /books/[slug] /authors/[slug] /plans/[slug] /topics/[slug] /quotes/[author] /articles/[slug] /scripture/… |
routes/books/[slug]/+page.svelte |
Sermon page for the kind eyebrow and lang= on the title |
| Reading surface | The text itself: /books/[slug]/[order], /sermons/[slug] |
The chapter reader | Answers to --reading-measure, not .page-col (STYLE_GUIDE §3) |
| Utility / prose | /about /contact /legal /login /reset-password /settings /notebook /+error |
Settings (app-like) or About (prose) | These are the least consistent set today — see the backlog |
Anatomy of a browse shelf
In this order, and nothing else at the top level:
- Shell —
<div class="page-col px-5 py-10">.py-10, notpy-6/py-8. (Twelve shells saypy-10, six say something else; the content edge should not jump vertically on navigation.) <PageHeader>— always.titleis the nav word (Books,Topics,Reading Plans);taglineis one sentence;metais the total, formattedN books · M authors.eyebrowis only for a page whose h1 is not the nav word (Sermons: eyebrowSermons, titleThe Preached Word). Pick one register per page and keep<title>= the nav word either way.<CatalogLanguageNudge kind=…>— when the current language has fewer rows than English. Extend itskindunion rather than skipping it.- Secondary section (optional) —
h2.section-labelabove it (Continue reading,Sermon of the week), hidden while the reader is filtering. .filter-row— controls in this order: free-text.filter-field.grow(placeholderFilter by …) · scope<select class="filter-field">· sort<select>· group.seg· then a.chiprow underneath for taxonomy. No visible "Sort:" / "Length:" label — put it inaria-label. No "Clear" button in the row: clearing isFilterSummary's job.<FilterSummary>— rendered only while a filter is active, withonClear. Never "Showing 35 of 35".- The list — one card family per content type (§ Cards below). Grouped
shelves head each group with the group heading recipe (§ below), in a
<section>whosescroll-margin-toptracks the sticky bar, notscroll-mt-20. - States — three, all
<EmptyState>:- no rows in this language →
message+action= "Read the English library"; - filtered to nothing →
message+action= clear filters; - load failed →
message={t('common.loadError')} onRetry. Never a bare<p class="py-16 text-center">, never a hand-rolled panel. The no-rows action is usually just<CatalogLanguageNudge kind=…>above the list, not a second button inside the panel: the nudge already renders on an empty shelf (it shows whenever English has more items), so a read-English button in the EmptyState too is redundant — that's why Topics/Plans/Sermons carry only the nudge. Extend the nudge'skindunion rather than skipping a surface. On an English-only hub the error message still localises (t('common.loadError')— it is chrome), while the empty message may stay an English literal (it describes English-only content; F3 tracks localising those).
- no rows in this language →
- Data — the loader routes the shelf's PRIMARY list through
loadShelf()so a failed fetch is reported (aloadErrorthe page turns into<EmptyState onRetry>), not crashed to the 500 route or baked as a false empty shelf. Only the primary list: decoration keeps its own silent try/catch — the biographies cover-strip books degrade to[]so the bios still render, and routing them throughloadShelfwould either raise the whole-page error panel when only a strip failed or leave aloadErrorno one reads. Filter values that describe what is shown live in the URL viaurlFilters(); view preferences (grid/list, sort) live in localStorage. <Seo>— not a hand-written<svelte:head>. TitleBooks — Ochorus(em dash). Description falls back to a localized string, never empty.
No visible breadcrumb on a top-level shelf. Biographies removed its trail
first, with the reason on file; Quotes, Scripture and Articles followed in
#1430, so none of the shelves shows one now. Keep the BreadcrumbList JSON-LD —
build it with breadcrumbLd(crumbs) (§ leaf-page step 9).
Anatomy of a leaf page
- Shell —
page-col px-5 py-10. <Breadcrumb items={crumbs}>—Home › Section › This page, and the samecrumbsarray feeds the JSON-LD. Never hand-roll the<nav>; the three that did (author, sermon, reader) each ended up with a different trail, and the sermon's visible trail contradicts its structured data.- Head —
- kind eyebrow:
.eyebrowreadingKIND · N UNITS · TIME(Sermon · 12 min · 1855;Book · 7 chapters · 34 min); every leaf gets one, not just the sermon and the chapter; <h1 class="text-h1" lang={contentLang(item.language)}>—langfrom the served language, so a fallback edition is announced correctly;- byline as a link to
/authors/<slug>(the quotes page currently has the name only in the h1 and no link); <SourceBadge>directly under the byline wheneversource_type === 'ai_unreviewed'— the reading surfaces must show it too;- hero visual on the end side: cover, portrait, plate or
CoverStrip.
- kind eyebrow:
- Action row —
.btn.btn-primaryfor the one start verb (Begin reading,Start the plan,Start with <book>),<FavoriteButton>(the component, not abtn-iconheart),.btn.btn-ghostforSearch in this …. All the same size. A finished/complete state is a muted status line, not a<p>wearing.btn. - Summary —
h2.text-h3+ prose (About this book). One treatment, not a heading here and a bordered "In brief" callout there. - Sections —
h2.section-labelabove a list (contents, quotes, passages);h2.text-h3above prose. A count in a heading is(N)intext-small font-normal text-muted, or absent — not both styles. - Related — capped at 4–6, rendered with the content type's card
component (
BookCard,SermonCard,PersonCard), headedMore …. Never an unbounded<ul>of text links. - Source line —
mt-8 text-small text-muted, no rule above it. <Seo>withogImage(the cover twin, a portrait, or the section's/og/<section>.png) and both the item's LD and the BreadcrumbList. Build the BreadcrumbList withbreadcrumbLd(crumbs)from$lib/seo(feeds off the same{name, href}array as the visible<Breadcrumb>) — never hand-writejsonLd(breadcrumb(crumbs.map(...))), whosec.href→c.urlretype ships a broken trail with no error.
Language fallback is one policy. localized() serves English when the
language has no row; the page must then say so ("Not yet available in
Swahili — showing the English edition"), and no page 404s instead.
Reading surfaces and utility pages
The chapter reader and the sermon page are governed by --reading-measure
and their own chrome (STYLE_GUIDE §3, §5). What they still share with leaf
pages: the breadcrumb rule (2), the eyebrow (3), SourceBadge, prev/next as
a .btn pair (ghost previous, soft-primary next), and one shared
.focus-exit pill rather than three pasted copies.
Two reader gotchas (both fixed in #2906, both scroll-vs-paged specific):
- Paged mode slices the last line.
.paged .pagerfills each CSS column withcolumn-fill: auto, which starts a line whenever its TOP fits and lets the bottom spill past the content box;article.paged'soverflow: hiddenthen clips it into unreadable letter-tops — and that half-line is genuinely lost (multicol assigns it to one column, it does NOT repeat on the next page). Fix = reserve one prose line-height of bottom padding on the pager, sized ascalc(1.18rem * var(--reading-scale,1) * var(--reading-leading,1.85) + …)so the slack tracks the reader's Size/Spacing. Never a fixed rem/em —emon the pager is base 16px, not the reading size. - Scroll mode slices the same line differently: the fixed translucent
.progress-footbar the text scrolls under has a hard top edge. Fix = a short transparent→--bgscrim anchoredbottom:100%of the bar (scroll mode only; paged is handled by the padding above). Spacing above the chapter title cluster is scroll-only too — scope witharticle:not(.paged), since page mode zeroes the article padding and paginates from the top. - Verifying the reader locally against origin/main: run the worktree
frontend on port 5180 (the backend's CORS allowlist is 5173/5180 only) vs
the local seeded backend on :8000; the prod API blocks CORS from localhost.
The browser pane caches the CSP document, so after a
csp.config/env change navigate with a?cb=<n>cache-buster or the oldconnect-srckeeps blocking.
Settings and Notebook are app pages: page-col px-5 py-10, <PageHeader>
(eyebrow Ochorus is not an eyebrow — use the section name or none), sections
as h2.text-h2, sub-sections h3.text-h3, every control from the .btn /
.field / .seg / .chip families without scoped overrides. About,
Contact and Legal are prose at their own measure; that measure needs a real
class — .reading-page is referenced by two of them and defined nowhere.
Cards and hover
| Content | Card | Hover |
|---|---|---|
| Book | .book-card (grid) / .book-card--row (row) |
overlay + border |
| Topic, Plan | <ShelfCard> |
lift −2px + shadow |
| Sermon on the shelf | <SermonCard variant="row"> |
hue-mixed border, no lift |
| Author | <AuthorBioCard> one-per-row |
border-accent |
| Article, Quotes-author | should be .book-card--row-family rows |
row tint |
Two hover languages only: lift for banded cards, border tint for rows.
A card directly under the <h1> titles itself with <h2>; a card under a
group <h2> uses <h3>. Home shelves, the error page's "three to try" and the
plans-progress panel must use these same components, not re-drawn tiles.
Group heading (grouped shelves, search result groups)
One component: <GroupHeading> (lib/components/GroupHeading.svelte) —
h2.mb-4.flex.items-center.gap-2.5.text-h3.text-muted, an optional 32px portrait,
the name as a link when it has a page (href), and the count on the shared
.count class (tabular figures + muted). Books, Sermons and the search-result
groups render the default variant; Biographies passes sticky for its bordered
era heading (solid ink, pinned via --pinned-offset, count pushed to the end),
and a detail snippet carries the era's year range or Search's bespoke "N of M".
<SectionHeader> is not this — it renders h2.text-h2 and is the home page's
"shelf title + See all" pattern.
The topic leaf page also uses <GroupHeading>: its Books section groups the
books by author when the topic is author-clustered — most of its books share
an author with another (the Puritans) — and stays a flat book-grid otherwise
(a diverse gallery like Women of Faith). The rule is one tested pure helper,
lib/topicBookGroups.ts (groupBooksByAuthor → groups | null); the
section-label "Books" heading stays, with the author GroupHeadings nested
under it. This is the adaptive-shared-anatomy approach — adapt from data, not a
per-topic bespoke layout — extend the topic page the same way, never with a
hand-built one-off. (The bare author-Map reduction now has three copies —
here, sermons/+page.svelte, BooksShelf.svelte — but the topic one's null
gate is topic-specific; a shared low-level groupByAuthor is a deferred
refactor, not this pattern's job.)
Same shape for the order of the content sections: Books / Sermons /
Articles are rendered in prominence order — the topic leads with the type it
has the most of and drops any that are empty (lib/topicSections.ts), so a
sermon-heavy topic (The Gospel Call, Christ & the Cross) surfaces its sermons
first and an article-heavy one (The Deeper Life, 21 articles) its articles,
while a balanced topic keeps the familiar Books → Sermons → Articles order.
Verifying a page in the in-app Browser pane: npm run dev binds IPv6
[::1] only, but the pane resolves localhost→IPv4, so navigation fails with
"denied or failed" — start it --host 127.0.0.1, and export
PUBLIC_API_BASE_URL so the dev CSP admits the API (see verify-local).
Chrome parity — the checklist for a new content type
A content type is not shipped until it is reachable from every surface that lists content types, in the same order everywhere:
- top nav
NAVinroutes/+layout.svelte(if it earns a nav slot) - footer Explore group (same file)
-
COMMANDSinlib/components/CommandPalette.svelte -
SearchHitkinds inlib/library-public.ts+ the search page's facet rail (Articles: done —ArticleHit+ facet rail, see F1. Scripture/Quotes still absent by design.) -
lib/sitemap.tsstatic pages (nav order) and a sitemap section — for a prerendered URL family (e.g. a topic-filtered shelf), the route'sentries()and the sitemap section must advertise the same set, orprerenderCoverage.test.tsfails on the URL that was advertised but never built -
CatalogLanguageNudge'skindunion - a
/og/<section>.pngcard for pages without their own image - the guard lists in
lib/pageShell.test.ts(BROWSE_PAGES/LEAF_PAGES)
Articles, Scripture and Quotes each failed several of these: footer-only, absent from the palette and from search.
Adding a topic shelf (a topic ROW, not a new route)
The /topics/[slug] page and its chips are generated from data, so a new
shelf is one seed edit — but three separate build-time asset sets are each
guarded by a test that fails ONE AT A TIME, so you rediscover them the slow
way unless you regen all three up front:
-
backend/library/topic_seed.py— theTOPICStuple (slug, title, description, [book slugs]), plusTOPIC_SERMONS/TOPIC_SCRIPTUREif wanted. Append LAST: a plate-cover book draws the emblem of the first topic (seed order) that holds it, so inserting earlier silently re-skins existing covers. No migration — topics are seed data. - English-only shelf → add the slug to
TRANSLATION_PENDINGin the same file, or the per-language coverage guard demands prose in all 7 langs. - emblem identity in
frontend/src/lib/emblemNames.tsTOPIC_META(accent+ a uniqueemblemwith ≥3 colours inemblems.tsEMBLEM_ART, peremblems.test.ts) — thennpm run emblem:art(writesbackend/library/data/emblems/topics.json+<emblem>.svg, read bycovers.py) andnpm run emblem:hues(emblemHues.ts).emblemArt.test.ts/emblemHues.test.tsfail on drift. Omit theTOPIC_METAentry and the shelf uses a graceful fallback emblem — no art, no regen — but a flagship shelf earns its own. -
node scripts/generate-topic-og.mjs(=npm run og:topics) — the OG share card PNG +static/og/topics/og-manifest.jsonentry;tests_fixture.TopicShareCardTests(two tests) fail without it.
Check is_published on PROD before choosing members — the fixture holds
UNPUBLISHED works. A shelf shows only its published members, and no test
guards which ones you list, so an unpublished member just silently vanishes
from the live shelf. Verify each intended slug against the LIVE API
(/api/library/books/<slug>/?language=en → 200 = published; 404 = not) —
is_published is create-only in the seed and toggled live via the admin, so
the fixture value is not authoritative for prod. (Shipped a "For Teens" shelf
whose two most on-topic books, the-body-of-christ-teens and if, were
unpublished and so absent live — #2373.)
A curated PLAN (plan_seed.CURATED_PLANS) is created ONLY in a language
where EVERY source book is published there — one unpublished source book and
seed_plans silently skips it forever (logs the bland "Plans already seeded
(or source books missing)"), so the plan never appears on prod even though CI
was green. So: (a) pick source books confirmed 200-live, (b) keep the plan
English-only by anchoring on a published English-only book (else it becomes
creatable in other langs and the coverage guard demands plan_translations),
and (c) verify creation locally — nothing asserts a specific new plan
exists, so seed a DB and run it:
manage.py seed_if_empty && manage.py seed_plans then check
Plan.objects.filter(slug=…) has the row with its days. (The teen plan seeded
green in CI but never went live because two of its three books were unpublished
— #2380 reworked it onto published books.)
Copy conventions
<title>:{Page} — Ochorus— em dash with spaces, everywhere. Leaf pages{Title} — {Author} — Ochorus. Not· Ochorus.- A copy fix to a string that lives in a catalogue must touch all eight
messages/*.json, not justen.json. The title suffix rides inside translatable keys (book_title_tag), so fixing English alone leaves the book reading— Ochorusin English and· Ochorusin Swahili — invisible until a reader of that language opens the tab.messageCatalogues.test.tsnow fails any catalogue carrying· Ochorus; add the same shape of guard when you normalise the next catalogue-borne label. A value-only edit needs nosync:cataloguesunless it translates an English placeholder (that snapshot tracks missing and pending keys, not values). - A NEW key you add to
en.jsonneeds a value in all eight catalogues (parity), andmessages.test.tsalso fails any non-English value that is byte-identical to English — so a bare label with nothing to translate ("10–30 min":minis the standard minute abbrev in es and pt) trips the guard for those locales even when it's correct. That's whatSAME_AS_ENGLISH_OK(frontend/scripts/same-as-english.mjs) is for — add the key there (notPENDING_TRANSLATION, which is for real un-done debt). The test throws on the first failing locale (es), so a second, silent collision (pt) waits behind it: fix the class, not the one it named. (Length-filter labels, #1899.)
- A copy fix to a string that lives in a catalogue must touch all eight
- Counts:
N books · M authors(middle dot, spaces). Never "Showing N of N". - Chrome strings — crumbs, "Home", "Read", "Topics:", plurals — go through
t()/m.*()even on an English-only hub; only content may be literal. - Icons come from
<Icon name>; never▦ ☰ ✕ ▶ ✓ ♥ 🔖 📝 ✦ →as glyphs. - Compact buttons are
.btn-sm, neverpy-1.5/py-2on.btn.
Guards to extend when a page ships
Add a new route to lib/pageShell.test.ts — BROWSE_PAGES (shell + PageHeader
- the
page-col px-5 py-10padding) orLEAF_PAGES(shell only). When a page delegates its shell to a component (asbookspoints atBooksShelf.svelte, andarticlesatArticleDetail/ArticleTopicShelf), point the guard entry at the component file, not the thin+page.svelte— the guard greps that file for.page-col/<PageHeader>, so a router that only{#if}-switches between components carries neither. Five source-text guards now scan every.svelte(admin exempt) — EXTEND them, don't re-add:
pageShell.test.ts—.page-col,<PageHeader>on browse, thepy-10shell padding, and nomx-auto max-w-{2xl…7xl}shell.typeScaleGuard.test.ts— notext-[…]; no scoped literalfont-size;.text-display= home hero only;.text-h1= the page<h1>only, never an<h2>–<h6>; and no Tailwind default size (text-base/-lg/-xl/-6xl…).typeScaleGuard.test.tsalso forbidsvar(--radius-*, …)fallbacks (a fallback fires only on a MISSING token, so it hid the never-defined--radius-chip).systemClasses.test.ts— no scoped<style>redefinition of.btn/.field/.seg/.chip/.eyebrow/.section-label/.count/.page-col; add a modifier inapp.cssinstead (.btn-sm,.eyebrow-micro). Exempts admin +settings/+page.svelte(its.segis the E1 deferral — remove the exemption when E1 lands).colorTokens.test.ts(no raw hex) andrtl.test.ts(physical properties).
Still unguarded (deliberately or on the backlog): literal transition durations
— the remaining literals (a one-shot celebration keyframe, the spinner period,
the reduced-motion override) are non-transition timings that belong OFF the
three --duration-* tokens, so a duration guard would false-positive; and
hard-coded English on the English-only hubs (F3).
Re-auditing (how the backlog was produced)
Three read-only agents in parallel, one per page kind — browse shelves, leaf
pages, chrome + utility pages + app.css + the guard tests — each asked for a
comparison matrix (shell, header, filters, counts, states, headings, cards,
related, badge, SEO) and then only the cells that differ without a stated
reason in the file. Grep-verify the headline counts afterwards (class="page-col…",
— Ochorus vs · Ochorus, <PageHeader, radius-chip, text-(6xl|lg|base)).
Pair it with a live-site pass at 1280px: one screenshot per shelf, one leaf of
each kind, the reader, the 404 and About. The in-app Browser pane cannot scroll
or hover while hidden — scroll with window.scrollTo via javascript_tool,
and expect a blank screenshot right after a JS scroll; read the footer with
read_page instead.
Converting an existing page onto the system
<svelte:head>→<Seo>is output-equivalent when the hand-written head is the standard set:title(also theog:titledefault),description(alsoog:description),canonical(alsoog:url), thehreflangalternates + x-default,og:type=website,og:image,twitter:card, and the JSON-LD blocks. Map an{#if x.length}{@html xLd}{/if}gate tostructuredData={x.length ? [xLd] : []}. A page that destructuredconst { alternates, xDefault } = hreflangAll(...)for the loop passes the whole object instead:const hreflang = hreflangAll(...). There is no shared canonical helper —${SITE_URL}${localizeHref(path)}is the repo-wide idiom (16 files);absUrl()is wrong here (it omits the locale prefix).<header>→<PageHeader>carries the eyebrow/title/tagline; keep a page hand-rolled only when its<h1>is genuinely composite (the era page's name + date-range badge), and then match PageHeader's metrics exactly (header mb-8,h1 text-h1 mb-2,tagline max-w-2xl text-body text-muted).- Verifying locally needs the Django API for data, so the browse pages that go
through
loadShelfshow their converted head above an EmptyState while the unguarded ones (still on the C1 backlog) hit the error route — both still prove the head/PageHeaderrendered. Confirm the head withjavascript_tool: one<title>, right canonical/og. On Node 25 the pure file-reading guards run undernpx vitest run --environment node <files>(the jsdom store suite needs the pinned Node 22 — CI has it). - A fresh worktree has no
frontend/node_modules. Do a realnpm install --prefer-offline --no-audit --no-fundin the worktree (~6s warm, and it compiles paraglide) — do NOT symlink the main checkout'snode_modules, which breaks rolldown's realpath resolution (Could not resolve 'node:module'/Tsconfig not found) and fails every vitest/config load.npm run checkthen still errors once onconfig.ts($env/static/public has no exported member PUBLIC_API_BASE_URL) until youcpthe main checkout'sfrontend/.envin; that one error is env-only, not your diff.
Verify before merge
Open the new page and its two neighbours (the shelf it hangs off, and a
sibling leaf) in lamplight, paper and sepia; at /ar/… for direction; at 375px
for the filter row. The question is not "does this page follow the guide" but
"can you tell where this page ends and the next begins".
Backlog — design-consistency audit, 2026-09-04
Findings from a code + live-site pass over all 40 public routes. Grouped;
within a group, most reader-visible first. → names the model to converge on.
Tick and date an item when its PR merges; append new drift at the end of the
relevant group.
Reconciled 2026-09-08. Markers below were re-checked against
mainand flipped to match merged PRs; three E-group items are[~]because only part shipped (see each). This mirrors the STYLE_GUIDE §9 reconciliation of the same date.
A. Shells, headers, titles
- A1 (shipped #1419 — quotes/scripture/articles on
<PageHeader>+ BROWSE_PAGES; era kept hand-rolled as the composite-title exception; settings/notebook are A9) Four hand-rolled<header>blocks —biographies/era/[era],quotes,scripture,articles— each with different margins; Scripture's tagline istext-small. →<PageHeader>(Topics); add all four toBROWSE_PAGES. - A2 (shipped #1419 — folded into
.page-col px-5 py-10, guarded) Shell padding ispy-10on 12 pages,py-6on 5 (quotes ×2, scripture ×3, articles ×2),py-8on Biographies; breadcrumb-to-title gap ismt-5(book),mt-4(topic), 0 elsewhere. →py-10, no extra gap; or fold the padding into.page-col. - A3 H1 register: Sermons/Biographies carry a brand h1 under a nav-word
eyebrow; Books/Topics/Plans/Search use the nav word; Quotes/Scripture use a
descriptive sentence with no eyebrow.
<title>≠<h1>on Sermons and Biographies. → decide one register, document it in STYLE_GUIDE §5. - A4 (shipped #1419 — all 8 catalogues, guarded)
<title>suffix:— Ochorus×39,· Ochorus×7 (book, quotes, articles, scripture ×2, era mixes both). →— Ochorus, and put the suffix inSeo.svelteso nobody types it. - A5 (shipped #1419) Five browse pages hand-write
<svelte:head>(books, biographies, topics, plans, sermons); five use<Seo>. →<Seo>. - A6 (shipped #1430 — visible trail dropped from the three index hubs, JSON-LD kept) Quotes/Scripture/Articles indexes show a
Home › Xbreadcrumb; the nav'd shelves don't (Biographies removed it, reason on file). → drop the visible trail, keep JSON-LD. - A7 (shipped #1430 — one
crumbsarray feeds both<Breadcrumb>and the head on Author/Sermon/Reader; JSON-LD side then folded intobreadcrumbLd(crumbs)in #1431) Author, Sermon and Reader hand-roll their breadcrumb<nav>with three different trails (with/without Home, with/without the current item); Sermon's visible trail contradicts its BreadcrumbList. →<Breadcrumb>with onecrumbsarray feeding both (Book). - A8 (shipped #1540 — kind eyebrow on the book and plan heads) The kind eyebrow (
Sermon · 12 min · 1855) exists on Sermon and Reader only. → every leaf (Book:Book · 7 chapters · 34 min; Plan:Reading plan · 27 days). - A9 Settings and Notebook hand-roll headers; eyebrows are
Ochorus(legal, notebook), the nav word (about, contact), or none. →<PageHeader>; section name or no eyebrow. - A10 (shipped #1537 —
.reading-page, the one prose-page shell) Leaf prose measure is hand-set:max-w-[40rem]×3 on Author,max-w-xlon Plan and Book,max-w-2xlon Quotes..reading-pageon About/Legal is defined nowhere. → one.prose-measureclass (or--reading-measure). (Articles: the list cap is gone — it fills.page-collike Sermons; the article prose sits at--reading-measurewith<ReaderControls>like the sermon/bio, but its.article-bodyhand-consumes the--reading-*vars — a fourth copy of that recipe to fold into the shared class when this lands.) - A11 Login/Reset use
mx-auto max-w-[26rem], invisible to the shell guard (regex only matchesmax-w-2xl…7xl). →.page-col--narrow; widen the regex.
B. Filters, counts, state
- B1 (shipped #1432 — Sermons & Plans filters live in the URL) Sermons'
q/bookand Plans'lengthare local$state, so a filtered shelf can't be shared or returned to with Back. →urlFilters()(Biographies). - B2 (shipped #1470 — one clear-filters affordance) "Clear filters" has three shapes:
FilterSummarylink (Books), link- ghost button in the EmptyState (Biographies), a
Clearghost button inside the filter row with its own copy key (Sermons). → FilterSummaryonCleareverywhere; retiresermons.clear.
- ghost button in the EmptyState (Biographies), a
- B3 (shipped #1470 — count only while filtering) Result counts in five formats; Biographies renders "Showing 35 of 35
writers" unfiltered; Topics/Plans show none; Quotes buries the total in the
tagline. →
PageHeadermetafor the total,FilterSummaryonly when active. - B4 (shipped #1519 — seg order made consistent) Default grouping: Books
all, Sermonspreacher, with the same seg order on both (so Books' default is the second option). → one default; the default option first. - B5 (shipped #1519 — sort/label consistency) Sort is a
<select>on three shelves and a labelled.segon Search; visibleLength:/Sort:labels exist only on Plans/Search. →<select>+aria-label. - B6 Only Biographies pins its filter bar and collapses it on mobile;
Books has more controls and neither. Books/Sermons hard-code
scroll-mt-20where Biographies/Search measure the bar. → aFilterBarcomponent. - B7 (shipped #1519 — count badge) Count badges beside labels are styled six ways (
opacity-60,tabular-nums opacity-70,text-small font-normal text-muted, an accent-soft pill,text-muted/70,text-eyebrow). → one.countrecipe.
C. Empty, error, loading
- C1 (shipped #1424) Load failures: Biographies, era and Quotes loaders are unguarded and
crash to the error route; Articles and Scripture swallow the error and claim
the shelf is empty — the exact anti-pattern
loadShelf()was written to end. →loadShelf+EmptyState onRetry(Topics). - [~] C2 (partly shipped #1424 — articles/quotes/era on
<EmptyState>; the drawer/popovercompactvariants + Search's panel remain) Eight empty-state renderings. Filtered-to-nothing is a bare<p class="py-16 text-center">on Books, era and Search; Sermons' EmptyState says "try clearing the filters" with no button; Search hand-rolls a copy of the EmptyState panel. →<EmptyState>with anaction(Biographies); add acompactprop for drawers/popovers. - C3 (shipped #1424) Zero rows in a language: Books offers "Read the English library",
Plans/Sermons get the nudge, Topics gets a dead-end message, Biographies gets
nothing. → extend
CatalogLanguageNudgetotopics/authors; action on all. - C4 (shipped #1536 — keeps the label +
aria-busy; verified onmain) Loading: Search's "Show more" replaces its label with…(STYLE_GUIDE §6 forbids this); seven surfaces show a bare…paragraph. → keep the label.btn-spinner+aria-busy; one<LoadingLine>usingt('common.loading').
D. Sections, cards, related
- D1 (shipped 2026-09-08 — heading size set by role, not page. Leaf-page
content-list headings moved
.text-h3→.section-label(books: Contents / People / Related; authors: Books-by-X (N) / Sermons-by-X (N) / Appears-in / More-lives; sermons: More-on-X; ArticleDetail: Read-next) — the shelf model. Prose sub-sections stay.text-h3(books "About this book"). Contact's two section blocks moved.text-h3→.text-h2to match About/Settings/Legal.<SectionHeader>already renders.text-h2. Group headings decided at.text-h3and left for D2 (grouped shelves + search result groups keep the text-h3 recipe). typeScaleGuard already reserves.text-h1for the<h1>; a list-vs-prose role check is not reliably automatable, so none was added.) An<h2>renders at four sizes:.text-h1via<SectionHeader>on Home,.text-h2on Settings/About,.text-h3on leaf pages,.section-labelon shelves — 37 distinct class combinations. →.section-labelabove a list,.text-h3above prose,SectionHeaderdrops to.text-h2;.text-h1is the<h1>only. - D2 (shipped 2026-09-08 — one
<GroupHeading>component on the sermons recipe (.text-h3muted, optional portrait, linked name,.count) replaced all four hand-rolled headings: Books, Sermons, Biographies (itsstickyvariant) and the search-result groups. Search dropped.section-labeland Biographies' count converged onto.count.) Grouped-shelf headings are hand-rolled four ways (Books plain text, Sermons portrait + link, Biographies stickytext-text, Searchsection-label). → the sermons recipe as aGroupHeading. - D3 (shipped 2026-09-08 — two hover recipes now, and only two:
.card-lift(grid/banded rise) and.card-tint(row warm-in-place), shared opt-in classes inapp.csson--duration-fast, each documented in STYLE_GUIDE §5. Every audited card wears one: book/shelf/library + continue-reading resume lift; sermon-row/card, article,AuthorBioCard,PersonCard,AuthorTile,BookListRowand the/quotesauthor card tint. PersonCard (border-only) and AuthorTile (bg-only) converged; the quotes card gained its missing ground shift. The book-cover "Begin reading →" plate is kept as a documented cover signature, not a second hover language.) Card hover: three lift depths and four colour treatments across.shelf-card,.article-card,.sermon-row, the quotes card,AuthorBioCard,BookListRow,PersonCard,AuthorTile. → lift for banded cards, border tint for rows; nothing else. - D4
ArticleCardis bespoke:border-radius: 0.75rem,0.15sliteral transitions, literalRead →, an<h3>directly under the<h1>. The quotes index card has no heading at all;BookCard's title is a<div>. → rebuild on the row family;<h2>under the h1. - D5 Home shelves re-draw cards that have components:
ContinueReading(bespoke row + scoped gradient) vs.book-card--row;PlansProgressvsShelfCard; the error page's "three to try" as bare covers with floating captions. → the components. - D6 Related blocks use five different components; the sermon's "More sermons on X" is an unbounded text list; Plan, Topic, Quotes and Scripture have none. → card components, capped at 4–6 (Book).
- D7 (shipped 2026-09-08 —
FavoriteButtonrebuilt on the.btnfamily: labelled.btn-smon leaf action rows, icon-only.btn-iconin reader toolbars; scoped CSS + literal durations deleted. All three leaf pages now passshowLabel; the sermon page dropped its hand-rolledbtn-iconheart for<FavoriteButton kind="sermon" …>. Ships with J5.) Favorite:FavoriteButtonis outside the.btnfamily (own padding,0.15sliterals); the sermon page uses abtn-icon btn-ghostheart instead; only Author passesshowLabel. → rebuild on.btn.btn-sm; one label policy. - D8 Action rows: "Search in this X" is
btn-ghost(Book),btn-sm btn-ghost(Author), a text link with→(Topic); Author has no.btn-primaryalthough it has a start verb; Plan's finished state is<p class="btn btn-ghost">✓ Finished</p>. → Book's row. - D9 Topic/scripture chip rows are hand-rolled eight times in three
colourways (bordered muted, bordered text, filled surface-2) with three label
styles; Sermon's scripture chips always link to search where Book's link to
/scripture/…. →.chip+.eyebrowlabel; shared href logic. (partial, #2426: the two shelf topic-filter rows — Books + Sermons — are now the sharedTopicFilterRow.svelte(.chip-scrollercontainer +.eyebrowlabel, propstopics/selected/onSelect). Reuse it for any new shelf filter; the/topicsbrowse chips and sermon scripture chips remain. Not to be confused withTopicChips.svelte, the "Browse by topic" anchor-link section.) - D10 Prev/next: a
.btnpair in the reader, bespoke bordered cards on the sermon page. The exit-focus pill is pasted verbatim into Author, Sermon and Reader. → the reader's pair; one.focus-exitclass. - D11 (shipped #1427 —
SourceBadgein the chapter reader and on author bios)SourceBadgesits under the byline on Book, after the text card on Sermon, and is absent from the chapter reader and the author bio — anai_unreviewedchapter never shows "awaiting native review" (CLAUDE.md rule). → under the byline everywhere; add to the reader eyebrow line. - D12 Language fallback:
localized()silently serves English with no notice on six pages; Topic 404s instead; Book's<h1>has nolang; the reader's JSON-LDinLanguagereports the UI locale, not the served edition. → fallback + visible notice +langfrom the served language, everywhere. - D13 Summary: Book's "About this book" is an
h2+ prose; Sermon's "In brief" is a bordered callout with an accent eyebrow andtext-smallbody. → one treatment. - D14 (shipped #1526 — documented
.eyebrow-micro) Sermon page overrides.eyebrowto--fs-microfour times;SermonCarddoes it once more. → plain.eyebrow.
E. Tokens and system classes
- E1 Settings re-declares
.segin scoped CSS as a pill group, losing the--border-strongedge — the same toggle looks different on Settings and Sermons. → delete; a.seg--pillmodifier inapp.cssif wanted. - [~] E2 (partly shipped #1522 — radii back to the four and the phantom
--radius-chipfallback gone; the bespoke30rem(SermonPlate) /34rem(Scripture) breakpoints remain) Radii: ten values in use against the guide's four, includingvar(--radius-chip, 0.4rem)on Quotes and Scripture where--radius-chipis defined nowhere, so the fallback always fires. Bespoke breakpoints30rem(SermonPlate) and34rem(Scripture). →--radius-sm/--radius-card; Tailwind breakpoints. - [~] E3 (partly shipped —
ArticleCard/FavoriteButtondurations on the token; the reader (0.15s/1.1s) andapp.cssbtn-spin(0.7s) literals remain) Literal durations survive inArticleCard,FavoriteButton, the reader (0.15s,1.1s) andapp.css(btn-spin 0.7s). →--duration-*. - E4 Two token vocabularies in scoped CSS (
--color-borderon Topic, Quotes, Article, Scripture; bare--borderon Author, Sermon and the components) plus re-declared font-stack fallbacks (var(--font-display, Georgia, serif)). → bare tokens, no fallbacks. - E5 (shipped #1530 — icon-only controls use
<Icon>; the residual glyphs are a decorative separator and admin (exempt)) Unicode glyphs as icons in eleven places (▦ ☰view toggle,✕recent-search chips,▶×2,✓,♥×2,🔖 📝,✦,→in "see all" links); the hero search<svg>is stroke 2. →<Icon>; addcheck,note,arrow-right. - E6 Two text inputs bypass
.field(home hero, command palette). Login duplicates.btnas.google-btn, declares.mail-badgetwice, and its password-reveal control is pasted into Reset.PwaToastsships a solidbg: var(--accent)button. →.field,.btn,.btn-primary. - E7 (shipped #1526 — compact buttons on
.btn-sm) Compact ghost buttons hand-rollpy-1.5/py-2on Settings ×4, Sermons andCatalogLanguageNudge. →.btn-sm. - E8 Near-duplicate classes:
.footer-heading≈.section-label,.sermon-row-ref≈.eyebrow,.navsearch≈ a pill.field; the topic hero still carries its own weaker.hue-band..stat-numberis admin-only but lives with the public classes;--hl-*are fixed hex that don't follow the theme. → fold or document. - [~] E9 (partly shipped #1522 — the guard now names them;
text-6xl(404) andtext-base(settings/ReaderControls) remain as documented exceptions) Off-scale Tailwind sizes slip the type guard:text-6xl(error page),text-lg,text-base×2. →--fs-*; extend the guard.
F. Chrome and reachability
- F1 Articles, Scripture and Quotes are footer-only (English): absent
from the command palette and from search (
SearchHithas no such kinds). → paletteCOMMANDS; anarticlehit kind. Search half done (claude/ochorus-dev-srch1-search-articles):ArticleHitadded end to end — backendsearch.pyentity branch + caps + per-type page,SearchHitunion, search-page facet rail, palettehitItem(),type/groupcatalogue keys ×8. English-gated by the per-languageArticlefilter (no hardencheck — a future translation ungates itself). Product call: Articles only; Scripture and Quotes are deferred to their own treatment — Scripture has no model (pages are synthesised from citations, and a reference query already routes to scripture-engaging sermons/chapters), and Quotes are review-gated sourced sentences aggregated into hub pages, not search entities. Flip to[x]once the palette-COMMANDShalf (claude/ochorus-dev-c3b9a9-reachability) also lands on main. - F2 (shipped
claude/ochorus-dev-f2-chrome-order) nav, footer and palette now derive their content-type order from one list ($lib/contentNav:PRIMARY_NAV+ the English-onlyENGLISH_HUBS), so the three can't drift;contentNav.test.tspins the order. The three already agreed by the time this ran — the fix removes the triple-hardcoding that let them drift. The sitemap is deliberately left out: its section order answers a crawl / per-locale-coverage question (chapters-* first, topics/plans folded intopages), not a nav one — documented insitemap.tsrather than force-fit. - F3 (shipped
claude/ochorus-dev-f3-hub-i18n) The chrome on the Articles, Scripture and Quotes hubs —Home/hub crumbs (which also feed the BreadcrumbList JSON-LD), CTAs (Read →,Browse by topic →,All of …), section labels,quotation(s)/passage(s)/writer(s)counters, empty states, aria-labels,Copy/Copied— now goes throught(), filled for all 8 advertised locales (≈47 new keys). Reuses existing keys where they exist (common.home, thenav.*words,reader.previous/next,search.type*). Pluralisation follows the_one/_manyconvention; scripture counts are always plural (a page exists only above the citation floor). The "deliberately English" comments on the scripture pages / CitingPassages are updated: the passage DATA and the SEO title/description prose stay English (they name / describe English-only content), but the surrounding chrome is catalogued. Scope note: the content-embedding META title/description prose was left English by decision — see the chrome-only split. Non-English strings are a first pass pending native review, like the rest of the catalogues. - F4 Meta: Plan and Topic can ship an empty description; slice lengths
are 155/250/300; the reader, articles and scripture pages emit no
og:image; the reader emits no BreadcrumbList. → localized fallback, one length, section OG cards (Book).
G. Guide and guards
- G1 STYLE_GUIDE drift: §5 says ghost buttons have a transparent border
(
app.cssgives them--border); §2 says body line-height 1.6 (app.css1.65); §5 describes a nav (About/Books/Biographies/Contact + language picker) and a three-block footer that no longer exist; §9 counts are stale. → fixed in the same PR as this skill (guide §5/§9 rewritten). - [~] G2 (mostly shipped 2026-09-05: the four routes added; a
systemClasses.test.ts; typeScaleGuard extended to Tailwind default sizes andvar(--radius-*,)fallbacks; a.text-h1-reservation and apy-10shell-padding guard. LEFT: literal durations — deliberately not guarded, see above — and solidbg-accent.) Guard gaps:pageShellomits Home, Articles ×2, the Quotes index and the auth pages, and itsmax-wregex missesmax-w-xl/max-w-[…];typeScaleGuardmisses Tailwind's default steps; nothing checks literal durations, undefined--radius-*fallbacks, scoped redefinition of.seg/.chip/.field/.btn/.eyebrow/.page-col, solidbg-accent, or hard-coded English. → add the routes; asystemClasses.test.ts; extendtypeScaleGuardto durations and radii fallbacks.
Suggested order
- A1 + A2 + A4 + A5 — mechanical, four pages, one afternoon; the biggest visible jump per line changed.
- C1 + C2 + C3 — the only items that strand a reader.
- B1 + B2 + B3 — one
urlFilterscall and aFilterSummaryeach. - A8 + D12 — leaf heads, and the unreviewed-translation gap (A7 shipped #1430; D11 shipped #1427).
- D1 + D2 — heading sizes; touches many files, best as its own PR.
- G2 — the guards, so none of the above regresses.
Backlog — second pass, 2026-09-05
A finer-grained pass over interaction/state and spacing/color/microcopy, run as
two read-only agents after the A–G sprint closed. These sit beneath the
structural items above: the pages now share an anatomy, so what's left is the
atom-level drift the shape guards don't yet see. Several sharpen an open A–G
item (cross-referenced with cf.); the rest are new. Same rules: tick and date
on merge, most reader-visible first within a group. Two agents independently
surfaced H2/I2/J1/K2/K5 — a good signal those are real, not noise.
H. Cards & hover (cf. D3)
- H1 (shipped 2026-09-08 with D3 — the documented two-recipe rule now
exists:
.card-lift/.card-tintshared classes inapp.css+ STYLE_GUIDE §5, on--duration-fast. The remaining stragglers converged —PersonCard(was border-only) andAuthorTile(was bg-only) now tint like.sermon-card, and the/quotesauthor card gained its ground shift. Row-card halves already shipped 2026-09-05.) Content cards hover in five languages:.book-card/.shelf-cardand the sermon-of-week plate lift;.sermon-row,.sermon-card,.article-card,.author-cardvariously tint border/bg or do nothing. → two recipes only — grid card lifts, row card tints (this is the concrete form of D3). - H2 Card interior padding is five values across five families
(
.book-card0.6rem,.sermon-card0.85rem,.shelf-card-body0.9rem,.article-card1.1rem,AuthorBioCardp-5) — none on a shared band. → snap top-4/p-5. - H3
AuthorBioCardis laid out two ways: one-per-rowspace-y-4on/biographiesvs a two-upgrid items-startonera/[era]— which also breaks §5's "a card grid must never setitems-start". → era copies the index'sspace-y-4.
I. Buttons & controls
- I1 (shipped 2026-09-05 —
.btn:disabledadded; a working button that marks itselfaria-busykeeps full opacity so the §6 spinner state still reads as working; guarded insystemClasses.test.ts) No.btn:disabledrule exists, so disabled Save/Sync/Export (settings), admin reloads and login resend render at full opacity with a normal cursor. →.btn:disabled{opacity:.5;cursor:default;pointer-events:none}. - I2 (shipped 2026-09-05 — the public
PwaToastsupdate/undo CTA →.btn btn-sm btn-primary;.pwa-cta+ itsfilter:brightnesshover (I6) deleted. The admin language trio is exempt; the plan completed-day is a selected-state checkbox marker, not a CTA — left solid on purpose) Solidbg-accentCTAs violate §5's soft-primary rule:PwaToastsinstall toast (public), the plan completed-day marker, the admin language trio. →.btn.btn-primary(cf. E6, G2's "solidbg-accent"). - I3 The busy-button pattern is split: spinner + kept label +
aria-busy(login/reset/search) vs label swapped to a gerund with no spinner (LanguageSettingsCard"Saving…", admin "Refreshing…"). → the spinner pattern (cf. C4). - I4
.google-btnre-implements.btnwith the default surface fill and no Google brand colour — not even the sanctioned vendor exception. →class="btn w-full"+ the glyph (cf. E6). - I5 Disabled opacity disagrees where it is set at all: 0.4 (
.widthctl), 0.5 (.google-btn), 0.5 (a call site). → one value via I1. - I6 (shipped 2026-09-05 with I2 —
.pwa-cta:hoverremoved).pwa-cta:hoverusesfilter:brightness(1.05)— a hover used nowhere else. → fold into.btn-primary(rides with I2).
J. Pills & badges
- J1 (shipped 2026-09-05 — one
.tagclass inapp.css; 9 hand-rolled copies across 8 files swapped; added to thesystemClassesowned set.TopicChipskeeps its own larger px-4/py-2 treatment on purpose) A "tag/related" nav pill is hand-rolled ~9 times (search, book ×2, sermon, author, article, reader, favorites,TopicChips) drifting on text colour, fill and padding. → one.tag/.pill-linkclass (this is the concrete form of D9). - J2 Two pill hover languages:
.chip:hover→ bg surface-2 (no accent) vs the nav pills → border+text-accent (no bg). → one pill hover. - J3 Notebook's colour filter active state skips the accent-soft fill
every other selected filter gets (border+text-accent only). → render as
.chip.active. - J4 Two accent-soft eyebrow badges, different padding: bio count px-1.5
vs "FULL LIFE" px-2 py-0.5. → a shared
.badge-soft. - J5 (shipped 2026-09-08 — one saved state everywhere: filled heart tinted
--accentvia thetext-accentactive convention the reader toggles already use;--dangeris now destructive-only. Documented in STYLE_GUIDE §5. Ships with D7.) The saved-heart is red (FavoriteButton--danger) in one place and indigo (text-accent, sermon page) in another. → one token for "saved" (cf. D7).
K. Colour & radius
- K1 Blockquote/pull-quote left-rule uses four colours + two widths
(
.readingaccent-soft,.biogold,.article-bodysolid accent, the book "about" figure a 2px border) where §1 says gold. → one.pullquote. - K2 Two token vocabularies in scoped CSS:
--color-*(12 files) vs bare--*(25 files). → bare tokens (this is E4, re-counted). - K3 The "·" middot separator is drawn at opacity-40/50/60 + bare
text-muted, with varying margins. → one.sephelper. - K4 Secondary text is
text-mutedvstext-muted/60vs/70vsopacity-*interchangeably. →text-muted(or.countfor figures). - K5 "Full-round" is written three ways:
rounded-full,999px,9999px. → one (--radius-pill). - K6
ArticleCard+ the article page use off-scale radii0.75rem,0.5rem,0.25rem. → tokens (cf. D4, E2).
L. Spacing rhythm
- L1 Leaf-page section separators range
mt-8→mt-16for the same "next titled section" role (book mixes 8/12, author 14/16, sermon 12); the identical prev/next nav ismt-14on the reader,mt-12on the sermon. → one band value. - L2 Bordered-footer padding drifts
pt-5/pt-6/pt-8. → onept-6. - L3 Anchor scroll-offset is computed four ways; only two track the
sticky bar (
calc(var(--pinned-offset)…)vs hard-codedscroll-mt-20on sermons/books,scroll-margin-top:5rem,scroll-mt-24). → the--pinned-offsetcalc (cf. B6, §3).
M. Duplication (forms, dividers, rows)
- M1 The password-reveal control is copy-pasted between
loginandreset-password(the reset copy comments "Mirrors /login's"). → aPasswordField.svelte(cf. E6). - M2
.mail-badgeis declared twice (the first copy dead) and triplicated, and is shape-identical to.emblem-chip. → delete the dead block; make it an.emblem-chipvariant (cf. E6, E8). - M3 "Label between two hairlines" divider is built two ways:
.or-divider(--border) vs the error page'sbg-gold/30spans. → one divider helper. - M4 The search field has three chromes; two bypass
.fieldand its--border-strongedge (CommandPalette, home hero use a--borderhairline). → base both on.field(cf. E6). - M5 Settings re-implements the prefs row:
.setting-row/.setting-labelduplicateapp.css's.prefs-row/.prefs-labelwith drifted padding. → promote one shared class.
N. Loading & state
- N1 Seven bare "…" loading placeholders with no skeleton/spinner/
role=status(notebook, settings, ScripturePopover, DefinePopover, NotesDrawer, TocDrawer, CommandPalette) while a skeleton pattern and.btn-spinneralready exist. → one shared loading affordance (this is the drawer/popover half of C4).
Second-pass order
I1 + I2 + H1 + J1 — the batch-of-four(shipped 2026-09-05: disabled buttons, public solid CTA, the two hue-less row-card hovers, the tag pill;systemClasses.test.tsextended withtag+ a.btn:disabledassertion.)- Everything else is lower-visibility cleanup — pull from it opportunistically,
not as a push. K3 (
.sep) and K4 (secondary text) touch the most files and are best folded into whatever leaf-page work comes next.