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>
237 lines
15 KiB
Markdown
237 lines
15 KiB
Markdown
# 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:
|
|
|
|
1. **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 `.ninjatpl` with 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 skipped
|
|
`RenderDefinition` and 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.yaml` providers to get the loop variable exactly
|
|
right.
|
|
|
|
2. **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 is `BlockContext`-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-suggestions` renders 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` → `label`
|
|
- `card`: `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
|
|
|
|
1. `{% for %}` iterates a **`[]any` of maps ONLY**. No string iteration, no int-slice
|
|
iteration. "Repeat N times" must be unrolled or shaped as data in a provider/sample.
|
|
2. **Tailwind JIT cannot see interpolated classes** (`grid-cols-{{ n }}`). Use literal
|
|
conditional classes per enum value.
|
|
3. **No bracket subscript** (`foo[bar]`). Dynamic-key lookups are impossible — shape the data
|
|
in a provider/sample instead.
|
|
4. `{% if %}` **cannot nest inside a tag's arguments** (`{% img %}`, `{% button %}`). Branch
|
|
the whole tag call.
|
|
5. 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 in `plugin.mod` `required_icon_packs` and in
|
|
`RECOMMENDED_ICONS.md`.
|
|
6. `{# comments #}` must be **single-line**.
|
|
7. The `media` filter is **not registered** in the block render path. Resolve images via
|
|
`{% img %}`, never `|media`.
|
|
8. The navbar drawer uses **fixed element ids** — one navbar per page.
|
|
9. **Dual-mode via semantic tokens only** (`bg-background`, `text-foreground`,
|
|
`hsl(var(--token))`). A literal `hsl(...)`/hex/`rgb(...)` in a template **fails
|
|
check-safety** and breaks the other mode.
|
|
10. `{% extends %}`/`{% include %}` are for page templates, not block overrides.
|
|
11. **Bare-string seed values hard-error attribute reads.** `field.text|default:field`
|
|
aborts the whole block (blank hero) when the seed passes `"headline": "..."` instead of
|
|
`{"text": "..."}`. Always read styled-text fields as `field|get:"text"|default:field` —
|
|
the process-global `get` filter degrades gracefully on both shapes.
|
|
12. **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`).
|
|
13. **Theme templates ARE scanned by the host Tailwind build** (fixed cms `50c713ed0`,
|
|
2026-07-05 — extracted `.bnp` `**/*.ninjatpl` is 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 in
|
|
`css.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. Dropping `bn-hero-title` from 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>` gets `bn-post-title`, the hero heading gets `bn-hero-title`, etc.
|
|
Keep existing utility classes; the override rule is unlayered and wins only when set.
|
|
- **Hardcoded `font-size` in 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/`). **Every**
|
|
`font-family` goes through `var(--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 in `RECOMMENDED_FONTS.md`. That is an accepted
|
|
deviation; document it.
|
|
- **Email wrapper** — `templates/email/<system>.ninjatpl`, email-safe (tables, inline styles,
|
|
**literal hex** — email clients ignore `var()`). 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)
|
|
|
|
```bash
|
|
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 lag `main`** — 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 `ImageExists` check 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 registry
|
|
`previewImageUrl` / `preview.png` is the gallery card (home hero). See
|
|
`cms/docs/theme-previews.md`. Auth to a provisioned instance's CMS via
|
|
`SSOService/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 `.bnp`** onto an instance's plugin-source dir on an older base image:
|
|
its `entrypoint.sh` runs `./server --build-so <name>` for any source dir and wedges on a
|
|
codeless theme. Provision-time registry install is the clean path.
|