Two changes bundled (shared-file bundling, solo-on-main): - Theme font size hook contract from cms ADR 0029: bn-* class hooks, var(--fs-<key>, fallback) consumption, preserve hooks in re-skins, wrap hardcoded font-size declarations, republish caveat. New section in theme-overrides.md, summary + pointer in SKILL.md. - Pre-existing uncommitted SKILL.md refresh: block/core references migrated to block/pluginsdk and cms/docs/abi/ doc paths (P3 proto-first SDK program), description line updated to match. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
15 KiB
Codeless themes: template overrides, the ninjatpl engine, seed, screenshots
Read this before building or editing a codeless theme (a .bnp that re-skins the
built-in blocks and ships page templates, presets, fonts, an email wrapper, and demo
content). Distilled from the theme-fleet build (16 themes, 2026-07-05). Companion to
cms/docs/TEMPLATE_PLUGINS.md and cms/docs/theme-previews.md.
What a codeless theme is
No Go, no plugin.wasm, no go.mod, no block_core pin. The repo is:
manifest.yaml # theme_presets, bundled_fonts, master_pages, system_templates,
# page_templates, template_overrides, css.input_css_append, email wrappers
plugin.mod # name/display_name/kind="theme"/scope="@themes"/categories/tags/required_icon_packs
templates/<theme>/*.html # the page templates (default, full-width, landing, article, blog-index, contact, auth)
templates/overrides/<theme>/*.ninjatpl # one per built-in block key you re-skin
templates/email/<system>.ninjatpl # email wrapper
blocks/*.ninjatpl + *.schema.json # persona blocks ONLY (things no built-in covers)
presets.json fonts.json master_pages.json
seed/seed.json seed/workflows.json # demo content (demo:true)
assets/ # fonts, css, static
Compatibility is not pinned in the repo — it is enforced by manifest synthesis in
ninja plugin build and the registry gates. kind = "theme" is frozen at first publish.
Re-skinning a built-in block = a template override
Ship templates/overrides/<theme>/<key>.ninjatpl plus a template_overrides entry in
manifest.yaml (template_key: <theme>, block_key: <key>). Keys use the dashed
canonical form (video-embed, feature-grid, author-bio-hero). Some accept underscore
aliases, but overrides target the dashed key.
A re-skin is presentation only. Copy the built-in's default template as your starting
point (cms/backend/blocks/builtin/manifest/{blocks.yaml,schemas,sample} for declarative
builtins; the compiled builtin's Go for the rest) and preserve every content-field read,
provider variable, custom tag ({% img %}, {% button %}, {% form %}, {% signup_form %},
{% auth_form %}), and inline <script> verbatim. Only classes and markup change. If you
rename or drop a variable, that part renders empty.
Persona blocks (blocks/) are for furniture no built-in covers (a reservation strip, a
tour-dates list). Anything that duplicates a built-in (footer, hero, gallery, divider, menu,
stats, pull-quote) must become an override of that built-in, not a parallel block. Delete the
duplicate block + its schema.
How overrides dispatch — know this or you ship empty lists
There are two kinds of built-in, and they reach your override by different paths:
-
Definition-backed built-ins (most of them: navbar, footer, hero, feature-grid, the whole blog family, breadcrumbs, category-list, author-bio-hero, …). These declare data providers (
menus,posts,authors,categories,footer_menus, …). The codeless loader registers your override as a template source, so render flows through the definition engine (RenderDefinition), which builds the providers and then renders your.ninjatplwith the content-map fields and the provider variables merged in. A navbar override gets its menu items; a blog-index override gets its posts.- This is cms main (
fa31808d7, 2026-07-05) onward. On an OLDER image the codeless loader registered these overrides on the compiled path, which skippedRenderDefinitionand never built providers — so the chrome rendered around empty lists. If your provider-backed override renders empty, your cms is pre-fix. - Author provider-backed overrides faithful to the built-in's provider variable names.
Read the built-in default + its
blocks.yamlproviders to get the loop variable exactly right.
- This is cms main (
-
Compiled built-ins with NO definition (
auth-form,auth-status,password-reset-form,page-suggestions). Your override dispatches via the compiled path —Registry.GetForTemplate(templateKey, blockKey)returns your override closure before the base block, so it renders. Their data isBlockContext-driven (auth.*,context.*), not providers, so they render fully. Preserve the hardcoded endpoints and input names (/api/auth/login|register|logout|request-password-reset, honeypots, the Cap<cap-widget>markup).page-suggestionsrenders themed 404 chrome, but the dynamic "did you mean" list is a server-side Postgres FTS query that a codeless template cannot reproduce — themed chrome yes, dynamic list no.
(Internal note if you ever touch the loader: definition-backed keys must be registered via
RegisterTemplateOverrideSource so registry.Has("theme:key") stays false and the
blocks.go dispatch gate takes the RenderDefinition branch; non-definition keys keep the
compiled RegisterTemplateOverride path. pongo2's tag/filter registries are process-global,
so the source-layer renderer and the compiled BlockTemplate closures share the same custom
tags/filters — an override renders identically either way.)
Legacy override field reconcile (the dead-override trap)
If the theme carried .so-era overrides, the built-in field names changed. Fix the reads or
the override renders empty:
button:href/url→link,variant→style,text→labelcard:body→text,image→media
Grep your overrides for the old names before assuming they still work.
ninjatpl (pongo2) engine gotchas — hard-won, do not rediscover
{% for %}iterates a[]anyof maps ONLY. No string iteration, no int-slice iteration. "Repeat N times" must be unrolled or shaped as data in a provider/sample.- Tailwind JIT cannot see interpolated classes (
grid-cols-{{ n }}). Use literal conditional classes per enum value. - No bracket subscript (
foo[bar]). Dynamic-key lookups are impossible — shape the data in a provider/sample instead. {% if %}cannot nest inside a tag's arguments ({% img %},{% button %}). Branch the whole tag call.- Icons are
"pack:name"strings: split with|split:":"|first/|last. The::pack:name:SIZE::shorthand takes size keywords only (sm/md/lg/xl), not class strings. Render icons only as<svg><use href="/icons/<pack>.svg#<name>"/></svg>— never inline SVG. Declare packs inplugin.modrequired_icon_packsand inRECOMMENDED_ICONS.md. {# comments #}must be single-line.- The
mediafilter is not registered in the block render path. Resolve images via{% img %}, never|media. - The navbar drawer uses fixed element ids — one navbar per page.
- Dual-mode via semantic tokens only (
bg-background,text-foreground,hsl(var(--token))). A literalhsl(...)/hex/rgb(...)in a template fails check-safety and breaks the other mode. {% extends %}/{% include %}are for page templates, not block overrides.- Bare-string seed values hard-error attribute reads.
field.text|default:fieldaborts the whole block (blank hero) when the seed passes"headline": "..."instead of{"text": "..."}. Always read styled-text fields asfield|get:"text"|default:field— the process-globalgetfilter degrades gracefully on both shapes. - Scroll-reveal must be robust. A naive
.reveal+ IntersectionObserver hides below-fold content in screenshots and for no-JS/reduced-motion users. Required: reveal instantly when already in view at init, a safety-net full reveal (load+ timeout), and content visible without JS (gate hiding on a JS-added class, e.g..theme-js). - Theme templates ARE scanned by the host Tailwind build (fixed cms
50c713ed0, 2026-07-05 — extracted.bnp**/*.ninjatplis an injected@source; CSS regenerates on boot, theme activation, and runtime install/swap). Use normal responsive utilities in templates; do NOT duplicate them as hand-rolled media-query CSS incss.input_css_append— reserve that for genuinely bespoke design CSS.
Theme font size hooks (bn-* classes and --fs-* variables)
Since 2026-08 (cms ADR 0029) admins can set per-element font sizes in Theme settings.
The theme CSS emits --fs-<key> variables plus rules targeting class hooks; a
template only responds if it carries the hook (or its stylesheet consumes the var).
Canonical key list: cms backend/internal/theme/fontsize.go; selector map: cms
backend/internal/theme/css.go fontSizeOverrideSelectors.
| Key | How it applies |
|---|---|
h1..h6 |
bare element rules in @layer base (Tailwind size utilities still win, by design) |
hero-title / hero-subtitle |
.bn-hero-title / .bn-hero-subtitle (unlayered, beats utilities) |
post-title / post-lede |
.bn-post-title / .bn-post-lede (unlayered) |
index-card-title |
.bn-post-card-title (unlayered) |
nav-link |
.bn-nav-link — host navbar chrome only; menu links, not brand/CTA/utility anchors |
post-body, post-h2, post-h3, post-meta, page-title, page-lede, button |
consumed as var(--fs-<key>, <fallback>) in stylesheets (host sheets do this; yours can too) |
Rules for theme/plugin authors:
- Re-skinning a built-in? Preserve its
bn-*hooks. They are content contract, like field reads. Droppingbn-hero-titlefrom a hero re-skin silently kills the admin's Hero Title size control on every site using your theme. - Custom templates opt in by adding the hook to the semantically equivalent element:
the article
<h1>getsbn-post-title, the hero heading getsbn-hero-title, etc. Keep existing utility classes; the override rule is unlayered and wins only when set. - Hardcoded
font-sizein your CSS on a mapped element? Wrap it:font-size: var(--fs-hero-title, 3rem);with your current value as the fallback, so zero-override rendering is byte-identical. This works even when you cannot touch markup. - Do not invent hooks for unmapped elements and do not map decorative elements (stat numbers, section eyebrows, footers) to keys they do not represent.
- Published pages freeze markup: pages published before a hook existed pick it up
only on republish. The
var()path applies immediately.
Presets, fonts, email, motion
- presets.json — 4 to 6 presets, all 19 tokens,
mode: "both". Tune the dark side intentionally (a real dusk/night palette, not an inversion). - fonts.json — bundle woff2 (OFL/Apache only; record the license in
assets/). Everyfont-familygoes throughvar(--font-heading|body|mono, <fallback>); never hardcode a family (it breaks the admin font picker). If you cannot obtain the exact locked face offline (no woff2 in-workspace, no network), bundle a close OFL substitute and name the intended face as an admin Google-Fonts assignment inRECOMMENDED_FONTS.md. That is an accepted deviation; document it. - Email wrapper —
templates/email/<system>.ninjatpl, email-safe (tables, inline styles, literal hex — email clients ignorevar()). Context:body|safe,site_name/site_url/logo_url,colors.<camelCase>hex tokens. - Motion — dependency-free JS only; honor
prefers-reduced-motion; lazy-init any canvas showpiece on view (IntersectionObserver); ship a static no-JS fallback. Keep canvas to the hero/landing when a theme is a "big motion" theme; nowhere else.
Seed demo content (seed/seed.json, demo: true)
Sections: settings, media, pages, menu_items, data_tables. Required pages: home,
about, a blog index with ≥3 posts, contact, login. Contact wiring: a data_tables entry
(e.g. contact_submissions) referenced from the contact-form block as
formConfig.targetTable (rewritten to the real table id at apply), plus seed/workflows.json
with a row_inserted trigger on that table and an email step
(recipients: {mode: "admins", scope: "all"}).
Page slugs are single-segment. The pages table enforces slug_single_segment (no
slashes). Blog posts under /blog must be seeded as nested pages (a child of the blog
page with a single-segment child slug), not as a page whose slug is a full path like
/blog/my-post — a path slug is rejected and 500s InstallDemoContent (home/about/blog-index
seed first, then the nested post fails and aborts the rest). Confirm the current seed
applier's nested-page handling in cms/backend/internal/services before authoring blog seed.
Data-table + workflow seeding is proven by the seed-workflows e2e (dojo suite
seed-workflows-e2e).
Verify + commit (theme workflow)
cd themes/<theme> && make # ninja plugin build --codeless → <name>-<ver>.bnp
make archive-check # git archive HEAD packs cleanly
ninja plugin verify <name>-<ver>.bnp
cd ~/src/blockninja/check-safety && go run . ~/src/blockninja/themes/<theme> # MUST exit 0
A clean theme reports 32 checks: 19 ok 13 skip -> OK.
Commit gotcha (bit me repeatedly): the safety-gate commit hook resolves its target from
the shell cwd. If you commit while cwd is elsewhere, or a sibling theme repo has
pre-existing violations, the hook scans the wrong repo and denies you. Commit with
git -C ~/src/blockninja/themes/<theme> commit ... (or cd into the repo first) so the hook
scans your theme. Stage explicit paths; never git add -A. *.bnp/*.so stay gitignored;
kind = "theme".
Screenshots / registry previews
- A codeless theme renders only on a cms that carries the built-ins (and, for populated
lists, the provider-dispatch fix
fa31808d7). A released image can lagmain— check the released tag's commit before assuming it can render a new theme. A theme built against new built-ins will not render on an image that predates them. - Provisioning resolves the newest cms tag, then does a local
ImageExistscheck before pulling (orchestrator .../operation_provision.go). So a locally-built image tagged as that resolved version is used as-is — you can preview against an unreleased cms without pushing anything. - Documented recipe: provision
siteType: showcase+ninja theme screenshot; the registrypreviewImageUrl/preview.pngis the gallery card (home hero). Seecms/docs/theme-previews.md. Auth to a provisioned instance's CMS viaSSOService/GenerateSSOToken→/admin/sso?format=json&token=…(no seeded instance admin password). - Codeless themes provision fast (~10 s, no CGO compile), so the old
.so≤3-concurrent / 5-minute-compile cautions no longer apply. The account 10-site cap still does — tear galleries down between batches and poll until gone. - Do NOT sideload a
.bnponto an instance's plugin-source dir on an older base image: itsentrypoint.shruns./server --build-so <name>for any source dir and wedges on a codeless theme. Provision-time registry install is the clean path.