developing-blockninja-plugins: font size hooks + pluginsdk path refresh

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>
This commit is contained in:
Alex Dunmow 2026-08-03 23:49:53 +08:00
parent 34c2b73801
commit 5154c2c813
2 changed files with 177 additions and 20 deletions

View File

@ -1,6 +1,6 @@
--- ---
name: developing-blockninja-plugins name: developing-blockninja-plugins
description: Use when creating, modifying, building, or publishing BlockNinja CMS plugins or themes — work in plugins/* or sites/* repos, codeless .bnp artifacts (blocks.yaml, seed.json, manifest.yaml), plugin registration, blocks, templates, plugin Connect services, migrations, ninja plugin commands, check-safety for plugins, or block/core SDK usage in standalone plugin repos. description: Use when creating, modifying, building, or publishing BlockNinja CMS plugins or themes — work in plugins/* or sites/* repos, codeless .bnp artifacts (blocks.yaml, seed.json, manifest.yaml), plugin registration, blocks, templates, plugin Connect services, migrations, ninja plugin commands, check-safety for plugins, or block/pluginsdk SDK usage in standalone plugin repos.
--- ---
# Developing BlockNinja Plugins # Developing BlockNinja Plugins
@ -19,14 +19,14 @@ description: Use when creating, modifying, building, or publishing BlockNinja CM
**The ABI is the capability model; the Go SDK is only its ergonomic front-end.** **The ABI is the capability model; the Go SDK is only its ergonomic front-end.**
Since WO-WZ-019, every host interaction is reachable purely over the ABI — a Since WO-WZ-019, every host interaction is reachable purely over the ABI — a
manifest declaration, a `blockninja.host_call` method, or a host-invoked hook — manifest declaration, a `blockninja.host_call` method, or a host-invoked hook —
so a plugin in any language gets 100% of the surface; linking Go `core` is a so a plugin in any language gets 100% of the surface; linking the Go pluginsdk
convenience, not a dependency of the model. The canonical plugin-need → ABI- is a convenience, not a dependency of the model. The canonical plugin-need →
mechanism map is `~/src/blockninja/core/docs/abi-capability-surface.md`. ABI-mechanism map is `~/src/blockninja/cms/docs/abi/abi-capability-surface.md`.
Never write plugin code from memory — every SDK symbol is locally verifiable. Truth lives at: Never write plugin code from memory — every SDK symbol is locally verifiable. Truth lives at:
- **SDK source:** `~/src/blockninja/core` — for WASM plugins, import prefix `git.dev.alexdunmow.com/block/core/...` is the ONLY one allowed; never `block/cms/...`. Codeless plugins import NOTHING (no Go at all). - **SDK source:** `~/src/blockninja/pluginsdk` (module `git.dev.alexdunmow.com/block/pluginsdk`) — since the proto-first SDK program (P3, 2026-07), plugins import ONLY `block/pluginsdk/...`; `block/core/...` and `block/cms/...` imports fail check-safety. Codeless plugins import NOTHING (no Go at all). The ABI protos live in `pluginsdk/abi/proto/v1` (Go bindings `pluginsdk/abi/v1`).
- **ABI contract (authoritative):** `~/src/blockninja/core/docs/wasm-abi.md` — reactor mode, hook catalog, capability dispositions, error codes, `plugin.mod` `data_dir`; capability matrix: `core/docs/abi-capability-surface.md` - **ABI contract (authoritative):** `~/src/blockninja/cms/docs/abi/wasm-abi.md` — reactor mode, hook catalog, capability dispositions, error codes, `plugin.mod` `data_dir`; capability matrix: `cms/docs/abi/abi-capability-surface.md`
- **Architecture spec:** `~/src/blockninja/cms/docs/superpowers/specs/2026-07-03-wasm-plugin-migration-design.md` - **Architecture spec:** `~/src/blockninja/cms/docs/superpowers/specs/2026-07-03-wasm-plugin-migration-design.md`
- **Canonical guide (registration concepts, blocks, provisioning):** `~/src/blockninja/cms/docs/PLUGIN_DEVELOPMENT.md` - **Canonical guide (registration concepts, blocks, provisioning):** `~/src/blockninja/cms/docs/PLUGIN_DEVELOPMENT.md`
- **Exemplars:** `plugins/testplugin` — THE reference wasm plugin, every surface + failure endpoints (see its README surface table); `plugins/symposium` — the largest real port (service-heavy: RPC, jobs, AI, templ blocks; its Makefile is the build/publish convention) - **Exemplars:** `plugins/testplugin` — THE reference wasm plugin, every surface + failure endpoints (see its README surface table); `plugins/symposium` — the largest real port (service-heavy: RPC, jobs, AI, templ blocks; its Makefile is the build/publish convention)
@ -36,10 +36,10 @@ Core-vs-plugin: platform-wide behavior → core; domain-specific or owns its own
**Codeless plugins (WO-WZ-020):** a repo with NO Go source builds a codeless **Codeless plugins (WO-WZ-020):** a repo with NO Go source builds a codeless
`.bnp` — blocks as `blocks/blocks.yaml` definitions (host-rendered via data `.bnp` — blocks as `blocks/blocks.yaml` definitions (host-rendered via data
providers), `seed/seed.json`, `manifest.yaml` for presets/fonts/master pages. providers), `seed/seed.json`, `manifest.yaml` for presets/fonts/master pages.
No wasm, no `block/core` dependency at all. Prefer this form for themes, No wasm, no Go dependency at all. Prefer this form for themes,
content sites, and template-only block packs; write Go only for genuine logic content sites, and template-only block packs; write Go only for genuine logic
(services, jobs, computing tags, custom fetch). Contract: (services, jobs, computing tags, custom fetch). Contract:
`core/docs/codeless-bnp.md`. `cms/docs/abi/codeless-bnp.md`.
## Choose the artifact kind FIRST: codeless vs wasm ## Choose the artifact kind FIRST: codeless vs wasm
@ -48,7 +48,7 @@ content sites, and template-only block packs; write Go only for genuine logic
plugin/theme by asking whether it needs code AT ALL — themes, content sites, plugin/theme by asking whether it needs code AT ALL — themes, content sites,
and template-only block packs should be codeless (blocks as `blocks.yaml` and template-only block packs should be codeless (blocks as `blocks.yaml`
definitions + providers, `seed/seed.json`, `manifest.yaml`; contract: definitions + providers, `seed/seed.json`, `manifest.yaml`; contract:
`core/docs/codeless-bnp.md`). Write Go only for genuine logic: Connect `cms/docs/abi/codeless-bnp.md`). Write Go only for genuine logic: Connect
services, jobs, computing tags/filters, custom fetch, HTTP handlers, media services, jobs, computing tags/filters, custom fetch, HTTP handlers, media
hooks, RAG fetchers. `--codeless` asserts the expectation. Mixed form is hooks, RAG fetchers. `--codeless` asserts the expectation. Mixed form is
legal: a wasm plugin may ALSO ship `blocks/` + `seed/` and keep Go only for legal: a wasm plugin may ALSO ship `blocks/` + `seed/` and keep Go only for
@ -65,7 +65,7 @@ same `CoreServices`. What changed is how it's served. Boilerplate `main.go`
package main package main
import "git.dev.alexdunmow.com/block/core/plugin/wasmguest" import "git.dev.alexdunmow.com/block/pluginsdk/plugin/wasmguest"
func init() { wasmguest.Serve(Registration) } func init() { wasmguest.Serve(Registration) }
@ -134,6 +134,15 @@ express. Custom keys also risk red `block-fallback` boxes when a key isn't regis
Custom blocks that hit the DB have a wasm-specific gotcha — see "Per-instance state" below. Custom blocks that hit the DB have a wasm-specific gotcha — see "Per-instance state" below.
## Theme font size hooks (`bn-*` / `--fs-*`)
Admin-set per-element font sizes (cms ADR 0029) reach templates through class hooks
(`bn-hero-title`, `bn-post-title`, `bn-post-lede`, `bn-post-card-title`, `bn-nav-link`,
`bn-hero-subtitle`) and `var(--fs-<key>, <fallback>)` consumption in CSS. Re-skins must
preserve the built-in's `bn-*` hooks; custom templates add them to equivalent elements;
hardcoded `font-size` on a mapped element wraps in `var(--fs-<key>, <current value>)`.
Full key table and rules: **[theme-overrides.md](theme-overrides.md)**.
## Database — sqlc unchanged, sandboxed role ## Database — sqlc unchanged, sandboxed role
sqlc-generated code (`sql_package: "pgx/v5"`) works **without source edits**: at runtime sqlc-generated code (`sql_package: "pgx/v5"`) works **without source edits**: at runtime
@ -173,7 +182,7 @@ marshaling to host functions. Full family/method table + disposition of every
the unforgeable `X-Bn-Verified-Captcha` trusted header before dispatch. Fail closed the unforgeable `X-Bn-Verified-Captcha` trusted header before dispatch. Fail closed
when it returns false. Reference: calcomblock `HandleCreateBooking`. when it returns false. Reference: calcomblock `HandleCreateBooking`.
- `AppURL`/`MediaPath` — delivered once in `LoadRequest.host_config`. - `AppURL`/`MediaPath` — delivered once in `LoadRequest.host_config`.
- **`deps.Provisioner` (core ≥ v0.17.x) is the seeding path** — `EnsurePage`, - **`deps.Provisioner` is the seeding path** — `EnsurePage`,
`EnsureMenuItem`, `EnsureMedia`, `EnsureSetting`, `MergeSiteSettings`, data `EnsureMenuItem`, `EnsureMedia`, `EnsureSetting`, `MergeSiteSettings`, data
tables, embeds, job schedules, custom colors — call it from **`Load`**. tables, embeds, job schedules, custom colors — call it from **`Load`**.
**`RegisterWithProvisioner` is INERT under wasm**: Register runs at DESCRIBE **`RegisterWithProvisioner` is INERT under wasm**: Register runs at DESCRIBE
@ -221,7 +230,9 @@ instance, and only Register runs on all of them.
## Sandbox constraints — write reload-safe code ## Sandbox constraints — write reload-safe code
- **No filesystem, no network, no env** in the guest. One opt-in exception: `data_dir = true` - **No filesystem, no ambient network, no env** in the guest. Outbound HTTP has ONE opt-in
path — the `allowed_hosts` egress grant + `deps.OutboundHTTP` (see §Outbound HTTP egress
below); there is no other way to reach the network. One opt-in filesystem exception: `data_dir = true`
in `plugin.mod` grants a read-write preopen at guest `/data` — a per-plugin directory on in `plugin.mod` grants a read-write preopen at guest `/data` — a per-plugin directory on
a persistent volume (`instance-<slug>-plugin-data`), shared across the plugin's pool a persistent volume (`instance-<slug>-plugin-data`), shared across the plugin's pool
instances, deleted on uninstall. Off by default; the grant is visible at publish time. instances, deleted on uninstall. Off by default; the grant is visible at publish time.
@ -237,12 +248,107 @@ instance, and only Register runs on all of them.
`/data`. `/data`.
- No `os.Getenv` for secrets (nothing is there anyway) — plugin settings or `CoreServices`. - No `os.Getenv` for secrets (nothing is there anyway) — plugin settings or `CoreServices`.
## Outbound HTTP egress — `allowed_hosts` + `deps.OutboundHTTP` (ADR 0023)
A guest has no ambient network. To call a third-party API (Pexels, Cal.com, any
webhook target) the plugin declares an egress allowlist and rides a
host-mediated transport. Exemplar: `plugins/calcomblock` (`client.go`,
`handler.go`). The whole surface lives in `pluginsdk``pluginsdk/egress`
(policy), `pluginsdk/plugin` (`deps.OutboundHTTP`, `plugin.mod` fields), cms
host `plugin/wasmhost/caps/net.go` (`http.request`).
**Declare it in `plugin.mod`** (first-class keys — a hand-added TOML key not in
the struct is dropped on the next `ninja plugin init`/`bump`):
```toml
allowed_hosts = ["api.pexels.com", "*.cal.com"] # empty ⇒ no egress at all
max_response_mb = 25 # optional; default cap is 10 MB
```
`ninja plugin build` validates the patterns and stamps them into the manifest
(`PluginManifest.allowed_hosts` / `max_response_mb`).
**Host-pattern grammar** (`pluginsdk/egress.ParsePattern`): a bare hostname
(`api.pexels.com`) or a single **left-anchored** multi-label wildcard
(`*.cal.com`) whose base must be a registrable domain. Each entry is **https/443
only** unless it names an explicit port (`host:8443`). Rejected: bare `*`,
public-suffix wildcards (`*.com`, `*.co.uk`), embedded schemes/paths/userinfo, a
URL instead of a host. A request to a non-matching host, a non-https scheme, or
(post-DNS) a private/SSRF IP fails with `ABI_ERROR_CODE_EGRESS_DENIED`
(`egress.ErrDenied`) — catch it and degrade; it's distinct from a transport
failure.
**Make requests through `deps.OutboundHTTP`** (an `http.RoundTripper`), never a
bare `http.DefaultTransport`:
```go
client := &http.Client{Transport: deps.OutboundHTTP, Timeout: 30 * time.Second}
```
The host enforces the grant, the SSRF guard, the platform denylist, and the
response-size cap, performs the buffered request, and logs it (buffered only —
no streaming/SSE/WebSocket; request AND response bodies are capped, oversize
fails rather than truncates). `deps.OutboundHTTP` is **nil when the plugin
declared no `allowed_hosts`** — and at DESCRIBE/native/test time. Follow
calcomblock's `NewCalcomClient(apiKey, rt http.RoundTripper)` shape: pass
`deps.OutboundHTTP` as the transport, and fall back to `http.DefaultTransport`
when it's nil so DESCRIBE probes and `httptest` paths still work (tests reach a
fake server by overriding the base URL).
**API keys** for the upstream service are the plugin's OWN secret: store them in
plugin settings (encrypted at rest via the settings capability, as calcomblock's
`settings_store.go` does), NOT in `allowed_hosts` and NOT in env. The allowlist
authorizes the *destination*, not the *credential*.
**Install-time consent (ADR 0023 D5/D9):** declaring `allowed_hosts` (or a
raised `max_response_mb`) makes the plugin **egress-granted** — the CMS gates it
behind an install-time consent dialog (`GetPluginEgressPlan` reports declared vs
granted with the widening diff; approval is all-or-nothing, no per-host
selection). A version bump that **widens** the allowlist or cap re-gates the
update. So keep the allowlist as tight as the integration truly needs.
## Public site-root routes — `public_routes` + `sitemap` (ADR 0026)
By default a plugin's HTTP handler is reachable ONLY under the namespaced
mount `/api/plugins/<name>`. To serve top-level public URLs (SEO pages,
public APIs) the plugin claims them in `plugin.mod` (first-class keys, same
round-trip rule as `allowed_hosts` — a hand-added key not in the struct is
dropped on the next `init`/`bump`):
```toml
public_routes = [{ path = "/area", prefix = true }, { path = "/api/semantic-search" }]
sitemap = true # host fetches entries from the plugin's well-known sitemap endpoint
```
`ninja plugin build` validates and stamps both into the manifest
(`PluginManifest.public_routes` / `sitemap`; validator:
`pluginsdk/plugin.ValidatePublicRoutes`). Build-rejected: relative paths, the
site root `/`, trailing slashes (set `prefix = true` to claim the subtree),
traversal or malformed segments, duplicates, anything equal to or under the
reserved prefixes `/admin`, `/api/plugins`, `/ws`, and a prefix claim that
would cover one (a `/api` prefix claim covers `/api/plugins`). Routes and the
sitemap flag require an HTTP handler; codeless plugins cannot declare either.
The guest ABI is unchanged: matching requests dispatch to the SAME
`HTTPHandler` hook, which already receives full unstripped request paths, so
one handler routes both mounts by `r.URL.Path`. Conflict rules are host-side
at install/load: core routes always win, first-installed plugin wins between
plugins, and losing claims surface in the admin plugin UI (never silently
dropped).
**Status (2026-07-12):** manifest + CLI shipped (pluginsdk v0.2.7, cli
71d005f). Host-side mounting and the sitemap merge land with WO-PX-002 — until
that cms release is deployed, claims are stamped into the `.bnp` but not yet
mounted. First consumer: perthplaygrounds (`/area`, `/badges`, `/age`,
`/api/directory`).
## Build → verify → publish ## Build → verify → publish
`plugin.mod` is still the manifest (name/version/kind/scope/categories/tags, plus the new `plugin.mod` is still the manifest (name/version/kind/scope/categories/tags, plus the new
first-class `data_dir` bool). `kind` is still **frozen at first publish**. Pin a recent first-class `data_dir` bool). `kind` is still **frozen at first publish**. Pin a recent
`block/core`; exact-version lock-step with the CMS is dead (testplugin builds on v0.15.1 `block/pluginsdk`; exact-version lock-step with the CMS is dead (testplugin builds on
while cms runs v0.15.2) — compatibility is gated by ABI major (`abi_version`, currently 1). v0.2.5 while v0.2.7 is current) — compatibility is gated by ABI major (`abi_version`,
currently 1).
```bash ```bash
ninja plugin build --dir . # wasip1 compile + DESCRIBE probe → <name>-<version>.bnp ninja plugin build --dir . # wasip1 compile + DESCRIBE probe → <name>-<version>.bnp
@ -291,26 +397,26 @@ without `localdev` is PRODUCTION). Install via Admin → Plugins → Browse Regi
## Verifying SDK symbols ## Verifying SDK symbols
Before using an unfamiliar SDK call, check: (1) the pinned SDK — `go doc Before using an unfamiliar SDK call, check: (1) the pinned SDK — `go doc
git.dev.alexdunmow.com/block/core/plugin CoreServices` from the plugin dir; (2) the git.dev.alexdunmow.com/block/pluginsdk/plugin CoreServices` from the plugin dir; (2) the
capability disposition table in `core/docs/wasm-abi.md` (does it cross the ABI, and how?); capability disposition table in `cms/docs/abi/wasm-abi.md` (does it cross the ABI, and how?);
(3) nearest exemplar usage in testplugin/symposium; (4) the CMS-side implementation in (3) nearest exemplar usage in testplugin/symposium; (4) the CMS-side implementation in
`cms/backend` for semantics. `cms/backend` for semantics.
**Missing capability** ⇒ first check `core/docs/abi-capability-surface.md` — the surface **Missing capability** ⇒ first check `cms/docs/abi/abi-capability-surface.md` — the surface
is complete for every known plugin need since WO-WZ-019. A genuinely new capability is an is complete for every known plugin need since WO-WZ-019. A genuinely new capability is an
**ABI extension** (manifest field, `host_call` method pair, or hook — additive within ABI **ABI extension** (manifest field, `host_call` method pair, or hook — additive within ABI
major 1), landed in the ABI proto + cms host first; the Go guest stub is then the major 1), landed in the ABI proto + cms host first; the Go guest stub is then the
ergonomic wrapper, not the capability itself. Alternatively vendor the cms-internal ergonomic wrapper, not the capability itself. Alternatively vendor the cms-internal
package into the plugin's `internal/` with a provenance header. NEVER `replace` package into the plugin's `internal/` with a provenance header. NEVER `replace`
directives; NEVER `block/cms` imports. Remember the wasm boundary: a value that carries directives; NEVER `block/cms` or `block/core` imports. Remember the wasm boundary: a value that carries
functions/handlers can't cross — it needs a hook or a manifest declaration instead. functions/handlers can't cross — it needs a hook or a manifest declaration instead.
## Doc routing ## Doc routing
| Working on | Read first | | Working on | Read first |
|---|---| |---|---|
| ABI, hooks, capabilities, error codes, `data_dir`, build/pack internals | `core/docs/wasm-abi.md` | | ABI, hooks, capabilities, error codes, `data_dir`, build/pack internals | `cms/docs/abi/wasm-abi.md` |
| Which ABI mechanism serves a plugin need (matrix; codeless equivalents) | `core/docs/abi-capability-surface.md` | | Which ABI mechanism serves a plugin need (matrix; codeless equivalents) | `cms/docs/abi/abi-capability-surface.md` |
| Scaffold, registration, custom block types, `CoreServices` concepts | `cms/docs/PLUGIN_DEVELOPMENT.md` | | Scaffold, registration, custom block types, `CoreServices` concepts | `cms/docs/PLUGIN_DEVELOPMENT.md` |
| **Section/content blocks — html block vs custom; menus; live vs pre-render** | **[html-blocks.md](html-blocks.md)** (in this skill) — read BEFORE writing a custom block type | | **Section/content blocks — html block vs custom; menus; live vs pre-render** | **[html-blocks.md](html-blocks.md)** (in this skill) — read BEFORE writing a custom block type |
| **Codeless THEMES — template overrides, ninjatpl gotchas, override dispatch, seed slugs, screenshots** | **[theme-overrides.md](theme-overrides.md)** (in this skill) — read BEFORE writing any theme override or `.ninjatpl` | | **Codeless THEMES — template overrides, ninjatpl gotchas, override dispatch, seed slugs, screenshots** | **[theme-overrides.md](theme-overrides.md)** (in this skill) — read BEFORE writing any theme override or `.ninjatpl` |
@ -318,6 +424,7 @@ functions/handlers can't cross — it needs a hook or a manifest declaration ins
| Plugin migrations / DB tables — own schema, per-plugin role | PLUGIN_DEVELOPMENT.md §Migrations + this skill §Database | | Plugin migrations / DB tables — own schema, per-plugin role | PLUGIN_DEVELOPMENT.md §Migrations + this skill §Database |
| Platform data tables (`Provisioner.EnsureDataTable`, View-rootable JSONB store) | PLUGIN_DEVELOPMENT.md §Provisioning → Data tables | | Platform data tables (`Provisioner.EnsureDataTable`, View-rootable JSONB store) | PLUGIN_DEVELOPMENT.md §Provisioning → Data tables |
| Public HTTP routes (webhooks, widgets) — buffered only | `cms/docs/PLUGIN_HTTP_HANDLERS.md` | | Public HTTP routes (webhooks, widgets) — buffered only | `cms/docs/PLUGIN_HTTP_HANDLERS.md` |
| Top-level site-root URL claims (`public_routes`, `sitemap`) | `cms/docs/adr/0026-plugin-public-routes-manifest-capability.md` + this skill §Public site-root routes |
| Load/Unload, runtime state | `cms/docs/PLUGIN_LIFECYCLE_HOOKS.md` + this skill §Per-instance state | | Load/Unload, runtime state | `cms/docs/PLUGIN_LIFECYCLE_HOOKS.md` + this skill §Per-instance state |
| Themes, templates, master pages, CSS | `cms/docs/TEMPLATE_PLUGINS.md` | | Themes, templates, master pages, CSS | `cms/docs/TEMPLATE_PLUGINS.md` |
| Theme preview screenshots (`ninja theme screenshot`, registry `previewImageUrl`) | `cms/docs/theme-previews.md` | | Theme preview screenshots (`ninja theme screenshot`, registry `previewImageUrl`) | `cms/docs/theme-previews.md` |
@ -343,6 +450,10 @@ compiles, go:embed'd web bundles, or core-version skew.
the artifact matches the tag; `make archive-check` proves HEAD compiles. the artifact matches the tag; `make archive-check` proves HEAD compiles.
- `ninja plugin init` is interactive and DROPS unknown `plugin.mod` keys on rewrite — - `ninja plugin init` is interactive and DROPS unknown `plugin.mod` keys on rewrite —
re-check `plugin.mod` after running it (`data_dir` is a known key and survives). re-check `plugin.mod` after running it (`data_dir` is a known key and survives).
- **Stale-CLI manifest drop:** a `ninja` binary older than the field you rely on builds a
`.bnp` whose manifest silently omits it. `allowed_hosts` needs CLI >= 7cfc371;
`public_routes`/`sitemap` need CLI >= 71d005f (pluginsdk v0.2.7). Rebuild/reinstall the
CLI from `~/src/blockninja/cli` before publishing a plugin that uses a new manifest field.
New plugin: `ninja plugin init`, copy `main.go` + layout from testplugin and the Makefile New plugin: `ninja plugin init`, copy `main.go` + layout from testplugin and the Makefile
from symposium, minimal registration per PLUGIN_DEVELOPMENT.md. from symposium, minimal registration per PLUGIN_DEVELOPMENT.md.

View File

@ -113,6 +113,52 @@ Grep your overrides for the old names before assuming they still work.
`hsl(var(--token))`). A literal `hsl(...)`/hex/`rgb(...)` in a template **fails `hsl(var(--token))`). A literal `hsl(...)`/hex/`rgb(...)` in a template **fails
check-safety** and breaks the other mode. check-safety** and breaks the other mode.
10. `{% extends %}`/`{% include %}` are for page templates, not block overrides. 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, fonts, email, motion