Documentation menu
Documentation / Foundation / blocks-core
Blocks Core
npm install @wabbit/tome-blocks-coreFirst install from our registry?
Get a free registry token from your credentials page. Every install from our registry needs one, free packages included.
Add the registry and your token to the
.npmrcat the root of your project, with your token in place ofYOUR_TOKEN:@wabbit:registry=https://npm.wabbit.com/ //npm.wabbit.com/:_authToken=YOUR_TOKEN
Core primitives for the Tome block system — the block/bundle registry, defineBlock/defineBundle factories, variant-aware admin components (BlockPicker, VariantPicker), and the thumbnail pipeline; upstream dependency for every tome-blocks-* pack.
Overview
Core primitives for the Tome block system. Provides the registry, type definitions, defineBlock / defineBundle factories, admin components (BlockPicker, VariantPicker), and the thumbnail pipeline (SVG wireframes, plus an opt-in raster screenshot source).
This package is the upstream dependency for all @wabbit/tome-blocks-* bundle packages and the @wabbit/tome-blocks meta-package.
Add Tome blocks to an existing Payload project
The canonical path for adding one or more @wabbit/tome-blocks-* packs to a Payload project that already exists — the Payload website template, a hand-rolled site, anything with its own payload.config.ts and its own blocks field. Every pack README's own "Install into an existing Payload project" section points back here for the parts that don't change per pack.
1. Registry access (once per machine)
Add the scope mapping to your project's .npmrc (skip if it's already there):
@wabbit:registry=https://npm.wabbit.com/Every install then needs an install token — the registry answers no anonymous reads, free packs and the shared foundation packages included. Getting one costs nothing:
- Create a free account at wabbit.com/signup (no card).
- In Account → Credentials, claim the free license and generate an install token. It's shown exactly once; if you lose it, generate a new one.
- Put it in your user-level
~/.npmrc(never the project one, so it stays out of your repository):
//npm.wabbit.com/:_authToken=<your token>A free token installs the free packs (@wabbit/tome-blocks-marketing-starter, @wabbit/tome-blocks-content-writer) and everything they depend on (blocks-core, blocks-extras, blocks-house, tome-ui). Paid packs install with the same token once your account holds the plan or pack — see wabbit.com/subscribe. The step-by-step customer guide is wabbit.com/docs/get-started.
2. Install a pack
npm install @wabbit/tome-blocks-marketing-starterEvery pack declares @wabbit/tome-blocks-core (and, where it uses them, @wabbit/tome-blocks-house/@wabbit/tome-blocks-extras) as a required peer, and this package in turn declares @wabbit/tome-ui as a required peer. npm 7+ and pnpm install required peers automatically, so this one command is the whole install — there's no separate npm install @wabbit/tome-blocks-core @wabbit/tome-ui step.
3. Load the design tokens
One line in your site's global CSS:
@import '@wabbit/tome-blocks-core/styles.css';It imports the @wabbit/tome-ui token layer and the page grid (the global tome-grid class step 5's RenderBlocks uses). Block CSS reads --tome-* custom properties, almost always with no fallback — skip this line and blocks render as bare, unstyled HTML. That line is the only setup: there is no next.config change, because every pack ships its per-block stylesheets already compiled.
4. Add blocks to a blocks field
Every pack exports its blocks as descriptors — a meta plus a .block(config?) factory that returns a Payload Block. Import the ones you want and add them alongside whatever your field already has:
// payload.config.ts
import { ctaBlock, faqBlock } from '@wabbit/tome-blocks-marketing-starter'
// inside the `pages` collection's `layout` field:
{
name: 'layout',
type: 'blocks',
blocks: [
...existingBlocks,
ctaBlock.block(),
faqBlock.block(),
],
}5. Render them
Two prop shapes exist, and mixing them up is a runtime crash. Every pack renderer is typed React.FC<BlockRenderProps<T>> — it destructures { block, className, index }, with a block's own fields nested under block. That is the opposite of how most Payload sites render a blocks field.
Render through this package's `RenderBlocks`. Every pack block places its own inner sections on @wabbit/tome-ui's named grid lines (content-start/content-end, full-start/full-end, etc.) via CSS grid-template-columns: subgrid, so it needs the page grid as an ancestor. Without one nothing errors or warns; the page just renders with every block flush against the viewport edge, zero gutters (reproduced 2026-09-24 by scripts/blocks-fresh-install-smoke.mjs in tome-core). RenderBlocks provides it: it wraps each block in the [data-tome-block-wrapper] subgrid div those named lines need, and its container gets the page grid. With no gridClassName that is the global tome-grid class the step 3 CSS line brings, so there is nothing to wire. Use it instead of a hand-rolled .map() loop:
// blockComponents.ts
import { renderers as marketingRenderers } from '@wabbit/tome-blocks-marketing-starter/render/register'
import { adaptRenderersForPayload } from '@wabbit/tome-blocks-core/render'
export const blockComponents = {
...adaptRenderersForPayload(marketingRenderers),
// your own site-native components, already spread-props shaped, pass through as-is
}// src/blocks/RenderBlocks.tsx — delegate to this package's RenderBlocks
// rather than a hand-rolled array loop, and don't pass `defaultColumnSpan`:
// leaving every wrapper at its default `1 / -1` keeps the FULL set of named
// lines (including `full-start`/`full-end`) available inside each block's
// own subgrid — pre-clipping the wrapper to content columns silently drops
// the lines outside that range from every block's subgrid.
import { blockComponents } from './blockComponents'
import { RenderBlocks as TomeRenderBlocks } from '@wabbit/tome-blocks-core/render'
export function RenderBlocks({ blocks }: { blocks: LayoutBlock[] }) {
// The container defaults to the global `tome-grid` from styles.css. Pass
// `gridClassName` only to use a grid of your own, e.g. tome-ui's CSS Module:
// `import gridStyles from '@wabbit/tome-ui/grid'` → `gridClassName={gridStyles.grid}`.
return <TomeRenderBlocks blocks={blocks} components={blockComponents} />
}Theme hooks on the wrapper. Each per-block wrapper carries data-block-type and data-block-stored-type. data-block-type is always the block's catalog slug (the slug in BLOCK_CATALOG, e.g. high-impact-hero), whatever its Payload row stores, so a theme writes [data-tome-theme="x"] [data-block-type="high-impact-hero"] [data-block-variant="quick-ask"] once and it matches real CMS rows. data-block-stored-type keeps the row's stored blockType (e.g. highImpactHero). A block outside the catalog (a site-native block, @wabbit/tome-longform's longformCallout, or a slug you renamed with applyBlockSlugOverrides) gets its stored value in both.
Placement hook on the wrapper. Each wrapper also carries data-block-placement, the name of its grid-column: full (1 / -1, the default, or full-start / full-end), content (2 / -2 or content-start / content-end), another page-grid area's name (reading-start / reading-end is reading), or custom for anything else (3 / 11). A stylesheet that depends on where its wrapper sits matches this, e.g. [data-tome-block-wrapper][data-block-placement='full'] > .root. Never match the wrapper's style attribute text ([style^='grid-column:1 / -1;']): the server writes it one way, the browser another, and once any script writes to the element's inline style WebKit rewrites the whole attribute with longhands (grid-column-start: 1; grid-column-end: -1), so the match fails in Safari only. blockPlacementFor(columnSpan) and BLOCK_PLACEMENT_ATTR (from @wabbit/tome-blocks-core/render) give a hand-rolled wrapper the same attribute. The platform packs read a wrapper without the attribute as full.
Per-block column override: `--tome-block-column`. The wrapper's inline style is grid-column: var(--tome-block-column, <placement>), where <placement> is the block's columnSpan (or defaultColumnSpan, or 1 / -1). Unset, the computed column is exactly the placement, so nothing moves. A page template or theme moves one block by setting the custom property on the wrapper, per breakpoint and without !important: [data-block-type='product-gallery'] { --tome-block-column: 2 / 8; } inside a media query. The inline style text changed from grid-column:1 / -1; to grid-column:var(--tome-block-column, 1 / -1);; match data-block-placement, never the style text.
The block root inside a moved wrapper: `--tome-block-inner-column`. The wrapper is a subgrid of its own span, and tome-ui's default puts the block root on 2 / -2 of it, which drops a column on each side once the span is narrower than the page. The same rule that sets --tome-block-column sets --tome-block-inner-column: 1 / -1 (or any placement inside the wrapper) and the root fills the span. Unset, the default is exactly 2 / -2.
Blocks that declare meta.pinnedBand: true (e.g. media-panel, pull-interlude in @wabbit/tome-blocks-cinema-pack) always span 1 / -1 at their own render root — no site-level handling needed, it's baked into each such block's own CSS and beats this package's :where(.grid [data-tome-block-wrapper] > *) { grid-column: 2 / -2 } default on ordinary specificity.
If you're dispatching one block at a time and want it nested (no spreading), use RenderBlock — it calls the pack renderer exactly the shape it expects, no adapting needed. It needs the same grid ancestor as above for any block reading named lines (an element with className="tome-grid" is enough once the step 3 CSS line is loaded):
import { RenderBlock } from '@wabbit/tome-blocks-core'
import { renderers } from '@wabbit/tome-blocks-marketing-starter/render/register'
<RenderBlock block={block} components={renderers} />That's the whole path: registry access, install, CSS import, add blocks to a field, render (through this package's RenderBlocks with the grid — not optional, see step 5). Everything below is optional.
6. Optional — override the defaults
- Rich text and images render with no adapter setup. This package ships a default rich-text adapter (built on
@payloadcms/richtext-lexical/react, already a required peer) and a default media adapter (resolves a Payload upload document or id to a plain<img>) — nothing to register for either to work. Upload fields must be populated atdepth >= 1; a bare, unpopulated id can't be resolved without a fetch and renders nothing, with a one-time console warning in every environment (not just dev). CallregisterBlockAdapters()only to override a default — swap innext/image, a route-transition-aware link, etc.:
// lib/blockAdapters.ts — import from BOTH payload.config.ts AND your root layout
import { registerBlockAdapters } from '@wabbit/tome-blocks-core/adapters'
registerBlockAdapters({
richText: { RichText: MyRichText, isRichTextActive: true },
media: { Media: MyImageComponent, isMediaActive: true },
link: { Link: MyTransitionLink, isLinkActive: true },
}) Links that open a new tab (target="_blank") are announced: TomeLink / resolveLink() add data-new-tab to the anchor (a hook a theme can draw an arrow from) and a visually hidden " (opens in a new tab)" after its children, ahead of the adapter, so a registered Link receives them too. An aria-label gets the phrase appended instead. Links without target="_blank" render exactly as before.
A media adapter receives MediaProps: resource, className, alt, priority, sizes, and for an image that fills a box the block sizes itself (a tile, a logo cell, a capture frame) fill: true and imgClassName. The built-in adapter renders one <img> carrying both classes. An adapter that wraps its image (a <div> or <picture> taking className) should render the image in fill mode over that wrapper, put imgClassName on the <img>, and pass sizes to the image (the Payload website template's Media names that prop size, so map sizes to it in the adapter):
const SiteMedia = ({ sizes, ...props }: MediaProps) => <Media {...props} size={sizes} />
registerBlockAdapters({ media: { Media: SiteMedia, isMediaActive: true } }) A link adapter receives LinkProps: href, children, className, style, id, target, rel, plus any data-* theme hooks and aria-* attributes the block puts on the link (data-section-label-link, data-panel-cta, data-zone-cell…). The built-in anchor forwards all of them; a custom Link should spread the rest of its props onto its anchor so the hooks reach the DOM.
- Coming from the Payload website template. Verified live against
create-payload-app -t website(2026-09-24) — the exact spots that bite: - Slug collisions.
cta/banner(@wabbit/tome-blocks-marketing-starter),archive(@wabbit/tome-blocks-content-writer), andcontent/code(@wabbit/tome-blocks-extras) share a slug with a block the stock website/ecommerce templates already register in the same field (docs/blocks-slug-collisions.mdhas the full audit). Rename just the colliding ones at the point you assemble your blocks array and your renderer map — a pack's own default slug never changes, so no stored data migrates:
import { applyBlockSlugOverrides, remapRendererSlugs } from '@wabbit/tome-blocks-core/slugOverrides'
const overrides = { cta: 'tomeCta' }
const blocks = applyBlockSlugOverrides([...existingBlocks, ctaBlock.block()], overrides)
const components = {
...blockComponents,
...remapRendererSlugs(adaptRenderersForPayload(marketingRenderers), overrides),
}- Global CSS lives at `src/app/(frontend)/globals.css` — inside the
(frontend)route-group parens, easy to miss. Put the step-3@importright after the template's own@import 'tw-animate-css'line, before@config. - `src/blocks/RenderBlocks.tsx`'s `blockComponents` needs a type annotation. The template declares it with no type, so TypeScript infers a closed literal type from its 5 built-in blocks; spreading a pack's
Record<string, Component>renderer map into it fails withTS7053at theblockComponents[blockType]lookup — which, as a side effect, also turns the template's own pre-existing{/* @ts-expect-error ... */}above<Block {...block} />into a second error (TS2578, "unused directive"), since the widened lookup no longer produces the mismatch that comment was suppressing. Fix both together: annotateblockComponents: Partial<Record<string, React.ComponentType<any>>>(matches this package's ownRenderBlocksProps['components']type) and delete that@ts-expect-errorcomment. - Re-run `payload generate:types` after adding Tome blocks to a collection's
blocksfield, before your next build — otherwise the build's own TypeScript pass sees a stale generated union missing the new slugs. - The render step above (step 5) applies here too — swap the template's hand-rolled
.map()loop inRenderBlocks.tsxfor this package's ownRenderBlockswithgridClassName, exactly as shown there. Skipping it is the single most common way this template ends up with ungapped Tome blocks.
- Site-wide element reset.
@wabbit/tome-ui/baseresetsbody/h1–h6/p/ul/oland similar elements off tokens. It is deliberately NOT part of the required CSS import in step 3 — it applies a site-wide reset that can override a consumer's own typography/reset choices well outside the blocks themselves. Import it only if you want that reset:
@import '@wabbit/tome-ui/base';Per-block stylesheets (.tome-scss / .tome-css)
Every CMS page in a Tome site is one Next route, so Next ships the CSS of every block you've registered on every page. A block stylesheet named *.tome-scss (Sass) or *.tome-css (plain CSS) is instead compiled per block and inlined only on pages that render the block, hoisted into <head> once however many times the block appears. Authoring is the same as a CSS Module: scoped class names, :global(...), composes within the file or from global.
Using a pack needs none of this. Every @wabbit/tome-blocks-* pack ships its stylesheets already compiled to plain JS modules (tome-core's scripts/compile-pack-stylesheets.mjs), so a site that only installs packs keeps a stock next.config, and the packs' render code loads under plain Node too (the Payload CLI, seed scripts, tests). The setup below is for a site that writes block stylesheets of its own.
Site setup (once, only for your own stylesheets). Wrap your next.config and declare the module types:
// next.config.js
import path from 'node:path'
import { withTomeBlockStyles } from '@wabbit/tome-blocks-core/next'
export default withPayload(
withTomeBlockStyles(nextConfig, {
// Only if your .tome-scss files use a path alias or extra load paths:
aliases: { '@/': path.resolve('src') + path.sep },
loadPaths: ['src/styles'],
}),
)// src/tome-stylesheets.d.ts
/// <reference types="@wabbit/tome-blocks-core/tome-stylesheets" />withTomeBlockStyles registers the bundler loader, @wabbit/tome-blocks-core/block-styles/loader, for you; nothing imports that subpath directly. Works the same under webpack and Turbopack, for next build and next dev. A site's own .tome-css / .tome-scss imports fail to build without withTomeBlockStyles, never silently at runtime. .tome-scss needs sass installed in the site. Packs are unaffected either way: the loader's rule matches only .tome-css / .tome-scss, never a pack's compiled .tome-css.js.
In a block:
import { BlockStyles } from '@wabbit/tome-blocks-core/block-styles'
import styles from './Hero.tome-scss'
export function Hero() {
return (
<section className={styles.root}>
<BlockStyles sheet={styles} />
…
</section>
)
}<BlockStyles> works from server and client components alike. A stylesheet imported by client code carries its CSS text in that client JS too, so a client component renders the same style on the client as on the server, and one that first mounts after client-side navigation still gets its styles. Stylesheets imported only by server components add nothing to client JS.
Every file that imports a block stylesheet renders it. It's deduped by href, so a sheet shared by several components (a form's field components, a pack-wide grid) costs nothing extra when each one renders it. Rendering it only in one importer breaks the moment another importer is used without it: that component ships unstyled. The one exception is a file that's always mounted by a sibling that renders the sheet, such as a block's client half or an effect hook. That file names the sibling in a comment, // block-styles: rendered by ./Hero. In tome-core, pnpm assert:block-stylesheets (part of pnpm gate) enforces this and keeps CSS-module imports out of the blocks-* packs.
Cascade: in production, block styles form React's tome-block precedence group, hoisted into <head> once per page and placed after all the CSS Next bundles (tokens, shell, globals, component CSS). In next dev each block renders its CSS as an inline <style> instead: Turbopack dev splits Next's CSS into separate precedence groups that a hoisted block style could land between, and an inline style keeps the production order (after everything in <head>) while updating live on every edit. Either way, a global or component rule no longer beats a block rule of equal specificity just by loading later.
withTomeBlockStyles accepts and returns your own config type, so a typed next.config.ts (const nextConfig: NextConfig) works unchanged.
Not supported (the build fails with a message): composes/@value imported from another file, and relative url() (inlined CSS would resolve it against the page URL). Use absolute paths, full URLs or custom properties.
Editor and lint setup, so the files are treated as SCSS/CSS:
// .vscode/settings.json
{ "files.associations": { "*.tome-scss": "scss", "*.tome-css": "css" } }// stylelint config: lint them with the SCSS syntax
overrides: [{ files: ['**/*.tome-scss', '**/*.tome-css'], customSyntax: 'postcss-scss' }]// .prettierrc
{ "overrides": [{ "files": ["*.tome-scss"], "options": { "parser": "scss" } }, { "files": ["*.tome-css"], "options": { "parser": "css" } }] }Why a non-CSS extension: Next routes every .css/.scss import from a server component through its own CSS pipeline by extension, before Turbopack rules can apply, so a .module.scss file can't be delivered per block under Turbopack.
Public API
| Export | Description | |
|---|---|---|
defineBlock(meta, factory, variants?) | Register a block descriptor and auto-inject _variant field when variants are declared | |
defineBundle(meta, blocks) | Register a bundle descriptor | |
buildVariantField(variants) | Build the hidden _variant select field | |
createVariantAwareBlock({ factory, variants }) | Wrap a factory to apply variant fieldOverrides per-config (also exported, with hasVariants and VariantAwareBlockInput, from the @wabbit/tome-blocks-core/variants subpath) | |
hasVariants(descriptor) | Type guard: does a descriptor declare variants? | |
blockRegistry | Global BlockRegistry singleton (the registry classes and both singletons are also exported from the @wabbit/tome-blocks-core/registry subpath) | |
bundleRegistry | Global BundleRegistry singleton | |
BlockRegistry | Class — block CRUD, variant lookup, resolve() | |
BundleRegistry | Class — bundle CRUD, resolve(), resolveMany() | |
RESERVED_VARIANT_FIELD | '_variant' — reserved field name constant | |
BLOCK_CATEGORIES (also @wabbit/tome-blocks-core/categories) | Category constant map — the eight canonical display values (Content, Layout, Actions, Data, Navigation, Social, Marketing, Utility); the standalone subpath is Payload- and React-free | |
BlockPicker | Payload admin component replacing the block drawer | |
VariantPicker | Payload admin component for the _variant select field | |
useTheme() | Hook: observes document.documentElement[data-theme] | |
Thumbnail | React component rendering a block variant's thumbnail — raster screenshot when a ThumbnailSourceProvider resolves one, else the inline SVG wireframe | |
sanitizeSvg(svg) | Strip XSS vectors from SVG strings | |
resolveThumbnail(blockSlug, variantSlug?) | Resolve SVG string from the generated index | |
PLACEHOLDER_SVG | Wireframe placeholder SVG used for missing thumbnails | |
ThumbnailSourceProvider | Context provider for raster thumbnails — takes a `resolver: (blockSlug, variantSlug) => { light?, dark? } \ | undefined` |
useThumbnailSource() | Hook consumed internally by Thumbnail; returns { resolver, theme } | |
useGalleryThumbsManifest(manifestUrl?) | Hook: fetches a thumbs-manifest.json-shaped file client-side and returns a resolver (or undefined on 404/error) | |
GalleryThumbsProvider | One-liner combining useGalleryThumbsManifest + ThumbnailSourceProvider — what BlockPicker wires internally | |
flattenThumbsManifest(manifest) | Flatten a bundle-keyed manifest into a blockSlug-keyed index (first-wins on slug collision, dev-console warn) | |
buildResolverFromFlatIndex(flat) | Build a ThumbnailResolver from an already-flattened index | |
registerRenderer(slug, component) | Register a block render component | |
getRenderer(slug) | Look up a block renderer | |
hasRenderer(slug) | Check whether a renderer is registered for a block slug | |
getAllRenderers() | Return the full slug → renderer registry map | |
RenderBlock | React component: renders a single block by blockType | |
RenderBlocks | React component: renders an array of blocks | |
catalogSlugForBlockType(blockType) | The catalog slug for a stored blockType (highImpactHero → high-impact-hero, formBlock → form); unknown types pass through verbatim | |
BLOCK_CATALOG | Static AI-readable block catalog array | |
BLOCK_TYPE_SLUGS | Generated stored-blockType → catalog-slug map that catalogSlugForBlockType reads | |
columnSpan (@wabbit/tome-blocks-core/fields/columnSpan) | Optional grid-column override Field for top-level blocks rendered through RenderBlocks | |
registerBlockBundleProductType({ productTypeRegistry, registerProductTypeHooks }) (@wabbit/tome-blocks-core/registry/productHooks) | Registers the block-bundle product type in @wabbit/tome-catalog's productTypeRegistry and its getCardFields hook through @wabbit/tome-core's registerProductTypeHooks. You pass both in: blocks-core depends on neither package, so nothing is discovered for you. Importing the subpath also attempts the registration through require(), which does nothing under native-Node ESM (for example payload generate:types); call this explicitly from payload.config.ts whenever you compose blocks with catalog. Idempotent | |
buildUsageManifest(descriptors) (@wabbit/tome-blocks-core/usage) | Flattens block descriptors' authored usage metadata into a machine-readable manifest for agent block selection/sequencing | |
loadInstalledBlockMeta(root, options?) (@wabbit/tome-blocks-core/usage/node) | Node-only: finds the @wabbit/tome-blocks-* packs installed under a checkout, reads each pack's payload-free @wabbit/tome-blocks-core/meta export, and returns the block entries, the pack each came from and the pack list. Never throws; a pack that cannot load is skipped with a warning on stderr. Feed descriptors to buildUsageManifest | |
useDebouncedResize(...) (@wabbit/tome-blocks-core/utilities/useDebouncedResize) | Shared resize-debounce hook — single timer collapses resize/orientationchange into one callback | |
formatDisplayDate(...) (@wabbit/tome-blocks-core/utilities/formatDisplayDate) | Shared display-date formatter — fixed locale + UTC by default for hydration-safe server/client parity | |
useDocumentTheme(defaultTheme?) (@wabbit/tome-blocks-core/utilities/useDocumentTheme) | Low-level hook observing document.documentElement[data-theme] via MutationObserver; SSR-safe |
Types
All of the below are re-exported from the root barrel and are also importable from the standalone @wabbit/tome-blocks-core/types entry point (types plus the reserved field-name constants RESERVED_VARIANT_FIELD, RESERVED_COLSPAN_FIELD, RESERVED_ROWSPAN_FIELD, RESERVED_ORDER_FIELD — no registry, no React).
BlockMeta, BlockDescriptor, BundleMeta, BundleDescriptor, BlockCategory, BlockVariant, EditorialHint, ThumbnailIndex, BlockRenderProps, BlockRenderer, BlockPickerProps, VariantPickerProps, ThumbnailProps, VariantAwareBlockInput, BlockCatalogEntry, ThumbnailResolver, ThumbnailRasterPaths, RasterThumbsManifest, ThumbnailSourceProviderProps, GalleryThumbsProviderProps
Install (register every block from scratch)
This is the "build a fresh Payload config from nothing" path — register the whole registry and resolve every known block into one field. For adding Tome blocks to a project that already has its own blocks field and its own blocks, see "Add Tome blocks to an existing Payload project" above instead.
pnpm add @wabbit/tome-blocks-core @wabbit/tome-ui payload @payloadcms/richtext-lexical react react-domRequired peers: payload >=3.67.0, @payloadcms/richtext-lexical >=3.67.0, react >=19.0.0, react-dom >=19.0.0, @wabbit/tome-ui >=0.13.0 <1.0.0. Optional peer: @payloadcms/ui >=3.67.0 (only the admin BlockPicker/VariantPicker surface needs it — the render/adapter half needs only React). @wabbit/tome-core and @wabbit/tome-catalog are no longer peers of this package at all (2026-09-24) — registerBlockBundleProductType() now takes both registries as explicit dependency-injected arguments instead of discovering them via an optional peer, which kept better-auth's own peer graph out of a plain Payload site's install.
Wire in your payload.config.ts:
import { blockRegistry, bundleRegistry } from '@wabbit/tome-blocks-core'
// Import your bundle package
import { register } from '@wabbit/tome-blocks-marketing-starter'
register(blockRegistry, bundleRegistry)
export default buildConfig({
collections: [
{
slug: 'pages',
fields: [
{
name: 'layout',
type: 'blocks',
blocks: blockRegistry.resolveAll(),
admin: {
components: {
Field: BlockPicker,
},
},
},
],
},
],
})Peer dependencies
Generated from package.json#peerDependencies (the README gate fails if this table and the manifest disagree).
| Peer | Range | Required |
|---|---|---|
@payloadcms/richtext-lexical | >=3.67.0 | yes |
@payloadcms/ui | >=3.67.0 | no (optional) |
payload | >=3.67.0 | yes |
react | >=19.0.0 | yes |
react-dom | >=19.0.0 | yes |
@wabbit/tome-ui | >=0.16.0 <1.0.0 | yes |
next | >=15.0.0 | no (optional) |
sass | >=1.70.0 | no (optional) |
next and sass are needed only for a site's own per-block stylesheets: the loader uses Next's own CSS Modules plugins, and sass compiles .tome-scss (a site with only .tome-css files doesn't need it). A site that only uses packs needs neither for them. @wabbit/tome-ui 0.16.0 is the first version with grid.global.css, which styles.css imports.
Only @payloadcms/ui is optional among the block-system peers: this package is two surfaces in one, and the render/adapter half needs only React + @wabbit/tome-ui tokens, while the admin half (BlockPicker, VariantPicker, thumbnails) needs @payloadcms/ui. @wabbit/tome-catalog and @wabbit/tome-core are not peers of this package (removed 2026-09-24, standalone-install readiness W1) — declaring even an optional peer on @wabbit/tome-core dragged its own optional better-auth → @sveltejs/kit → vite@8 peer graph into a plain Payload site's install and produced an ERESOLVE against the site's own vite@7. The block-bundle product-type registration (@wabbit/tome-blocks-core/registry/productHooks) now takes both registries as explicit, dependency-injected arguments instead. fuse.js is a real dependency (the picker's fuzzy search).
Thumbnail pipeline
# Regenerate from blocks tree (default: packages/blocks/src/blocks)
pnpm thumbnails:index
# Validate all committed SVGs
pnpm thumbnails:check
# scan specific bundle package roots
pnpm thumbnails:index --roots packages/blocks-marketing-starter,packages/blocks-content-writerRaster thumbnails (opt-in)
BlockPicker fetches /gallery-thumbs/thumbs-manifest.json client-side by default and, when a site has run the gallery's build-time screenshot capture CLI, every <Thumbnail> in the picker renders the real screenshot instead of the SVG wireframe. No wiring required beyond the manifest existing at that path — a 404 is a silent no-op and the picker falls back to the SVG pipeline unchanged:
// No config needed if the manifest lives at the default path:
<Field: BlockPicker />
// Custom manifest path:
<BlockPicker onSelect={...} thumbsManifestUrl="/my-thumbs/manifest.json" />
// Opt out of the fetch entirely:
<BlockPicker onSelect={...} thumbsManifestUrl={null} />For a custom Field wrapper (rather than Field: BlockPicker as-is), or to give raster thumbnails to a <Thumbnail> rendered outside BlockPicker, use the primitives directly:
import { GalleryThumbsProvider, Thumbnail } from '@wabbit/tome-blocks-core'
<GalleryThumbsProvider manifestUrl="/gallery-thumbs/thumbs-manifest.json">
<Thumbnail blockSlug="cta" variantSlug="minimal" />
</GalleryThumbsProvider>Theme (light/dark) is picked up from document.documentElement[data-theme] — the same mechanism useTheme() uses for the rest of the admin UI — observed once per provider, not once per thumbnail.
Server / client posture
This package's .tsx surface splits cleanly along its own subpath boundaries (verified by grep 2026-07-12):
- `@wabbit/tome-blocks-core/render` —
RenderBlock/RenderBlocks(the actual per-block render dispatch primitives) carry no'use client'directive. Safe to import from a Server Component; the client boundary, if any, lives in whatever block renderer they dispatch to, not in these primitives themselves. - `@wabbit/tome-blocks-core/admin` — the entire
BlockPickerfamily (BlockPicker,BlockCard,BundleFilter,CategorySection,EmptyState,RecentFavorites,SearchInput,TagFilter) and theVariantPickerfamily (VariantPicker,VariantPickerField) are all'use client'— genuine interactive Payload admin-UI surfaces (search, filtering, hover state). These belong in the Payload admin bundle only, never a site's public render path. - `@wabbit/tome-blocks-core/thumbnails` —
ThumbnailandThumbnailSourceare'use client'(theme observation viauseTheme(), raster/SVG fallback logic). - `@wabbit/tome-blocks-core/adapters` —
resolveRichText.tsxis server-safe (no directive); its two context-consuming siblings,context.tsx(MediaAdapterContext/RichTextAdapterContextand theirMediaAdapterProvider/RichTextAdapterProvider) andMediaContextFallback.tsx/RichTextContextFallback.tsx, are'use client'because they wrapuseContext.
Net: import @wabbit/tome-blocks-core/render from server code, @wabbit/tome-blocks-core/admin and @wabbit/tome-blocks-core/thumbnails only from the Payload admin panel's client bundle.
Decisions that shaped this package
- Adapters are a module-scope `globalThis` registry, not Context-only — 108 of 109 block renderers across the packs were forced client-only because the old
useRichTextAdapter/useMediaAdaptercontract could only be read via Context hooks defined in'use client'modules, even though adapter config is site-wide, not per-request.registerBlockAdapters()plus the environment-agnosticresolveRichText/resolveMediafunctions let static renderers drop'use client'and become RSCs. - The client Context survives as the override/interop layer, not the primary path — where a provider is mounted (Payload admin live-preview, interactive blocks), it wins over the module registry, so existing provider-based sites see zero behavior change during migration.
- `RenderBlock` was rewritten server-safe rather than deleted — kept for consumers needing per-item dispatch (parity with
RenderBlocks'componentsprop as a registry-free escape hatch), on the condition that it ships only afterregisterAllRenderers()gives the server graph a real registration path — a bare RSCRenderBlockreading the registry before that would silently resolve nothing. - Motion-consuming blocks stay client by design — the adapter contract deliberately does not touch
useMotionAdapters; motion is intrinsically client (GSAP/refs/effects), so the pattern is a small'use client'leaf around a server-rendered static shell, not a server-safe motion adapter.
Exports
| Import path |
|---|
@wabbit/tome-blocks-core |
@wabbit/tome-blocks-core/styles.css |
@wabbit/tome-blocks-core/types |
@wabbit/tome-blocks-core/categories |
@wabbit/tome-blocks-core/slugOverrides |
@wabbit/tome-blocks-core/defineBlock |
@wabbit/tome-blocks-core/defineBundle |
@wabbit/tome-blocks-core/fields/columnSpan |
@wabbit/tome-blocks-core/registry |
@wabbit/tome-blocks-core/registry/productHooks |
@wabbit/tome-blocks-core/variants |
@wabbit/tome-blocks-core/usage |
@wabbit/tome-blocks-core/usage/node |
@wabbit/tome-blocks-core/render |
@wabbit/tome-blocks-core/block-styles |
@wabbit/tome-blocks-core/block-styles/loader |
@wabbit/tome-blocks-core/tome-stylesheets |
@wabbit/tome-blocks-core/next |
@wabbit/tome-blocks-core/adapters |
@wabbit/tome-blocks-core/thumbnails |
@wabbit/tome-blocks-core/admin |
@wabbit/tome-blocks-core/utilities/useDocumentTheme |
@wabbit/tome-blocks-core/utilities/useDebouncedResize |
@wabbit/tome-blocks-core/utilities/formatDisplayDate |
Changelog
0.49.x
3 fixes (0.49.0 – 0.49.4)
- 0.49.4
Admin labels, field descriptions, demo content and messages now use American spellings.
- 0.49.3
Terminal Steps and Proof Ledger now land on the right gallery shelves.
Terminal Steps carried a
codetag that put it among the primitives, beside Spacer and Text Block, and it now sits with content. Proof Ledger matched no shelf and fell into a category of its own, and it now sits with data.Nothing changes on a page: tags only affect how a gallery groups the blocks.
- 0.49.0
The block catalog and block type slugs now list the six new Marketing Starter blocks and the new call to action and section header variants.
stop-viewer,switch-tally,proof-ledger,terminal-steps,layer-stackandterm-rowsget catalog entries and slug pairs, sodata-block-typecarries the catalog slug and a theme can target them. Thectaentry listscommand-close, and thesection-headerentry liststranscript-stageandproduct-split.
0.48.x
1 fix (0.48.0)
- 0.48.0
A page can now carry a breadcrumb trail, an urgent or important notice, and prose set beside its review line and a callout, all from blocks an editor composes.
The new
breadcrumbblock is a labeled navigation landmark holding an ordered list of links that ends in the current page (aria-current="page"); labels wrap at any width, every link is at least 44px tall, an unsafe link shows as text, and no structured data is emitted. Thepost-noticeblock gains an optionaltone(standard, important, urgent) and aheadingLevel: a toned notice gets a mark before its heading and a heavier edge, never color alone, it stays a static note (no alert role), and an untoned notice renders byte for byte as before.The practice pack's reviewed line is now nestable, so it can sit in a layout grid; build the reading layout with a layout grid in its feature-and-sidebar arrangement holding a text block, the reviewed line and a notice. The block catalog lists the new block.
0.47.x
1 fix (0.47.1)
- 0.47.1
The block catalog and block type slugs now list the practice pack's thirteen blocks.
0.46.xMinor
RenderBlocks can map a renamed block back to its catalog slug, so themes still find it.
RenderBlocksgains an optionalblockTypeSlugsprop (stored type to catalog slug), consulted before the generatedBLOCK_TYPE_SLUGStable when writing the per-block wrapper'sdata-block-type, and@wabbit/tome-blocks-core/slugOverridesgainsblockTypeSlugsForOverrides(overrides), which builds that map from the same overrides passed toapplyBlockSlugOverridesandremapRendererSlugs. A site that renames a colliding pack block (agency-essentialsgallerystored asagency-gallerybeside a native Gallery block) now keepsdata-block-type="gallery", so theme treatments keyed on the catalog slug reach it with no wrapper element.catalogSlugForBlockTypetakes the same optional map as a second argument and moves torender/catalogSlug.ts(still exported fromRenderBlocksand the barrels). Without the prop every wrapper renders exactly as before (a golden of the default markup, cut before the change, pins it).
1 fix (0.46.1)
- 0.46.1
The block catalog lists the two new
local-locationsandlocal-desk-stripblocks in the local pack.
0.45.xMinor
BlockStylestakesdedupe={false}so every instance emits its sheet, for a block rendered ahead and chosen on the client.Banner uses it: a layout that renders one alert per tenant and lets the header show the current one no longer gets an unstyled alert when another tenant's alert rendered first. marketing-starter now needs blocks-core 0.45.0 or later.
0.44.x
1 fix (0.44.0)
- 0.44.0
MediaPropsgains an optionalfetchPriority(high,loworauto) for the LCP image.The built-in default media adapter renders it as
fetchpriorityon the<img>when set, and adds nothing when it is not, so every existing image renders byte for byte as before. An adapter built onnext/imageshould pass it through explicitly: next/image 16 does not inferfetchPriorityfrompriority.The
high-impact-herovenuevariant is the first caller.
0.43.x
1 fix (0.43.2)
- 0.43.2
README: subpath and file mentions are written package-qualified (for example
@wabbit/tome-x/render) or as code text, so crawlers no longer resolve them as site URLs.
0.42.xMinor
New
@wabbit/tome-blocks-core/usage/nodesubpath:loadInstalledBlockMeta(root)finds the block packs installed in a checkout and returns their block metadata, ready forbuildUsageManifest.Additive. Node-only; it never throws, and a pack that cannot load is skipped with a warning on stderr.
1 fix (0.42.3)
- 0.42.3
<BlockStyles>emits each block stylesheet once per server request.React dedupes the hoisted
<style>in the DOM but not in the RSC payload, so a block repeated on a page re-serialized its whole sheet each time; a wabbit.com essay with 22 sections shipped 3.74 MB of HTML (now 0.78 MB). Client components are unaffected.
0.39.x
1 fix (0.39.2)
- 0.39.2
The block render registry now keeps a separate set of renderers for the server-components graph and for the SSR/client graph, so a pack renderer that wraps a client component can no longer be rendered from the wrong graph.
- A renderer registered from the server-components graph holds a client reference where it renders a
'use client'component. When the SSR graph looked it up throughgetRenderer,resolveRendererorRenderBlockand rendered it, the page threw "Attempted to call TestimonialClient() from the server but TestimonialClient is on the client". - Each graph now registers into, and reads from, its own store (the same rule the block-adapter registry already follows), so a lookup returns only a renderer registered from the graph it runs in. Within one graph the store is still shared across duplicate module instances.
- A site that registers renderers only from a
'use client'module and renders them with a server-componentRenderBlockmust also call the pack'sregisterRenderers()from a module the server graph imports; the missing-renderer warning inRenderBlocknames this fix.
- A renderer registered from the server-components graph holds a client reference where it renders a
0.38.xMinor
New
AddToBagadapter: a pack renders an add-to-bag control withresolveAddToBag(), and a commerce layer can register the real control.It sits beside the link, media and rich-text adapters with the same mechanics:
registerBlockAdapters({ addToBag }),AddToBagAdapterProvider, the module-graph stamp that keeps a server-graph registration out of the client graph, and a'use client'AddToBagContextFallback. The product is a reference,{ slug, id?, href?, title?, hasVariants?, purchasable? }, so a block that only knows a catalog slug can still render it.The props also take
variantSku,label,priceLabel,size(small|default) andmode(pill|stepper|restock).With nothing registered the default is a working link to
product.href(NOOP_ADD_TO_BAG_ADAPTER,isAddToBagActive: false), carryingdata-add,data-add-state="idle"and the price inspan[data-add-price]. With nohrefit renders nothing and never warns.Existing adapters and their output are unchanged.
RenderBlocksnow writes the block wrapper's inline column asgrid-column: var(--tome-block-column, <placement>)instead ofgrid-column: <placement>. With the property unset the computed column is exactly the placement, so no page moves; a page template or theme sets--tome-block-columnon a wrapper, per breakpoint and without!important, to move one block.The only change to a stored render is that style string (the snapshot and golden tests list it as an explicit allowance);
data-block-placementis unchanged, and a stylesheet must still never match the style text.AddToBagSizegains'compact'(restock mode: a one-line link or button instead of the form, for a tile whose plate is small or shows a second frame). An adapter that does not know it treats it assmall; nothing existing passes it.
0.37.xMinor
TomeLinkandresolveLink()now announce links that open a new tab: atarget="_blank"anchor gainsdata-new-taband a visually hidden " (opens in a new tab)".The hint is added ahead of the link adapter, so it reaches the plain anchor and any registered
Linkalike. A link with anaria-labelgets the phrase appended to the label instead, and links withouttarget="_blank"render exactly as before.Every pack block that routes its links through
TomeLink, including the hero link lists, picks it up with no change of its own.
1 fix (0.37.4)
- 0.37.4
Docs manifests keep each changelog entry's paragraphs, lists and code blocks instead of joining them into one line.
0.35.xMinor
RenderBlocksnames each block wrapper's placement in a newdata-block-placementattribute, so stylesheets no longer have to read the wrapper's inline style text.- The value is
full(1 / -1, the default, orfull-start / full-end),content(2 / -2orcontent-start / content-end), another page-grid area's name (reading-start / reading-endisreading), orcustomfor any other span. The inlinestyleis unchanged; the attribute is the only addition to the markup. - Why: a selector such as
[style^='grid-column:1 / -1;']depends on how the browser last serialised the attribute. Once any script writes to the element's inline style (tome-ui's scroll reveal sets a custom property there), WebKit rewrites the whole attribute asgrid-column-start: 1; grid-column-end: -1; …, and the match fails in Safari only. - New exports from
@wabbit/tome-blocks-core/render(and the root entry):blockPlacementFor(columnSpan),BLOCK_PLACEMENT_ATTRand theBlockPlacementtype, for a site that renders its own wrapper and wants the same attribute.
- The value is
0.34.xMinor
Theme hooks on links reach the DOM.
@wabbit/tome-blocks-core:LinkPropsdeclaresdata-*andaria-*attributes, and the default anchor (NoopLink, used byTomeLink/resolveLinkwhen no link adapter is registered) forwards them. It forwarded onlyhref,className,style,id,targetandrel, so every hook a pack put on a link (data-section-label-link,data-panel-ctaand others) was dropped. Links without such attributes render exactly as before, and props that are neitherdata-*noraria-*still stay off the anchor. A registered link adapter receives the attributes too: spread the rest of yourLink's props onto its anchor to keep them.@wabbit/tome-blocks-dossier-pack: eachzone-directorycell (the link anchor) carriesdata-zone-cell.
0.31.x
1 fix (0.31.0)
- 0.31.0
Self-animating motion components now mark their own root with
data-tome-motion="self", so the opt-in platform scroll reveal in@wabbit/tome-uinever animates their blocks a second time.@wabbit/tome-blocks-core: theRevealboundary carries the marker, which covers every block that wraps its root inReveal.@wabbit/tome-blocks-dossier-pack: the entrance and motion islands (StageMotion,DossierReveal, the phase-ledger rail, case-file rows, evidence plate, receipts trio, record roster and proof plates) carry it.RevealScoperenders no element of its own; when the pack reveal is on, it rendersDossierReveal, which carries the marker.@wabbit/tome-blocks-campaign-pack:CampaignRevealcarries it. As in the dossier pack,RevealScopeadds no element.@wabbit/tome-blocks-cinema-pack: the pull interlude, scene caption and statement band motion and the scrub story stage carry it.@wabbit/tome-blocks-extras: the animatedShowcaseroot carries it. The static fallback does not, because it does not animate.
The attribute changes nothing else. Markup, styles and motion are otherwise unchanged.
0.30.xMinor
BREAKING:
RenderBlockssetsdata-block-typeto the catalog slug (high-impact-hero), not the storedblockType(highImpactHero), which moves todata-block-stored-type.Migration: a stylesheet that targets a camelCase stored value, such as
[data-block-type="highImpactHero"], should target the catalog slug fromBLOCK_CATALOGinstead ([data-block-type="high-impact-hero"]), or move to[data-block-stored-type="highImpactHero"]. Rules that already use the documented catalog slug start matching real CMS rows.Resolution goes through the new generated
BLOCK_TYPE_SLUGSmap, which also covers the blocks whose stored type differs by name (formBlock→form,mediaBlock→media,cardBlock→card,checkList→checklist,customblock→custom,text→text-block). A kebab-stored row resolves too.A block outside the catalog, or one renamed with
applyBlockSlugOverrides, keeps its stored value.catalogSlugForBlockType(blockType)exposes the same lookup.
0.29.x
2 fixes (0.29.0)
- 0.29.0
The generated block catalog now lists the dossier pack's
dated-ledgerblock (columns,rows) and Phase Ledger'sdefaultandrailvariants.Catalog text regenerated from the updated dossier pack metadata.
- 0.29.0
The generated block catalog now lists the High Impact Hero's
quick-askvariant and the Contact block'sestimatevariant.Catalog text regenerated from the updated marketing-starter and agency-essentials metadata.
0.28.x
6 fixes (0.28.0 – 0.28.6)
- 0.28.6
MediaPropsgains optionalfillandimgClassName, so a block can ask a media adapter for an image that fills a box it sizes itself.The built-in media adapters render one
<img>and now putimgClassNameon it besideclassName; they ignorefill, since the block stylesclassNameto fill the box. An adapter that wraps its image (a<div>or<picture>takingclassName) should render the image in fill mode over the wrapper, putimgClassNameon the<img>and passsizesthrough.Nothing changes for a call that sets neither prop.
- 0.28.5
Published packages now contain compiled JavaScript and type declarations under a one-line license banner, and no longer include source maps.
What you install: one compiled
.js(ESM) and.cjs(CommonJS) file per source module, its.d.ts/.d.ctsdeclarations, and the stylesheets, fonts and other assets a package already shipped. Every JavaScript module opens with a comment naming the package and its license:/*! @wabbit/<package> — © Wabbit, LLC. Wabbit Tome Commercial License (see LICENSE.md). Not for redistribution. */.The
.mapfiles and thesourceMappingURLcomments that pointed at them are gone, which roughly halves the size of each tarball.Debugging: the code is still unbundled and unminified, one readable file per module, so a stack trace points at real code with real names. Line numbers in a stack trace are one higher than before, because of the banner line.
A
'use client'directive stays the first statement of its module (the banner is a comment above it), so React Server Component boundaries are unchanged.No API change, no runtime behavior change, and nothing to do on upgrade.
In
@wabbit/tome-blocks-gallery, the source snapshotsextractGallerySourcewrites from an installed pack leave out the license banner line, so a component or config snapshot starts at the code and a paid block's preview shows its first 15 lines of real code. - 0.28.3
The generated block catalog now lists the Term Ledger's
spec-measurevariant.Catalog text regenerated from the updated dossier pack metadata.
- 0.28.1
The generated block catalog now describes the campaign count block as reading live figures from the site's campaign source.
Catalog text regenerated from the updated campaign pack metadata.
- 0.28.0
The generated block catalog now describes the proposal plans block with generic service-level names.
Catalog text regenerated from the updated proposal pack metadata.
- 0.28.0
The block catalog entry for the custom hero now describes it in plain terms and states the correct number of design versions.
0.27.xMinor
styles.cssnow brings the page grid too, andRenderBlocksuses it by default, so a site no longer wires a grid to render blocks.@wabbit/tome-blocks-core/styles.cssimports tome-ui's newgrid.global.cssbeside the design tokens, andRenderBlocksgives its container the globaltome-gridclass when nogridClassNameis passed (DEFAULT_GRID_CLASSis exported). PassinggridClassNameworks as before.With the packs now shipping their stylesheets precompiled, that one CSS line is the whole site setup: no next.config change.
withTomeBlockStylesis still how a site compiles block stylesheets of its own.BREAKING: requires
@wabbit/tome-ui0.16.0 or later (the first withgrid.global.css); npm and pnpm install the peer automatically. ARenderBlockscall with neithergridClassNamenordisableContainernow renders its container with thetome-gridclass, so on a site that loadsstyles.cssthose blocks sit on the page grid instead of in a plain block container.styles.cssalso setsbox-sizing: border-boxinside block wrappers ([data-tome-block-wrapper]), at zero specificity. Block CSS is written for border-box, so on a site without a global reset a full-width padded block overflowed the page; the rule doesn't reach anything outside blocks.compileBlockStylesheet(./block-styles/loader) takes an optionalid, used for the class-name and href hashes instead of the file's path, so a stylesheet compiled at a package's build gets the same names wherever the package is installed.
0.25.x
1 fix (0.25.1)
- 0.25.1
The block-stylesheet loader's relative-
url()check no longer looks inside quoted values.A data-URI SVG that references its own filter (
url("data:image/svg+xml,…filter='url(%23n)'…")) was rejected as a relative URL because the check matched the innerurl(%23n). Eachurl()value is now read whole, so text inside a quoted data URI is ignored, and a genuinely relativeurl(./x.png)is still rejected, with the offending value named in the error.
0.22.x
1 fix (0.22.0)
- 0.22.0
<BlockStyles>now works inside client components.The browser bundle of a
.tome-css/.tome-scssstylesheet carries its compiled sheet too, as the server bundles do. Before, it carried class names only, so a block style rendered from a client component (a site's client footer rendering a pack's Marquee, for example) failed hydration in dev and dropped its styles.In production it would leave the block unstyled when that client component first mounted after client-side navigation. The cost is the CSS text in client JS, only for stylesheets that client code imports; stylesheets imported only by server components are unchanged.
The loader no longer needs a browser/server split, so
withTomeBlockStylesregisters one rule per bundler.
0.21.xMinor
Per-block stylesheets.
A block stylesheet named
*.tome-scss(Sass) or*.tome-css(plain CSS) is compiled per block and inlined only on pages that render the block, instead of shipping in every page's CSS bundle. New exports:./next(withTomeBlockStyles(nextConfig), which registers the loader for webpack and Turbopack),./block-styles(<BlockStyles sheet={styles} />, a hoisted, deduped React 19<style precedence="tome-block">),./block-styles/loaderand./tome-stylesheets(module type declarations).Additive: nothing changes for existing
.module.cssblocks; packs adopt it as they convert. See the README's "Per-block stylesheets" section for site setup.
1 fix (0.21.1)
- 0.21.1
Per-block stylesheet fixes from the first consumer canary (tome-starter).
withTomeBlockStylesnow accepts and returns the caller's own config type. A typednext.config.ts(const nextConfig: NextConfig) failed to type-check, because Next'sNextConfighas no index signatures.- Calling
withTomeBlockStyles(nextConfig)with no options failed the Turbopack build ("does not have serializable options"): unset options were passed asundefined. Only defined options are passed now. - In
next dev,<BlockStyles>renders an inline<style>instead of a hoisted one. Turbopack dev gives each CSS chunk its own precedence group, so a hoisted block style could land between Next's chunks and lose ties it wins in production (seen as a component's padding overriding a block's). The inline style keeps the production cascade and updates live on edit, replacing the dev stale-style cleanup. Production output is unchanged.
0.20.xMinor
RenderBlocksnow emitsdata-block-type="<blockType>"on each per-block wrapper, a public hook themes use to restyle a specific block.The canonical theme selector is descendant:
[data-tome-theme="x"] [data-block-type="<slug>"] [data-block-variant="<v>"](type on the wrapper, variant on the block root). The attribute is additive; it changes rendering only where a stylesheet already targets it.RenderBlock(single-block, registry dispatch) renders no wrapper and is unchanged.
0.18.xMinor
New server-safe pack helpers, so block packs stop copying the same boilerplate (2026-09-24 audit A3 #8).
createPackRegistrar(blocks, bundle)builds a pack's idempotentregister(blockRegistry, bundleRegistry)entry point;createLayerProbe(layerName, load)is the memoized "is this Tome layer registered?" check for domain packs (the pack keeps the dynamicimport('@wabbit/tome-core/…')literal, so blocks-core still has no core dependency); both ship from./registryand the root.mediaRelation(config)returns therelationTofor a media field (config.mediaCollection ?? 'media', typed asCollectionSlug); it ships from./defineBlockand the root.No new subpaths.
5 fixes (0.18.1 – 0.18.3)
- 0.18.3
CSS files are now copied to
dist/by a post-build script instead of a tsuponSuccesshook; no behavior change, and the publisheddist/is identical. - 0.18.3
resolveThumbnail('pricing-plans', …)now returns the pricing block's SVG thumbnails, which were keyed under the block's oldpricingslug.The thumbnail ingester now takes a block's slug from its own
meta.tswhen the directory name differs, and the generated index was regenerated.BLOCK_CATALOGwas regenerated with the new slug and updated block and variant descriptions. - 0.18.1
BLOCK_CATALOGregenerated so thebanner,feature-hero,signal-accordionandsignal-calloutdescriptions match their packs. - 0.18.1
customer-facing wording: internal references removed from admin descriptions, error messages and block metadata.
- 0.18.1
Text Reveal no longer promises a word-by-word scroll animation it does not have; its text renders fully visible, and its meta now says so.
@wabbit/tome-blocks-editorial-pack: thetext-revealblock meta carries the corrected description, summary and role.@wabbit/tome-blocks-core: the generatedBLOCK_CATALOGentry fortext-revealpicks up the same corrected description and role.
0.17.xMinor
Tome block packs now install into an existing Payload project the way the README says: one
npm install, one CSS import, no undocumented steps.Proven by the new fresh-install smoke test (
scripts/blocks-fresh-install-smoke.mjs) against a brand-newcreate-payload-appwebsite-template site.Consumers: list `@wabbit/tome-blocks-core` and `@wabbit/tome-ui` in your own `package.json` if you import from them (npm 7+ and pnpm install required peers automatically, so a fresh
npm installof a pack already brings them in).- One shared `blocks-core` per site. Every pack,
blocks-houseandblocks-extrasnow declare@wabbit/tome-blocks-core(and, where used,-house/-extras) as a required peer with an explicit range instead of a regular dependency, so a site gets exactly one hoisted copy and one adapter registry. - No more ERESOLVE in plain Payload sites.
blocks-coreno longer declares@wabbit/tome-coreor@wabbit/tome-catalog(their optional peer graph pulledbetter-auth→@sveltejs/kit→vite@8against a site'svite@7). Theblock-bundleproduct type still auto-registers when both are installed; new structural typesBlockBundleProductTypeDeps,BlockBundleProductTypeRegistryLike,RegisterProductTypeHooksLike. - Tokens in one line:
@import '@wabbit/tome-blocks-core/styles.css';(new export; imports@wabbit/tome-ui/tokens).@wabbit/tome-uiis now a required peer ofblocks-core. - Rich text and images render with no adapter setup. Built-in defaults render Lexical through
@payloadcms/richtext-lexical/reactand resolve populated Payload uploads; an unpopulated upload id warns once in every environment (previously content vanished silently in production). Registered adapters still win. - Payload's spread-props convention: new
adaptRenderersForPayload(renderers)/adaptRendererForPayload(Component)wrap any pack'srenderersmap for a site that renders<Block {...block} />. - Slug collisions with Payload's templates (
cta,banner,archive,content,code): newapplyBlockSlugOverrides(blocks, overrides)andremapRendererSlugs(renderers, overrides)(@wabbit/tome-blocks-core/slugOverrides). Defaults are unchanged; no stored data migrates. - `blocks-house` owns
gsapandhls.jsas dependencies (previously optional peers that still broke the build when missing), and registers GSAP'sScrollTriggeritself before first use. - Full-bleed bands actually span the grid. Eight
pinnedBandblocks (cinema-pack AmbientBand, MediaPanel, PullInterlude, SceneCaption, ScenePlate, ScrubStory, StatementBand; blocks-house FullBleedInterstitial) now declaregrid-column: 1 / -1at their root as the contract requires. Visible change: inside a tome-ui.grid, these render edge to edge where they were previously squeezed to content width. - `@wabbit/tome-ui`:
.griddeclaresreading-start/reading-endbelow 768px (aliased to the content column), so blocks placed on the reading column no longer collapse to a sliver on phones. - Every pack README gains an "Install into an existing Payload project" section and a peer table that matches
package.json;blocks-core's README carries the full walkthrough.
- One shared `blocks-core` per site. Every pack,
1 fix (0.17.1)
- 0.17.1
README: correct the registry-access step.
It said free-tier packs install anonymously; the registry now requires an install token for every read (free packs and shared foundation packages included). The walkthrough now explains the free path — a no-card account at wabbit.com/signup, a token from Account → Credentials, placed in the user-level
~/.npmrc— what a free token covers (the two free packs plusblocks-core,blocks-extras,blocks-house,tome-ui), and links the customer guide at wabbit.com/docs/get-started.Docs only; no code change.
0.16.xMinor
Raise the
react/react-dompeer floor to>=19.0.0across the linked blocks family.Every pack advertised
react: >=18.0.0while@wabbit/tome-core,chrome,forms,dispatch,longformandreadoutall require>=19— so the React 18 support the packs claimed was unreachable in any real Tome stack: no consumer could satisfy both halves of the graph. The advertised range was not a supported configuration, it was a range nobody could install into.Ruled 2026-09-01: the floor becomes the truth.
Their
devDependenciessaid the same thing from the other direction:reactand@types/reactpinned to^18.0.0while the rootpnpm.overrideshas pinned@types/reactto19.2.14for months, so every pack has in fact been developed and tested against React 19 types the whole time. Those pins move to^19.0.0— a manifest correction, not a version change; the resolved tree is byte-identical.One coordinated bump for the family (these eleven are
linkedin.changeset/config.json, so they version together by design).Consumer impact: a consumer genuinely on React 18 can no longer install these packs. That consumer could not have had a working Tome install anyway — the kernel would have refused the same graph.
Anyone on React 19 sees no change.
Block-adapter registry: each
registerBlockAdapters()call is stamped with the React graph it was made from ('server'for the RSC graph,'client'for anything that can run hooks — browser and SSR), and the*ContextFallbackcomponents read the registry with{ graph: currentAdapterGraph() }, so a registration made from the other graph is treated as absent instead of rendered.On the server the RSC graph and the SSR graph share one
globalThis; a component registered from the RSC graph holds client REFERENCES where it renders'use client'components, and rendering it inside the SSR pass calls that reference as a function ("Attempted to call the default export of … from the server"). Observed on wabbit.com/blocks/\_preview: a directive-free setup module registered the site's link component from the RSC graph, the preview harness rendered pack CTAs in a client tree with no<LinkAdapterProvider>, and every link-bearing block returned 500.Now that case renders the plain-anchor fallback and warns once in development, pointing at the provider. Ungated reads (
getLinkAdapter()with no options) and server-graph reads are unchanged.New exports:
AdapterGraph,currentAdapterGraph, and optional{ graph }on the register/get functions.dist now raw-Node loadable: relative specifiers get explicit extensions post-build.
buildgains&& node ../../scripts/fix-dist-extensions.mjs --strictas its last step, joining the 13 packages that already ran it. tsup buildsbundle: falseand emits relative specifiers exactly as the TypeScript source wrote them — extensionless — which bundlers resolve and raw Node does not (ESMERR_MODULE_NOT_FOUND; CJS worse,require('./x')finds the ESM.jstwin and Node 22+require(esm)then dies on that file's own extensionless import). Every consumer outside a bundler hit this: the payload CLI under plain node,generate:types,generate:importmap, ops scripts, codegen tools.No source changes, no API changes, and bundler consumers are unaffected — extensioned relative specifiers are universally resolvable.
Two supporting changes made the wiring possible, both in repo scripts rather than package source.
fix-dist-extensions.mjsnow skips bundler-asset specifiers (.css,.module.css,.scss, fonts, images, shaders) by explicit extension allowlist instead of reporting them as unresolvable — that single gap is why the 13 prior adopters were exactly the 13 packages that ship no CSS, since--strictexited 1 on any package with a relative stylesheet import.Dotted MODULE names (
./config.meta,./x.variants,./y.demo) are deliberately NOT treated as assets and still get.js/.cjsappended.assert-node-loadable.mjsgained the matching carve-outs so the new repo-wide CI gate reports real defects only: a resolution failure whose path lands undernode_modulesis a peer SKIP (next@15 has no exports map, sonext/imagefails as an absolute path), and a bundler-asset load failure is an environmental SKIP (CJS surfaces it asSyntaxError: Unexpected token '.'raised from inside the stylesheet).Verified before/after on four packages built one at a time: print 8 FAIL → 0, readout 22 FAIL → 0, ai 3 FAIL → 0, gamification 2 FAIL → 0 (its failure was the other signature — a
directory importmissing/index). cop was already clean on a fresh build, so the audit's "27 of 46 fail" figure includes at least one package whose local dist was merely stale.Manifest metadata:
homepage,bugs,engines.All 46 publishable manifests were missing the three fields a consumer sees before any code (2026-09-01 sale-readiness audit §6). Metadata only — no source, no build, no runtime change.
homepagedeep-links to that package README on GitHub (.../tree/main/packages/<dir>#readme). Without it a registry page links to the monorepo root and the reader has to guess which of 46 folders they want.bugs.urlpoints at the repo issue tracker, so a paying customer has a place to report a defect that is not email.engines.nodeis>=22, matching the rootenginesand.nvmrcset the same day. This is a real floor, not decoration: CI on Node 20 could not expand the glob the block packs use fornode --test, and a package installed on Node 20 fails at a runtime the installer cannot connect back to the version.
The forcing function ships with the change:
scripts/assert-manifest-metadata.mjs(rootpnpm assert:manifest-metadata, wired intoplatform-discipline.ymlbesideassert:license-metadata) fails when any publishable manifest lacksdescription,repository.directorymatching its own folder,homepage,bugs,engines.nodeequal to the repo floor,license,filesorsideEffects. It reported 138 violations before this change and 0 after.README fixes surfaced by the extended
assert:readme-contractgate (2026-09-01 sale-readiness audit, Tier 2), each verified against the package's own manifest or source.- blocks-core — the
./categoriesand./typesentry points are now named in the Public API section; both were published but undocumented. - core — added
/access/orgScoped,/access/vendorScoped,/infra/healthand/infra/env-scaffoldto the additional-subpaths table, and noted that/auth/collections/roleshas a real/auth/collections/Rolescase alias in the exports map. - crowdfund —
CROWDFUND_LAYER_VERSIONis also published standalone at./version; the row now says so. - dispatch — the eight per-block
./blocks/*config subpaths and all eight./components/*component subpaths are enumerated instead of one "etc." row. - forms — the peer table now lists
@wabbit/tome-core,@wabbit/tome-uiandtypescript, which are declaredpeerDependenciesbut appeared only in prose (or not at all). - lms-ui —
StudentProfileEditoris flagged@deprecatedin the component table, matching the tag its source already carries.
- blocks-core — the
README peer tables, and the gate that now requires them.
Sixteen packages declared
peerDependenciesand documented them nowhere a reader could scan — in prose inside an install paragraph, in a transposed "compatibility matrix" with the peers as columns, or not at all. Docs only; no source, no manifest, no runtime change (the one manifest change in this PR, admin'ssonnerpeer, has its own changeset).Each of the sixteen gains a
## Peer dependenciessection generated from its ownpackage.json—| Peer | Range | Required |, one row per peer, the range verbatim,no (optional)read frompeerDependenciesMeta, plus one sentence on what is a realdependencyrather than a peer and why the optional ones are optional. The worst omissions this surfaced:@wabbit/tome-coredocumented 2 of its 13 peers and left out bothnextand@payloadcms/richtext-lexical, which are required;@wabbit/tome-adminlisted 5 of 20;@wabbit/tome-readoutand@wabbit/tome-sclisted none.Eight block packs carried a hand-typed compatibility table that had drifted a full React major — still
>=18after the peer floor moved to>=19.0.0— and none of the eight listedreact-domat all. Those tables are retired in favor of the generated one, with a line saying what they used to claim so the next reader does not reinstate them.The forcing function ships with the fix:
scripts/assert-readme-contract.mjsnow FAILS a package that declares peers without a peer table (a markdown table whose header row names a Peer and a Range column — the existingOptional?andNotesthird columns still pass, so the thirty already-conforming READMEs were not touched). It is deliberately shape-only, not row-level: asserting that each row agrees with the manifest is the Tier 2 generation work.Verified non-vacuous by breaking one table's header and watching the gate fail, then restoring it.
CONTRIBUTING.md's assert-script list — which said "five" while sixteen existed — and the three guides that describe this gate were corrected in the same pass.
1 fix (0.16.6)
- 0.16.6
RenderBlockaccepts an optionalcontextprop and forwards it to the renderer asBlockRenderProps.context(typedunknown; each pack narrows its own shape).Lets a host pass per-page data — e.g. a deal's live totals — to data-bound blocks without a React context provider. Omitting it changes nothing.
0.15.xMinor
Route pack block links through the
LinkAdapterinstead of raw<a href>.LinkAdapter(0.14.0) shipped the seam; this connects it. 67 anchors across 55 renderer files in 10 packs now render through<TomeLink>, so a consuming site that registers a link adapter gets its route transition on pack blocks — previously impossible by construction, since a pack cannot import the consumer's transition component and the consumer cannot reach into a pack's render tree.New in `@wabbit/tome-blocks-core`: `<TomeLink>`, a component form of
resolveLink().resolveRichText()/resolveMedia()are functions because they turn a data value into content; a link _wraps children_, and the function form forces multi-line JSX through achildren:prop.<TomeLink href={…}>…</TomeLink>is a drop-in for the<a>it replaces. It delegates toresolveLink(), so there is exactly one resolution path, and it stays directive-free so RSC pack renderers can use it without becoming client components.`LinkProps.href` is now `string | null | undefined`. Block data routinely carries an optional URL, and the raw
<a href={undefined}>these calls replaced was legal markup. Narrowing it tostringwould have forced ~10 non-null assertions across the packs and changed behavior at each.NOOP_LINK_ADAPTERnormalizes null toundefinedso React omits the attribute — the unregistered path stays byte-identical to the pre-adapter markup.Behavior is unchanged for every consumer that has not registered a link adapter, which is currently all of them:
resolveLinkfalls back to a plain<a>.Deliberately left as raw
<a>:EditorialFootnotes— its#fnref-*anchors are in-page backlinks. Client-routing them would play a page transition for a jump within the same document.PricingPlans/PricingPlanCard— these already accept an injectable anchor component, a more expressive consumer mechanism that predates the adapter.LogoSlider— a self-closing, childlesstarget="_blank"overlay anchor. Always external, so the adapter would hand it straight back to the browser.@wabbit/tome-longform— it has zero runtime dependencies and does not peer onblocks-core. Adding that edge to the layer graph is its own decision, not a sweep side effect.
3 fixes (0.15.8 – 0.15.24)
- 0.15.24
Post Hero and Custom Hero catalog copy now describes what the blocks render, not their upstream military lore.
Post Hero renders a full-bleed cover-image header with an accent-marked category tag, a visibility badge, and a mono byline strip — nothing about it reads "SITREP tactical," so the description, editorial role, and both variant descriptions now say what the reader sees. Custom Hero's description drops the "COP tactical layouts / SITREP post headers" jargon for plain treatment names. blocks-core BLOCK_CATALOG mirror entries updated to match.
Enum IDs (
sitrep1,cop1…) and schema field names are unchanged, per the 71d3b09 purge discipline. - 0.15.9
Purge client-specific lore and Star Citizen universe references from all non-SC packages (content and labels only — no schema field names, slugs, or enum values changed).
- dispatch: demo content rewritten as an incident-war-room / ops-bridge scenario (SEV-1 bridge traffic, failover runbooks, recovered security-report transcript) plus neutral original fiction for inherently fictional variants (Relay Station Aurelia personal log, SV Aurelia ship log). Config field-description examples de-lored (old client- and universe-specific labels → neutral equivalents).
- readout: all 9 blocks' demo props rewritten as business-operations console data (deployment phases, sprint objectives, service status, perimeter traffic, on-call roster, infrastructure asset cards). Config examples de-lored.
- blocks-signal-theme: demo props for the 33-block pack rewritten as an original search-and-rescue expedition serial ("Operation Long Wake", SV Aurelia, Meridian Reach) with zero client or SC references; config examples de-lored. Pack positioning (SC-tier bundling per OQ-4) unchanged.
- blocks-extras / blocks-content-writer: Custom Hero and Post Hero meta descriptions stop name-dropping the client; "Callsign" field descriptions neutralized to "Author name or handle"; provenance comments neutralized.
- blocks-core: BLOCK_CATALOG mirror entries refreshed for custom-hero and post-hero only; registry comment neutralized.
- blocks-gallery: SourceBadge label for the
vngdsource value now renders "Legacy" (enum value unchanged). - accounts / core / lms / ui / org / admin / motion / longform / cop / blocks: internal provenance comments, shipped CSS comments, and consumer-visible field descriptions that named a specific client replaced with neutral "upstream" phrasing; longform package description de-lored. Historical CHANGELOG entries left untouched.
- 0.15.8
Neutralize gaming/military-flavored language across the org surfaces — labels, descriptions, and demo content only; zero schema changes (all field names, collection slugs, and enum/select VALUES are byte-identical, so no consumer data migration).
- blocks-org-pack: CampaignBanner's
codenamefield is now labeled "Name" with a business example ("Spring Launch" demo replaces "Operation Nightfall … contested systems"); MemberCard/MemberGridrankfields labeled "Role" with business-ladder demo values (Principal/Staff/Senior replace Captain/Lieutenant/Sergeant, "Fleet Commander" → "Design Lead"); EventCalendar demo uses business events (workshop, hiring open house, quarterly business review — "Upcoming Operations" heading → "Upcoming Events"); OrgChart meta/variants describe a generic three-level hierarchy instead of Division → Teams → Squads (render output was already 100% data-driven — level headings come from the authored rows, so no new props were needed); block meta descriptions/usage neutralized throughout. - tome-org: flavored admin LABELS get neutral text while stored values stay put — Event status
boarding/debrieflabeled "Check-In"/"Wrap-Up"; eventTypeoperation/patrol/examlabeled "Initiative"/"Outreach"/"Assessment";securityLevellabeled "Access"; Campaigncodenamelabeled "Internal Name" and campaignTyperecurring_op/special_operation/deploymentlabeled "Recurring Series"/"Special Initiative"/"Rollout"; Memberclassificationlabeled "Directory Visibility" withclassifiedlabeled "Private", "Chain of command" → "Reporting line"; RanksecurityClearancelabeled "Access Level" and categorycommandlabeled "Management"; Squad squadTypefire_team/flightlabeled "Crew"/"Pod",callsignlabeled "Nickname"; Membershipsquadronlabeled "Unit", role example "Pointman, Medic" → "Coordinator, Facilitator"; Position abbreviation example "CO, XO" → "COO, PM", categorycommandlabeled "Executive". The configurableDEFAULT_ORG_TERMINOLOGY(Division/Team/Squad/Rank) is deliberately unchanged — it is the documented override seam and@wabbit/tome-scinherits it for its themed collections. - blocks-core: BLOCK_CATALOG entries for campaign-banner, member-card, and org-chart re-mirror the updated pack meta descriptions (catalog is generated from pack meta; only the entries owned by this change were refreshed).
- blocks-org-pack: CampaignBanner's
0.14.xMinor
Add
LinkAdapter— the third block adapter, alongsideRichTextAdapterandMediaAdapter.Every link-rendering block in every pack emits a raw
<a href>, which is a full document load. A pack cannot import a consuming site's route-transition component (wrong layer direction), and a site cannot reach inside a pack's render tree to swap the anchor — so pack links could never participate in a consumer's page transition, by construction.registerBlockAdapters({ link: { Link: MyTransitionLink, isLinkActive: true }, }); resolveLink({ href: block.ctaUrl, className: styles.cta, children: text });Mirrors the existing adapters exactly: module registry +
'use client'Context fallback, withresolveLinkstatically importingLinkContextFallbackso the bundler cuts the client boundary at build time.One deliberate divergence: the unregistered default is fully functional, not a stub.
NOOP_LINK_ADAPTERrenders a real<a>andresolveLinknever dev-warns.Rich text and media have no meaningful fallback, so their absence is always a misconfiguration. A link does have one: navigate.
Losing the transition is degraded; losing the navigation is broken — so a plain anchor is a supported end state for a standalone pack.
New exports:
resolveLink,LinkAdapterContext,LinkAdapterProvider,useLinkAdapter,NOOP_LINK_ADAPTER, and theLinkProps/LinkAdaptertypes.BlockAdaptersgains an optionallinkslot.Fully additive — no existing adapter, export, or block behavior changes. Pack renderer migration off raw
<a>is a follow-up.
0.13.xMinor
render: shared
Reveal(scroll-reveal client wrapper) +renderEmphasis(asterisk→em) helpers for showcase variant graduation.Ported verbatim from tome-starter's
src/components/Revealandsrc/utilities/renderEmphasis(showcase program Phase 3.7) so pack renderers graduating starter-native showcase variants (e.g.@wabbit/tome-blocks-marketing-starter's ctadoor-strip/doors) can share them instead of vendoring copies. Both exported from the./rendersubpath.
0.11.x
9 fixes (0.11.0 – 0.11.2)
- 0.11.2
Fix
resolveMedia/resolveRichTextcrashing pages withTypeError: Cannot read properties of null (reading 'useContext')during RSC render.Both resolvers preferred a "registered adapter fast path" —
createElement(getMediaAdapter().Media, …)on a component read from the globalThis registry at runtime. A bundler cannot statically trace that runtime reference to the adapter's module, so it never cuts the RSC client-reference boundary for the component's subtree.When the registered
Mediarenders an intrinsically-client primitive (next/image, which callsuseContext), that primitive executes during the Flight serialization pass with a null dispatcher and throws — 500-ing any page whose server pack renderer (e.g.editorialFigure) resolves media. It reproduces even when the registered component itself carries'use client'; the boundary is defeated by the runtimecreateElement, not the directive.(Verified against wabbit-site-core
/static-site-launch, 2026-07-15.)Both resolvers now ALWAYS defer to their statically-imported
'use client'fallback component (MediaContextFallback/RichTextContextFallback), which the bundler CAN boundary at build time. Adapter resolution (Context override first, module registry second) moves into those components via new hook-freeresolveActiveMediaAdapter/resolveActiveRichTextAdapterhelpers, so both adapter sources still work, including for a server component with no provider ancestor.Consequence: media/rich-text now always render inside a client boundary (no zero-JS server-only path). This is unavoidable for any
next/image-based adapter, which is intrinsically client — mirroring the spec's existing "motion is intrinsically client" carve-out.A future opt-in
isServerSafeadapter flag could restore the zero-JS registry path for provably<img>-only adapters; it is deliberately not the default, because the default must be correct. - 0.11.0
Pre-existing test-suite fixes (unrelated to recent feature work).
high-impact-hero'sillustrationHerovariant carried two tags (brand,illustration) outside the canonical taxonomy declared inv2-coverage.test.ts. Fixed to['editorial', 'playful']. TheillustrationHeroslug itself is kept camelCase (not renamed to kebab-case) because it shipped in the published0.5.0release (2026-05-21) and is stored verbatim in consumer content as a_variantfield value — renaming would silently break every stored document that already selected it. The kebab-case rule now carries a documented, slug-scoped exception with a deprecation trigger (next content-breaking major version for this package, when a stored-content migration pass is already budgeted).blocks-core'stest/top20-variants.test.tsimported@wabbit/tome-blocks-marketing-starterand the other bundle packs directly, which a later refactor made unresolvable when it removed blocks-core's devDeps on those packs to break the blocks-core ↔ marketing-starter dependency cycle. Relocated the test to@wabbit/tome-blocks(the meta-package that already depends on every pack for exactly this purpose) rather than re-adding the removed devDeps and recreating the cycle. Test-only change; no runtime behavior changed in either package.
- 0.11.0
Peer/dependency contracts now tell the truth. blocks-core: importing the root barrel no longer hard-crashes when the optional peers (
@wabbit/tome-core,@wabbit/tome-catalog) are absent —productHooksregistration is lazily guarded; NEW explicitregisterBlockBundleProductType()export (root barrel +./registry/productHookssubpath) for deterministic, format-safe registration frompayload.config.ts(the import-time auto path no-ops under native ESM, which affectsgenerate:types-visible product-type options — call the explicit API when composing catalog). chrome:nextis now a required peer (>=14) — it was declared optional whilenext/navigation/next/linkwere hard-imported. readout: declares its realnextpeer;createReadoutBlocks({ accentPalette })is now implemented (field-tree narrowing, dispatch's mechanism) instead of a documented no-op. blocks-lms-pack / blocks-catalog-pack:@wabbit/tome-coremoves from harddependenciestooptionalDependencies, matching org-pack and the packs' own documented degrade-gracefully design. - 0.11.0
Every package now declares an explicit
sideEffectsfield (38 added; motion/engine/forms already correct).Registration-bearing modules (render files'
registerRenderer,blocks/*/index.tsdefineBlockself-registration, widgetregister.tsfiles, productHooks, permission self-registrations, print templates, chrome built-in variants) are listed so bundlers can tree-shake everything else WITHOUT dropping import-time registrations — previously the field was unset, which blocked cross-module tree-shaking through the barrels entirely. Never blanketfalseon a package with registration or CSS. - 0.11.0
Small verified fixes: agency-essentials
Contactgains its missing'use client'(it calls the rich-text adapter hook; direct RSC import crashed). chromeNavGuardnow dev-warns when its capability gate fails to load while arequiredCapabilityis set (the fail-open contract itself is unchanged and now documented). blocks-coreBLOCK_CATALOG.tscorrupted entries corrected from real block meta (content-two-column, content-with-corner-notch, signal-ship-card names/descriptions; gallery variants filled) + drift-risk header.Stale docstrings fixed (chrome
HeaderLogo, blocks-gallery registry header, lms-ui payload JSDoc import path). blocks meta-package backcompat suite now asserts the RENDER registry resolves renderers (previously only descriptor registration was tested — a dropped render import shipped silently). - 0.11.0
Gallery browse performance: search input debounced at 80ms via a local-state +
useDebouncedValuesplit (instant keystroke echo, one grid re-filter per pause; immune to context-sibling re-render stomps),BlockCardwrapped inReact.memo(props verified referentially stable — unrelated re-renders no longer re-run every card's hover/visibility machinery). blocks-core publishes./utilities/useDocumentThemeas a lean subpath (mirroringuseDebouncedResize) so Payload-free consumers can reach the theme hook without the admin barrel's@payloadcms/uigraph; gallery's local MutationObserver copy replaced with a narrowing wrapper over the canonical hook.Also: BlockPicker's artificial
setTimeout(0)skeleton delay removed; RecentFavorites static data-attributes moved from an effect into JSX. - 0.11.0
The clientization migration: 127 render components across seven packs dropped
'use client'— every file individually re-verified hook/handler/context-free before stripping; adapter-consuming static blocks converted toresolveRichText/resolveMedia.Exactly 20 of 155 renderers remain client, each for a verified reason (state/effects/motion, or a documented client-shell composition contract), enforced by the new
assert:rsc-boundariesCI script (per-pack manifest; fails loudly if a directive creeps back or a count drifts). Every renderer-bearing pack now exports./render/register(renderersmap + explicitregisterRenderers()), aggregated by@wabbit/tome-blocks's newregisterAllRenderers()— the format-safe registration path for server component graphs, where the legacy import-time barrel registration never executes (that legacy path is unchanged and remains supported until the spec's deprecation trigger).RenderBlockis rewritten server-safe: directive-free, optionalcomponentsprop (RenderBlocks parity) → registry fallback, dev warn-once naming both fixes on a miss; its docs state the explicit-registration prerequisite. Rendered output is byte-identical everywhere; behavior change only for consumers rendering migrated blocks in RSC WITHOUT a provider or registration — they get the documented warn + graceful degradation instead of silent client bundling. - 0.11.0
Server-safe adapter contract (spec 2026-07-12, waves M0–M1). blocks-core gains
./adapters:registerBlockAdapters({ richText?, media? })(explicit, idempotent, lazily globalThis-anchored — layerRegistry pattern) plus environment-agnosticresolveRichText(value, opts?)/resolveMedia(value, opts?)callable from RSC and client alike.The adapter React Contexts now live in blocks-core (
adapters/context.tsx); blocks-extras' adapter modules are thin re-exports (zero API break) and its Providers additionally sync their adapter into the registry (guarded write-during-render, documented). Unregistered-registry resolution returns a client fallback element that reads the Context — provider-based sites see zero behavior change even when migrated blocks execute as Server Components; sites that callregisterBlockAdaptersfrom a module in both graphs get pure server rendering.Migration contract for consumers: call
registerBlockAdaptersat config/app scope when adopting RSC-rendered blocks; theuseRichTextAdapter/useMediaAdapterhooks remain functional (deprecated-in-place; removal trigger in the spec). - 0.11.0
DRY adoption sweep (the audit's "adoption, not extraction" rule): crm/deals capability presets delegate to core's
sessionHasCapabilityOrLegacyAdmin; new corebuildOwnershipWhere/ownershipOrBypass(via./access) adopted by core's vendorScoped, catalog's vendor-scoping, and org's ownOrScoped (public APIs unchanged);slugField()adopted at 7 sites where semantics matched exactly (core lms collections + createMemberCollection — replacing a third independent slugify), with ~25 sites honestly skipped for named semantic divergences (auto-regenerate-on-clear vs allow-empty, collection-level hook pattern) now listed as core-enhancement candidates; newformatDisplayDatein blocks-core utilities (UTC-pinned, hydration-safe) adopted at 5 verified-identical sites; lms-ui consolidates its two certificate date formatters locally;useMediaQuery/useIsMobilepublished from tome-ui and adopted by AppShell + admin's SidebarProvider; gamification'sawardPointsnow uses the authoritativegetPointsBalance(fixes a divergent 1000-row scan cap vs the correct 10000).
0.10.xMinor
BlockPicker raster thumbnails: new GalleryThumbsProvider/ThumbnailSourceProvider API and thumbsManifestUrl prop render real gallery screenshot captures in the admin picker with a raster → authored SVG → placeholder fallback chain; shared useDocumentTheme utility; placeholder SVG migrated to canonical --tome-color-\* tokens.
0.9.xMinor
layoutGrid nesting capability audit + render hardening (Wave 2).
Tags the
nestablecapability flag (and aminColSpanfloor for internally-wide blocks) across the offered blocks in the core packs (extras, marketing, content, agency, editorial, signal), so thelayoutGridcontainer's derived child allowlist —blockRegistry.getNestableBlocks()— is populated rather than empty. Excluded by design (left non-nestable): containers (editorialSpread,editorialSection,split-view,stacking-wrapper,grid,layoutGrid) to enforce the one-level depth cap; full-bleed heroes/banners (band-owners); and inline Lexical blocks (not block-level grid children).minColSpan: 2is set on the internally multi-column blocks (card-grid,bento-section,content-two-column,signal-stats,signal-image-grid,signal-data-table) so they cannot be crammed into a single-column cell.Also hardens
LayoutGrid's child↔span pairing: children are now flattened with null slots preserved (instead ofReact.Children.toArray, which drops nulls), so an unrenderable child can no longer shift every later child's span metadata onto the wrong block. Adds a dev-mode warning when the consumer's child count doesn't match the item count.@wabbit/tome-blocks-coreis bumped to release the layoutGrid platform primitives merged earlier but never published (the registry's0.8.0predates that merge):BlockMeta.nestable/minColSpan,withChildPlacement, the reserved_colSpan/_rowSpan/_orderfield constants, andblockRegistry.getNestableBlocks(). Without this, a consumer wiring the layoutGrid allowlist would call agetNestableBlocksthat its installedblocks-core@0.8.0does not have.Domain packs (lms, catalog, sc, org) are intentionally deferred — they compose into their own domain layouts and can opt in when a consumer needs them.
2 fixes (0.9.2 – 0.9.4)
- 0.9.4
Docs-manifest emitter pipeline (W3 ship-readiness).
@wabbit/tome-blocks-corenow ships a standalone Node ESM CLI atscripts/emit-docs-manifests.mjsthat emits per-package documentation manifests (index.json, packages/<slug>.json, changelog.json) by reading what packages already carry — READMEs, the payload-free<pkg>/metablock-usage barrels, package.json exports maps, and CHANGELOG.md. It is the docs-pipeline sibling of the gallery source extractor and is consumed by host sites at prebuild:node node_modules/@wabbit/tome-blocks-core/scripts/emit-docs-manifests.mjs --output-dir <dir> --scope <scope.json>.To let the emitter import block metadata uniformly without dragging Payload config into a build script, the
./metapayload-free subpath (BlockMetaEntry[]) is extended to the remaining offered blocks packs — agency-essentials, catalog-pack, lms-pack, org-pack, and signal-theme — mirroring the existing editorial-pack / marketing-starter / content-writer / extras barrels. Each block'sBlockMetawas relocated verbatim into a payload-free sibling meta module and re-imported by its block config; no meta values changed.Every supported-core package additionally adds
CHANGELOG.mdto its publishedfilesarray so the next publish cascade ships changelogs the emitter can read from installed tarballs at prebuild. - 0.9.2
D3 breakout: additive optional
BlockMetafieldsdefaultBreakout/pinnedBandfor per-block breakout policy (pinnedBandis what the D6 Rule-D forward-tripwire guards).
0.8.xMinor
Batch 0 of the inserter/variant architecture (2026-06-27 spec): wire the variant picker onto
_variantand ship the per-block usage/intent layer.- Variant picker on `_variant` — the auto-injected
_variantfield is no longeradmin.hidden; it renders the thumbnailVariantPickerField(a newuseFieldadapter exported from/admin), referenced by the@wabbit/tome-blocks-core/adminpackage path (lazy via importMap — never enters the config graph).defineBlockthreadsblockSlug+ a serializable variant list throughclientProps. This makes declared variants (e.g.editorialSpread's 5) editor-reachable for the first time. Degrades to a native select when no thumbnails are ingested. - Render-registry variant dimension —
registerVariantRenderer/registerVariantRenderers/hasVariantRenderer/resolveRendereradd a{variant → component}dispatch alongside the existing slug-keyed registry (additive, back-compat). - Per-block usage/intent layer — additive optional
BlockMeta.usage(BlockUsage: summary / whenToUse / pageTypes / howToUse / pairsWith / sequence / avoidWhen / register) following therequiresMotion/requiredCapabilitiesprecedent, plus a newbuildUsageManifest()(./usagesubpath) that flattens descriptors into a selection/sequencing index for assembling agents. Exemplar authored oneditorialSpread. - Adds
@payloadcms/uias an optional peer dependency (only the/adminsubpath needs it).
Linked family aligns to 0.8.0. No breaking API changes (all additive;
_variantbecoming visible is a behavior change, not an API removal).- Variant picker on `_variant` — the auto-injected
0.7.xMinor
B1 — block tiering formalization + blocks-extras free/paid split.
- blocks-core: add
'addon'to theBundleMeta.tierunion (additive; existing'pro'values stay valid). - blocks-extras: register TWO bundles from one package (Option B) — a free
extras-primitivessub-bundle (9 structural primitives) + the paidextrasremainder (39 blocks, was 48). Mechanically additive: no import-path changes, all blocks still exported + registered, existing content keeps rendering, existingextrasentitlements keep working (the primitives are now free to everyone). The tier-scope change is the only semantic shift. - signal-theme + sc-pack: tier
'pro'→'addon'(sold independently of the tiered subscription track). - blocks-gallery: widen the
@wabbit/tome-blocks-corepeer to^0.5.9 || ^0.6.0 || ^0.7.0so the 0.7.0 bump doesn't force a spurious major (it's a types-only peer). Patch.
Release note: the blocks family is
linked, so this aligns the whole family to 0.7.0. Minor (not major) is deliberate — 0.7.0 still gates explicit consumer adoption (^0.6does not auto-resolve 0.7.0), without declaring a symbolic 1.0.0 before the marketplace launch.- blocks-core: add
0.6.x
1 fix (0.6.2)
- 0.6.2
Platform-wide peer-range sweep: every
workspace:*/workspace:^entry inpeerDependenciesreplaced with an explicit semver range (@wabbit/tome-core >=1.0.0 <2.0.0,tome-ui >=0.9.0 <1.0.0,tome-motion >=0.2.0 <1.0.0,tome-catalog >=1.1.0 <2.0.0,tome-admin >=0.5.0 <1.0.0;tome-crmranges standardized to>=0.2.0 <1.0.0).The workspace protocol publishes as an exact-version pin, so every substrate bump stranded installed dependents — the breakage class proven by marketing@0.1.0/deals@0.1.1 requiring
tome-crm@0.2.0exactly. devDependencies keepworkspace:*for the local link. (@wabbit/tome-admin-progot the same source fix but is rc-versioned; it carries the change on its next intentional release.)tome-crm additionally gains a once-per-process production warning when the capability-registry fallback grants access — the bootstrap heuristic (any authenticated user passes
crm:read) now announces itself instead of running silently on sites that forgot to seed capability grants (2026-06-10 audit hardening item).Graph-truth additions (same hygiene wave): tome-deals declares its lazy print integration as an optional peer (
@wabbit/tome-print >=0.1.0 <1.0.0); tome-intake declares its lazy catalog routing strategy (@wabbit/tome-catalog >=1.1.0 <2.0.0, optional). These were undeclared dynamic imports — invisible to consumers and to pnpm's build topology.
0.5.xMinor
Linked cohort version alignment (no functional change in this package).
4 fixes (0.5.1 – 0.5.9)
- 0.5.9
Updated dependencies [8947ff1]
- @wabbit/tome-core@1.0.12
- @wabbit/tome-catalog@1.1.3
- 0.5.7
Updated dependencies [36dc023]
- 0.5.7
Updated dependencies [2612799]
- @wabbit/tome-core@1.0.11
- @wabbit/tome-catalog@1.1.2
- 0.5.1
Add a
./fields/columnSpanexport to the packageexportsmap so registry consumers can import the sharedcolumnSpanfield factory directly.Under path-alias consumption the deep path resolved against source; the
exportsmap enforces it under registry consumption. Surfaced by tome-starter'sSection/config.tsduring the move to registry consumption.
0.4.xMinor
Register `block-bundle` as a Tier 1 product type + wire Tier 2 `getCardFields` hook.
@wabbit/tome-blocks-corenow self-registersblock-bundlein@wabbit/tome-catalog'sproductTypeRegistryat module-load (mirrors the catalog defaults pattern). Bundles previously lived only in the in-memoryBundleRegistry; they now appear in catalog admin's product type dropdown.Also wires a
getCardFieldshook against@wabbit/tome-core/registry/productTypeHookRegistryfor slug'block-bundle'. The hook resolves linked bundles viacatalog-products.metadata.bundleSlug→bundleRegistry.get(slug)(a new platform convention — documented in this changeset; will graduate to spec when a 2nd consumer adopts).Returns enriched card fields (
Bundle · N blocksbadge, block count metadata) when the bundle is registered; degrades gracefully with product-only fields otherwise.
1 fix (0.4.2)
- 0.4.2
*Re-publish: rewrite `workspace:` to actual semver in dependencies.**
Earlier 0.4.0/0.4.1 publishes used
npm publishdirectly, which doesn't rewriteworkspace:*deps. Verdaccio captured the literal"workspace:*"strings in the publishedpackage.jsondependenciesfields, so npm consumers (e.g. wabbit-site-core) failed to install withEUNSUPPORTEDPROTOCOL workspace:*.This changeset triggers a coordinated patch bump across the linked blocks-_ group (already 0.4.1 → 0.4.2) plus motion, lms-pack, catalog-pack, and the previously-untouched blocks-core. Re-publish flow uses
pnpm publishwhich rewritesworkspace:_to the actual version of the workspace dep at publish time.No source changes — purely a publish-pipeline correction.
0.3.xMinor
Sprint 3 blocks split — v0.2.0
Extracted the Tome blocks monolith (@wabbit/tome-blocks) into independently publishable bundle packages. Each bundle is independently installable, tree-shakeable, and testable in isolation.
New packages (all v0.2.0):
@wabbit/tome-blocks-core— registries, defineBlock/defineBundle, variants, thumbnails, admin components (BlockPicker, VariantPicker)@wabbit/tome-blocks-marketing-starter(free) — 8 marketing blocks: Hero, FeatureHero, CTA, LogoSlider, Pricing, Testimonial, FAQ, Banner@wabbit/tome-blocks-content-writer(free) — 10 editorial blocks: Blog, Archive, PostHero, RelatedPosts, EditorialOpener, EditorialBridge, EditorialSidenote, EditorialFigure, EditorialColophon, EditorialFootnotes@wabbit/tome-blocks-agency-essentials(starter) — 10 agency blocks: About, Contact/Form, Gallery, Media, SplitView, Stat, StatBar, TeamRoster, Timeline@wabbit/tome-blocks-editorial-pack(pro) — 8 editorial blocks: DataHero, Feature, InfoPanel, MessagePanel, MetricStrip, SplitPanel, StatusBoard, TextReveal@wabbit/tome-blocks-signal-theme(pro) — 33 Signal narrative blocks@wabbit/tome-blocks-lms-pack(pro) — scaffold for LMS blocks (Wave 3)@wabbit/tome-blocks-catalog-pack(pro) — scaffold for catalog/ecommerce blocks (Wave 3)@wabbit/tome-blocks-sc-pack(niche) — scaffold for Star Citizen blocks (Wave 3)@wabbit/tome-blocks-extras(pro) — 47 residual blocks (heroes, layout, content, marketing)
@wabbit/tome-blocks is now a meta-package that re-exports all bundle packages and provides
registerAll(blockRegistry, bundleRegistry)as a convenience function.Render colocation: All 113 render
.tsxcomponents migrated from the monolith into their owning bundle packages (./rendersubpath on each bundle).Breaking changes (internal):
@wabbit/tome-blocks/blocksand@wabbit/tome-blocks/bundlessubpaths removed (were Sprint 2 shims)packages/blocks/src/render/category index files removed (replaced by per-bundle./rendersubpaths)
Migration:
// Before (monolith singleton, all blocks loaded) import "@wabbit/tome-blocks"; // After (explicit registration, tree-shakeable) import { blockRegistry, bundleRegistry, } from "@wabbit/tome-blocks-core/registry"; import { register } from "@wabbit/tome-blocks-marketing-starter"; register(blockRegistry, bundleRegistry); // Or use the meta-package convenience function import { blockRegistry, bundleRegistry, registerAll, } from "@wabbit/tome-blocks"; registerAll(blockRegistry, bundleRegistry);