Documentation menu

Documentation / Blocks & themes / blocks-agency-essentials

Blocks Agency Essentials

Package
@wabbit/tome-blocks-agency-essentials
Version
v0.49.4
npm install @wabbit/tome-blocks-agency-essentials
First install from our registry?
  1. Get a registry token from your credentials page. You need a purchase that includes this package, or a Craft Library membership.

  2. Add the registry and your token to the .npmrc at the root of your project, with your token in place of YOUR_TOKEN:

    @wabbit:registry=https://npm.wabbit.com/
    //npm.wabbit.com/:_authToken=YOUR_TOKEN
Blocks & themesStable

Starter-tier agency blocks for business and portfolio sites — about, contact, team roster, gallery, timeline, stats, split-view, media, and a contact form.

Overview

Tier: Starter — core blocks for business and portfolio sites.

Agency-essential blocks for professional web presence.

Peer dependencies

If your site uses @wabbit/tome-blocks, you already have this bundle. No additional install needed. The meta-package's registerAll() call includes agency-essentials.

Stock Payload site — add to an existing blocks field

@wabbit/tome-blocks-core and @wabbit/tome-blocks-extras are required peers and install automatically (npm 7+/pnpm). Add the blocks you want alongside your existing ones and render them — see @wabbit/tome-blocks-core's "Add Tome blocks to an existing Payload project" for the full walkthrough:

// payload.config.ts
import { aboutBlock, contactBlock } from '@wabbit/tome-blocks-agency-essentials'
// blocks: [...existingBlocks, aboutBlock.block(), contactBlock.block()]
// blockComponents.ts
import { renderers as agencyRenderers } from '@wabbit/tome-blocks-agency-essentials/render/register'
import { adaptRenderersForPayload } from '@wabbit/tome-blocks-core/render'
// blockComponents: { ...adaptRenderersForPayload(agencyRenderers) }
@import '@wabbit/tome-blocks-core/styles.css';

Render through this package's RenderBlocks with the grid class — skip the grid and blocks render flush to the viewport edge with no error:

// RenderBlocks.tsx
import { blockComponents } from './blockComponents'
import { RenderBlocks as TomeRenderBlocks } from '@wabbit/tome-blocks-core/render'
import gridStyles from '@wabbit/tome-ui/grid'

export function RenderBlocks({ blocks }: { blocks: LayoutBlock[] }) {
  return <TomeRenderBlocks blocks={blocks} components={blockComponents} gridClassName={gridStyles.grid} />
}

Registering everything from scratch instead, wire the register() call into your Payload config:

// payload.config.ts
import { BlockRegistry, BundleRegistry } from '@wabbit/tome-blocks-core/registry'
import { register } from '@wabbit/tome-blocks-agency-essentials'

const blockRegistry = new BlockRegistry()
const bundleRegistry = new BundleRegistry()

register(blockRegistry, bundleRegistry)

export default buildConfig({
  collections: [
    {
      slug: 'pages',
      fields: [
        {
          name: 'layout',
          type: 'blocks',
          blocks: blockRegistry.resolveAll(),
        },
      ],
    },
  ],
})

Block List

SlugNameVariantsNotes
aboutAboutdefault, centered, dark, brutalistAbout section with headline, rich text, images, logos, counters
contactContactdefault, split, minimal, dark, estimate, reserveContact info with optional message form; phone and email values render as tel: / mailto: links. estimate is a two-column request band with a form card; reserve is a dark booking band that links out to a booking provider
team-rosterTeam Rosterdefault, horizontal, minimal, dark, peopleTeam member grid with photos and bios. people is a statement tile plus tall portrait cards with credentials and a personal fact
galleryGallerydefault, editorial, dark, minimal, offsetImage gallery with multiple layout options
timelineTimelinedefault, editorial, dark, brutalistChronological timeline with milestones
statStatdefault, dark, minimal, playful, sourcedKey statistics with counters. sourced is a ruled strip where each figure cites the record behind it
stat-barStat Bardefault, dark, minimal, playfulHorizontal stat strip
split-viewSplit Viewdefault, editorial, dark, minimalTwo-column split layout
mediaMediadefault, editorial, dark, minimalMedia embed with caption and controls
formForm Blockdefault, minimal, dark, playful, signup-bandOptional intro rich text plus a data-form-id slot for the selected Payload form — the renderer does not draw form fields itself; your site's form integration fills the slot

Contact `estimate`. Left: an optional eyebrow (a small meta-voice kicker above the heading, e.g. "Free written estimate"), the richText heading and intro, a promise (value as a big numeral, unit, text), an optional hours-aware promise (promiseOpen and promiseClosed, each { text }: with either set, the sentence is rendered three times, a neutral copy from promise.text and an open and a closed copy, and the copy that shows follows <html data-tome-open="true|false">, which chrome writes from the site's hours; with the attribute absent the neutral copy shows, so a site without hours never reads "closed"; a side left empty falls back to the neutral text; with neither set the promise renders exactly as before), a callAction (tel: link), hours[] (days, time) and areas[] (name, rendered as chips). Right: a form card, topped by an optional cardHeading and an optional formNote (a short meta line beside the heading, e.g. "Takes about a minute"), around the same data-form-id slot the form block renders, plus an optional responseLine. An optional aside (label, plus a body of a few short paragraphs in limited rich text: bold and italic only) closes the left column, for a note such as a confidentiality notice; it renders as an <aside> named by its label, and only when it has a label or a non-empty body. Phones stack heading, form, then call / hours / areas. Four optional fields turn the band into a closing section beside a face, all shown only for this variant and none with a default value: media (image, alt, label: a portrait; the alt text is required once an image is set, and the label, such as "Illustrative photo", renders only with the image), motif (image, alt: a small decorative image, alt required with the image), primaryAction (label, href, newTab: a link-out button such as "Book a first call", rendered through the TomeLink adapter, so a new-tab link gets data-new-tab and the hidden "(opens in a new tab)" hint; use callAction for a phone number) and signOff (line, name: a closing line and a name at the end of the note, above the form). The images relate to the mediaCollection option (default 'media'): contactBlock.block({ mediaCollection: 'media' }). The form area works with either form source: a form you host and pass as formSlot (a tome-forms form, say), or the cardForm relationship from the form builder. The form picker is opt-in, so the block still relates to no collection by default: register it with contactBlock.block({ formsCollection: 'forms' }) to add a cardForm relationship. Fill the slot by passing your site's form as the renderer's formSlot prop:

import { Contact } from '@wabbit/tome-blocks-agency-essentials/render'
// <Contact block={block} formSlot={block.cardForm ? <YourPayloadForm form={block.cardForm} /> : undefined} />

The slot always carries data-contact-form-slot, whatever fills it, and data-form-id when the id is known: from cardForm, or, for a form you host from elsewhere (a tome-forms form, say), from the formSlotId prop you pass with formSlot. The estimate band's block padding (--tome-section-py) sits on the section itself, so a theme that pads the band replaces it rather than adding to it.

Theme hooks: data-block-variant="estimate", data-contact-intro, data-contact-eyebrow, data-contact-promise, data-contact-promise-numeral, data-when-neutral / data-when-open / data-when-closed (the promise sentence's three copies), data-contact-aside, data-contact-form-card, data-contact-card-heading, data-contact-card-head (wraps heading and note, only when formNote is set), data-contact-form-note, data-contact-form-slot, data-form-id, data-contact-response-line, data-contact-side, data-contact-call, data-contact-hours, data-contact-areas, data-contact-chip; for the closing band data-contact-action (on the link), data-contact-media (the portrait figure, with its label as data-compliance-label), data-contact-motif, data-contact-signoff, data-contact-signoff-line, data-contact-signoff-name; detected values carry data-contact-link="phone|email".

Team Roster `people`. Its own fields, shown only for this variant (the other variants keep members[] with callsign, rank and wing): people[] (name required, role, credential for one line of admissions, licenses or certifications, fact for one personal line rendered in italic, photo as a tall 4:5 portrait that falls back to an initial tile, photoLabel for a disclosure on the portrait such as "Illustrative photo", rendered only with a photo, tag for a short tag beside the name such as "Ages 5–7" (data-person-tag, inside the name heading), and credentialTags[] (label) for chips such as "Background check" in a row of their own under the fact (data-person-credentials, each chip data-person-credential-tag)), credentialNote (a short note set after each person's chips, such as "Sample credentials", data-person-credential-note; people without chips get no row), statement (text, label, and an optional link with label and href that adds a button after the statement; it shows only with both, and is off by default) as the first tile, and emptyCells, the 1-based positions of deliberate empty cells counted across every cell of the grid, the statement included. Phones show two columns with the statement across both; from 640px three columns with the statement across two; from 1024px the statement takes one cell and the empty cells appear (they are hidden below 1024px). Empty cells are aria-hidden. A position more than one past the last cell is dropped.

Theme hooks: data-block-variant="people", data-roster-statement, data-person-statement-link (the statement's button, when set), data-roster-person, data-person-name, data-person-role, data-person-credential, data-person-fact, data-roster-empty, and data-compliance-label on the photo label.

Sourced stats (`stat` and `stat-bar`). Each stat row takes an optional source group (statSourceGroup()): serial (the record's number, printed with the prefix, "No. 2026-02"), label (what it is, "2025 annual report, p. 4") and href (where to read it, which the label links to). A stat with a source prints <p data-stat-source><span data-stat-source-serial>No. 2026-02</span> · <a>2025 annual report, p. 4</a></p>: under the description on a stat, under the row on a stat-bar. A stat without a source renders exactly as before. Two block-level fields: requireSource (off by default) drops any stat with neither a source label nor a link and hides the block when none is left, so a figure never shows without the record it cites (a record number alone does not count as a citation); sourcePrefix (default "No.") is the word before each number, and a number already written with it is not prefixed twice. The sourced variant of stat is the ledger for such figures: the headline (and, on a demo, sampleLabel) in a label cell, then each figure in a ruled cell led by its source, then the numeral and its caption. It prints a stat's suffix only when set, and the field defaults to "%": clear it. Hooks: data-block-variant="sourced", data-stat-label-cell, data-stat-sample with data-compliance-label (the sample label), data-stat-cells, data-stat-cell, data-stat-source, data-stat-source-serial, data-stat-value, data-stat-caption. Every stat root now carries data-block-variant beside data-variant.

No "Case Study" block. This pack has no block by that name.

Contact `reserve`. A dark booking band that hands the visitor to the booking provider a place already uses; Tome takes no bookings itself. It reuses eyebrow, richText (heading and intro), callAction (label, phone; prompt is estimate only) and contactBlocks, and adds four optional groups shown only for this variant, none with a default value:

  • reserve: provider (opentable, resy, tock or custom), urlTemplate (the provider link with {party}, {date} and {time} tokens, e.g. https://booking.example.com/venue?size={party}&date={date}&time={time}), partyLabel, dateLabel and timeLabel (the field labels, default "Party", "Date" and "Time"), partyMax (default 8, then one larger option labeled largePartyLabel, default "9+ (call us)" with a call action or "9+" without), times[] (value fills {time}, label shows; empty gives a time field), defaultTime, maxDaysAhead, submitLabel (default "Reserve") and altLabel (the lead-in before the call and alternative actions under the form, default "Or:"), and closedNote and noTimesNote (the wording of the opening-hours notes below; defaults "Closed that day. Choose another date." and "No sample times left. Choose another date."). Every label and note is localized; an empty one renders the English default, so existing bands are unchanged.
  • sampleSlots[] (time, label): example times shown as chips, always under sampleSlotsLabel (default "Sample times: check live availability when you book"). They never claim availability.
  • altActions[] (label, href, kind: link, call (the href is a phone number, rendered as tel:) or order, appearance: default (filled) or outline). An order action renders a <button data-order-open> for the ordering island, hidden until the island marks <html data-order-state>.

With a provider and a usable template (data-reserve-mode="form") the band renders the intro, a GET form with party, date and time fields for the tokens the template uses, the chips (each a link to the provider with its time and the default party filled in), then an "Or:" row with the call action and the alternative actions. With no provider, or no usable template (data-reserve-mode="actions"), the form hides: the actions lead (the call action filled), then the chips as plain text. Contact rows close the band as a facts row in both modes.

The form works without JavaScript when each token is a whole query value, as above: the tokens become field names and the template's other params ride as hidden inputs. A token anywhere else (in the path, or sharing a value, like OpenTable's datetime={date}T{time}) cannot be a form field, so without JavaScript that part is dropped and the visitor picks on the provider's page; the form is marked data-reserve-complete="false". The block stays a server component. For the full behavior, call the framework-free enhancement from your own client code: it fills today's date, sets the date's min and max from maxDaysAhead, keeps each chip's link in step with the chosen party and date, and fills and opens an incomplete template on submit.

'use client'
import { useEffect } from 'react'
import { enhanceReserveBands } from '@wabbit/tome-blocks-agency-essentials/reserve'

export function ReserveEnhancer() {
  useEffect(() => enhanceReserveBands(), [])
  return null
}

Opening hours. The second argument of enhanceReserveBands is either a Date (the clock, as before: enhanceReserveBands(root, now) is unchanged) or an options object. Give it the venue's hours and the band's sample times follow them:

'use client'
import { useEffect } from 'react'
import { listWindows } from '@wabbit/tome-local/hours'
import { enhanceReserveBands } from '@wabbit/tome-blocks-agency-essentials/reserve'
import { venueHours } from './venueHours' // a tome-local OpenStateSource

export function ReserveEnhancer() {
  useEffect(() => enhanceReserveBands(document, { hours: venueHours, listWindows, schedule: 'kitchen', lastSeatingMinutes: 30, lastPickupMinutes: 15, maxSlots: 3 }), [])
  return null
}
  • hours: a @wabbit/tome-local OpenStateSource (timezone, weekly rows with split shifts, dated overrides, named schedules). listWindows: that package's function, passed in so this pack needs no @wabbit/tome-local dependency; sites that do not use hours never load it. Both are typed structurally (ReserveHoursSource, ReserveListWindows), so the real ones are assignable as they are.
  • schedule: read this named schedule (for example a kitchen) when the source has it, else the primary hours. lastSeatingMinutes (default 0): the last time offered is the window's close minus this. lastPickupMinutes (default 0): the same for a band without a form. maxSlots (default: all that fit): the most chips shown in a band with a date field. notes: { closed?, noTimes? }, site-wide wording used when the block sets none. now: replaces the clock (tests).
  • Chips outside the chosen date's hours, or already past today, hide. The time select disables and hides the same times and moves its value to one that fits (keeping the default time when it does). A time list is the only thing filtered: a free time field is left alone.
  • When the chosen date is closed, or nothing is left, a note appears: <p data-reserve-note data-reserve-note-kind="closed|no-times" aria-live="polite">, under the chips (or right after the form when there are none). Its wording is the block's closedNote / noTimesNote (carried on the band as data-reserve-closed-note / data-reserve-no-times-note), else notes, else the neutral default. A band without a date field (pickup) says "Closed today." and "No sample times left today.".
  • On load only, a date field still on today with no time left moves to the next date that has one, within the field's max.
  • "Today" is the venue's date in the venue's timezone, so the date field's default, min and max follow it rather than the visitor's clock. A timezone the browser does not know turns the hours option off.
  • The note is made in the browser, so a band rendered without the option is byte-for-byte what it was. It re-runs when the form changes; a band with no form (pickup) is fitted when the enhancement runs.

@wabbit/tome-blocks-agency-essentials/reserve also exports the pure helpers the band and the enhancement share: fillReserveUrl(template, values), resolveReserveForm(template), resolvePartyOptions, resolveSampleSlots, resolveAltActions and the constants.

Theme hooks: data-block-variant="reserve", data-reserve-mode="form|actions", data-contact-intro, data-contact-eyebrow, data-reserve-form (with data-reserve-template, data-reserve-complete, data-reserve-max-days), data-reserve-field="party|date|time", data-reserve-submit, data-reserve-slots, data-reserve-slot (its time), data-compliance-label (the sample label), data-reserve-alt, data-reserve-note (made by the hours option, with data-reserve-note-kind), data-contact-call, data-order-open, data-contact-facts, data-contact-fact; detected fact values carry data-contact-link="phone|email".

Peer dependencies

Generated from package.json#peerDependencies (the README gate fails if this table and the manifest disagree).

PeerRangeRequired
@payloadcms/richtext-lexical>=3.67.0yes
payload>=3.67.0yes
react>=19.0.0yes
react-dom>=19.0.0yes
@wabbit/tome-blocks-core>=0.22.0 <1.0.0yes
@wabbit/tome-blocks-extras>=0.16.3 <1.0.0yes
@wabbit/tome-core>=1.22.0 <2.0.0yes

Public API

ExportSubpathDescription
Block descriptors (aboutBlock, contactBlock, teamRosterBlock, galleryBlock, timelineBlock, statBlock, statBarBlock, splitViewBlock, mediaBlock, formBlock) + register(blockRegistry, bundleRegistry).Payload block config descriptors and bundle registration
About, Contact, TeamRoster, Gallery, Timeline, Stat, StatBar, SplitView, MediaBlock, FormBlock@wabbit/tome-blocks-agency-essentials/renderLegacy self-registering render barrel
renderers map + registerRenderers()@wabbit/tome-blocks-agency-essentials/render/registerExplicit registration (server-safe adapter contract) — no side effects on import
getDemoProps(blockSlug, variant, ctx?) (plus tree-shakeable per-block demo functions)@wabbit/tome-blocks-agency-essentials/demoPack-level demo-props dispatcher feeding the auto-gallery route; returns null for unknown slugs
Bundle/block metadata (slugs + variants)@wabbit/tome-blocks-agency-essentials/metaPayload-free surface for the gallery storefront
`enhanceReserveBands(root?, now?options?), RESERVE_NOTE_DEFAULTS, fillReserveUrl, resolveReserveForm and the other reserve` helpers@wabbit/tome-blocks-agency-essentials/reserveThe contact reserve band's pure helpers and its optional, framework-free client enhancement (no React, no Payload)

Server / client posture

All 10 renderers under src/render/ are plain static components — zero carry a 'use client' directive (verified 2026-07-12; scripts/assert-rsc-boundaries.mjs MANIFEST: blocks-agency-essentials: 0). They render safely from either a server or client component graph. Both render subpaths (@wabbit/tome-blocks-agency-essentials/render/register and the legacy @wabbit/tome-blocks-agency-essentials/render barrel) load under plain Node — each renderer's per-block stylesheet ships precompiled as a JS module — so seed scripts and tests can import them. Keep payload.config.ts on the root entry point (block descriptors + register(), no React): that keeps React out of your config. Between the two render subpaths, prefer @wabbit/tome-blocks-agency-essentials/render/register: it has no import-time side effects.

Compatibility

Required peers:

payloadreactreact-dom@payloadcms/richtext-lexical
>=3.67.0>=19.0.0>=19.0.0>=3.67.0

Block reference

about

The primary "who we are" section — a headline, up to three stacked rich-text columns (text1/text2/text3), an optional image upload array, optional logos strip, optional counter array, and a CTA link. Four variants (default, centered, dark, brutalist) cover the tonal range from editorial-warm to high-contrast brand statement. The `designVersion` field is a deprecated shim; always set `_variant` directly.

When to use
  • An about-us or company-story section that needs rich prose alongside images
  • A brand-values or mission statement block with supporting counters
  • A logos/social-proof strip directly beneath a narrative paragraph
  • Any page section that needs a CTA link anchored to a narrative introduction
Page types
  • about
  • landing
  • marketing
  • dossier
How to use

Author headline as richText (h1/h2/h3 allowed). Use text1 for the primary column, text2 and text3 for secondary columns when you want a split-body layout. Add images (multiple allowed) for visual support. Add counters (value + title + description) when social proof numbers are part of the story. The link group renders a CTA; set type to "custom" and provide url + label. Choose `_variant`: default (editorial warm), centered (symmetrical brand statement), dark (high-contrast), brutalist (typographic punch). For a leaner named-contact card, use `team-roster` instead.

Pairs with
  • stat
  • team-roster
  • gallery
  • timeline
  • media
Precedes
  • stat
  • team-roster
  • timeline
Avoid when
  • You only need a raw text column without images or counters — use the platform `content` block
  • The page goal is a staff directory — use `team-roster`
  • You need a counter-focused layout where numbers dominate — use `stat` or `stat-bar`
Register in

marketing-landing

contact

A contact section combining a richText intro (h1/h2/h3), a repeating contactBlocks array (label + value + optional iconName for email, phone, address rows), and an optional inline form (toggled by showForm + formIntro). Designed as a standalone contact section, not a thin form embed — it owns the surrounding context.

When to use
  • A dedicated contact page or contact section with multiple info rows and an optional message form
  • A footer-adjacent contact info panel with phone, email, and office address
  • A landing page CTA section where a brief pitch precedes a contact form
Page types
  • landing
  • about
  • marketing
How to use

Author richText as the section heading and intro copy. Add contactBlocks rows: each row takes a label (e.g., "Email"), a value (e.g., "hello@example.com"), and an optional iconName string your renderer maps to an icon component. Toggle showForm to reveal the inline form; add formIntro for a short sentence above the form fields. A value that is exactly one phone number or email address renders as a tel: / mailto: link. For a request band (quote, booking, enquiry), pick the `estimate` variant: richText carries the heading and intro, then fill promise (value + unit + text), callAction, hours and areas, and optionally an aside (label + a few short bold/italic paragraphs) for a note such as a confidentiality notice, shown last in the intro column. For a closing section beside a face, also set media (a portrait with alt text and an optional label), motif (a small image with alt text), primaryAction (a link-out button: label, href, newTab) and signOff (a closing line and a name, at the end of the note above the form); each renders only when set. Register the block with `contactBlock.block({ formsCollection: "forms" })` to let editors pick the card form (`cardForm`); the site form integration fills the data-form-id slot in the card. For a booking band that hands off to a reservation provider, pick `reserve`: set reserve.provider and reserve.urlTemplate (with {party}, {date} and {time} tokens), the times, and optionally sampleSlots (shown under sampleSlotsLabel, never as live availability), callAction and altActions; leave the provider empty for an actions-only band (e.g. pickup), where contactBlocks render as its facts row. For a standalone form without surrounding contact info, use `form` instead. Phone and email values render as tel: / mailto: links. The estimate variant's form card hosts your form through the same data-form-id slot as the form block (pass it as formSlot). The reserve variant is a no-JavaScript GET form that links out to the booking provider, with labeled sample-time chips and alternative actions; with no provider it leads with its actions. Every variant sets data-variant and data-block-variant on the root.

Pairs with
  • form
  • about
  • media
Follows
  • about
Avoid when
  • You only need an embedded Payload form with no surrounding contact info — use `form`
  • The section is purely a newsletter sign-up — reach for a form with a focused intro
Register in

marketing-landing

team-roster

A named-member roster — an array of members each with callsign (required), rank, wing, dateJoined, dateOfPassing, portrait photo (falls back to a decorative initial), a note, and an accentColor. Supports a section-level sectionLabel, headline, subtext, and epigraph (quote displayed between the header and the member grid). Optional showFrame wraps the block in a labeled card with a dark header band. Four member variants: default (centered formal portraits), horizontal (card-per-member horizontal layout), minimal, dark. A fifth, `people`, has its own fields instead of members[]: people[] (name required, role, credential, fact, photo, photoLabel), a statement tile (text + label, and an optional link button) shown first, and emptyCells (1-based cell positions, statement included, for deliberate empty cells). The `designVersion` field is a deprecated shim; set `_variant` directly.

When to use
  • An about page team section listing staff with portraits and roles
  • A memorial or tribute roster with dateOfPassing entries and an epigraph
  • A dossier-style "meet the team" section on a landing or agency page
  • A nested sub-section within a split-view that introduces a team or unit
  • A professional team (partners, practitioners, advisors) shown as tall portraits with credentials and one personal fact (the `people` variant)
Page types
  • about
  • dossier
  • landing
  • marketing
How to use

Add members (minRows: 1); callsign is required. Fill rank and wing for role/department labeling. Upload photo (falls back to an initial avatar). Set accentColor per member for visual differentiation (primary/success/info/warning). Add an epigraph for a dedication quote displayed before the grid. Use showFrame + frameLabel when this block is one of several named sections on the page and needs a labeled container. Choose `_variant`: default for portrait-grid layouts, horizontal for card-per-row, minimal for text-only, dark for high-contrast, people for a professional team. For `people`, fill people[] instead of members, write one statement sentence for the first tile (add a link label and URL only when a button should follow it), set photoLabel when a portrait needs a disclosure (e.g. a stock stand-in), and add emptyCells only to even out the rows of a three-column grid. For a narrative "about us" section with images and counters rather than a named list, use `about`.

Pairs with
  • about
  • stat
  • gallery
  • timeline
Follows
  • about
Avoid when
  • You need a narrative brand or company story rather than a named member list — use `about`
  • The members have product-specific roles (course instructors) — consider the LMS pack blocks
Register in

dossier

gallery

A multi-image gallery with five layout variants (overlay, carousel, card, 4-column grid, blur-vignette), per-item richText captions and per-item links, a gallery-level richText heading/tagline, an optional gallery link, and editorial controls (editorialMode, captionPlacement, breakoutMode, aspectRatioLock). Each element carries its own image, imageHeight, icon, richText, and link.

When to use
  • A portfolio or work-samples section with multiple images that need individual captions or links
  • A project-showcase or case-study image strip on a marketing or landing page
  • An editorial article that needs a multi-image breakout (full-bleed or wide) mid-body
  • A client-logos or partners grid when logos need hover states or individual links
Page types
  • portfolio
  • landing
  • marketing
  • about
  • editorial-article
How to use

Add elements (minRows: 1); each needs at minimum a required image upload. Set imageHeight per item (12rem–32rem) for vertical rhythm control. Set the top-level variant: "grid" (default 4-col) for uniform layouts, "carousel" for sequential browsing, "overlay" for darkened caption-on-hover, "card" for framed tiles, "blur-vignette" for atmospheric moody treatment. Use breakoutMode ("wide", "full-bleed") when the gallery needs to escape the body column. Turn on editorialMode and set captionPlacement to "marginalia" when sitting inside an editorial 14-track grid. Use aspectRatioLock to enforce uniform crops across items.

Pairs with
  • media
  • about
  • timeline
  • stat
Follows
  • about
  • timeline
Avoid when
  • You only need a single image or video — use `media`
  • The images are purely decorative and need no captions or links — use `media` in full-bleed mode
Register in

marketing-landing

timeline

A chronological event list — a required heading followed by a sections array, each with a date, required tagline, optional icon name, optional image upload, and optional richText body (h2/h3/h4, lists). Renders as a vertical timeline spine with dated nodes. No variant axis beyond backgroundColor; content depth is controlled per section.

When to use
  • A company or project history section with dated milestones
  • A roadmap or phased-plan overview with sequential events
  • A case-study "how we got here" narrative on an about or portfolio page
  • An editorial deep-dive section showing chronological development of a topic
Page types
  • about
  • dossier
  • editorial-article
  • portfolio
  • marketing
How to use

Author heading (required). Add sections (minRows: 1): each section needs a tagline (the milestone label, required) and optionally a date, icon name, image upload, and richText body for supporting detail. The timeline renders nodes in the order sections are authored — reorder via drag in the admin. Set backgroundColor for tonal context. Use icon names your renderer maps to an icon component for visual node markers. For a grid of named members with dates, use `team-roster` instead; for a sequential stat argument, use `stat`.

Pairs with
  • about
  • stat
  • media
  • gallery
Follows
  • about
Avoid when
  • The entries are not ordered events — use `stat` for metric highlights or `about` for narrative prose
  • You need a named-person list with portraits — use `team-roster`
Register in

dossier

stat

A grid of counter statistics — each stat has a counter string (e.g., "99%", "$1M"), a title label, an optional richText description, and a suffix. Supports an optional richText headline above the grid, a colorScheme (blue/green/purple/orange/red) applied to all counters, optional CTA links below the grid, and a backgroundColor for the section.

When to use
  • A social-proof numbers section on a landing or about page (e.g., "50+ clients, $2M revenue")
  • A figures ledger where every number cites the record behind it (the `sourced` variant, with a source on each stat)
  • A metrics highlight mid-page to punctuate a narrative with evidence
  • A compact trust strip with 3–5 key performance numbers
Page types
  • landing
  • marketing
  • about
How to use

Add stats (minRows: 1, maxRows: 10). Each stat requires counter (the displayed value string — include symbol, e.g. "50+") and title. Set suffix per item for units that follow the counter value. Add an optional richText headline for a section label above the grid. Set colorScheme to match brand tone. Add links to provide CTAs beneath the stats. Give a stat a `source` (record number, label, link) to print the record it cites; turn on `requireSource` to hide any stat that cites nothing. The `sourced` variant is the ruled ledger for such figures: clear the suffix of each stat (it defaults to "%"), and set `sampleLabel` only on a demo. For animated percentage bars instead of raw counter values, use `stat-bar`. For counters embedded inside a longer about narrative, the `about` block has its own counter array.

Pairs with
  • about
  • stat-bar
  • timeline
  • gallery
Follows
  • about
Avoid when
  • The values are percentages that benefit from visual bar animation — use `stat-bar`
  • Counters are part of a larger about-us narrative with images — use the counter array on `about`
Register in

marketing-landing

stat-bar

An animated percentage bar chart — each stat is a label + numeric value (0–100) + suffix rendered as a horizontal progress bar. Supports optional section title and description, a colorScheme (blue/green/purple/orange/red/custom), and animation timing controls (delay and duration in ms). All bars share one color scheme; use colorScheme "custom" + a hex code for brand-specific fills.

When to use
  • Displaying skill proficiency, capability ratings, or portfolio mix as visual percentage bars
  • A service breakdown showing relative share (e.g., "Strategy 70%, Design 50%, Dev 80%")
  • A metrics section where the bar visualization adds meaning beyond a raw number
Page types
  • about
  • landing
  • marketing
  • portfolio
How to use

Add stats (minRows: 1, maxRows: 10). Each stat requires label and a numeric value 0–100 (the bar fill amount); suffix defaults to "%" but can be overridden (e.g., "pts", "ms"). Give a bar a `source` (record number, label, link) to print the record it cites under the row, and turn on `requireSource` to hide any bar that cites nothing. Set animationDelay (default 1000ms) to control when bars begin animating on scroll-enter; set animationDuration (default 800ms) for bar fill speed. For counters without bar visualization (raw numbers like "$1M" or "50+ clients"), use `stat` instead.

Pairs with
  • stat
  • about
  • timeline
Follows
  • about
Avoid when
  • The values are not percentages or are not comparative — use `stat` for raw counter strings
  • You need more than one color per bar — the block applies one scheme to all bars; consider splitting into multiple blocks by section
Register in

marketing-landing

split-view

media

A single image or video embed (upload or YouTube/Vimeo URL) with aspect ratio control, layout width (small/inline/narrow/normal/fullWidth), optional caption with placement (below/marginalia/hidden), breakout mode (default/wide/full-bleed/spread), optional duotone tint, and art-directed mobile crop for spread mode. The canonical single-asset pacing block in editorial contexts.

When to use
  • A hero or section-break image within an article or landing page body
  • A YouTube or Vimeo video embed with a custom thumbnail fallback
  • A full-bleed photographic pacing intervention between two editorial sections (max 1 per 800 words)
  • A narrow or inline figure with a marginalia caption in an editorial 14-track layout
Page types
  • editorial-article
  • landing
  • about
  • marketing
  • blog
  • portfolio
How to use

Set mediaType to "upload" and select the media relation, or set to "video" and enter a YouTube/Vimeo URL (add videoThumbnail for a custom preview). Set layout for width within the body column. Use breakoutMode "wide" for margin-to-margin or "full-bleed" for a full-start/full-end span; reserve "spread" for paired art-directed crops (add artDirectionMobile). Toggle showCaption and set captionPlacement to "marginalia" when the editorial grid has sidebar columns available. Use duotoneEnabled + duotoneIntensity (0.1–0.6) for accent-tinted grayscale treatment. For multiple images side by side, use `gallery`.

Pairs with
  • gallery
  • about
  • timeline
  • stat
Follows
  • about
Avoid when
  • You need more than one image displayed together — use `gallery`
  • The asset is decorative background (no caption, no user focus) — consider inline CSS or a section background instead
Register in

editorial

form

A thin Payload form embed — a required relationship to a form document (from the `forms` collection) plus an optional richText intro block (h1–h4, fixed + inline toolbar) shown above the form when enableIntro is checked. The block renders the form as-configured in Payload; all field structure, validation, and submission logic live in the linked form document.

When to use
  • Embedding any Payload-managed form on a page (newsletter, inquiry, booking, survey)
  • A focused conversion block where the form is the entire content, not part of a larger contact section
  • Reusing the same form document across multiple pages without duplicating fields
Page types
  • landing
  • marketing
  • about
  • faq
How to use

Select the form relationship (required). Toggle enableIntro and author introContent only when you need a headline or 1-2 sentences above the form; otherwise leave it off to let the form lead. The form slug is determined by the linked document — changing field structure means editing the form document, not this block. For a contact section that also displays phone/email rows, use `contact` instead; it has an integrated showForm toggle that embeds the same concept with surrounding context. For a closing conversion band, pick the `signup-band` variant: set eyebrow, heading, body, the form relationship, a promise line and an optional secondary link; it renders as a dark band, with a single-field form laid out inline (field and button on one row). The form itself comes from your site: pass it as formSlot, or let your form integration fill the data-form-slot / data-form-id slot (the Payload form relationship).

Pairs with
  • contact
  • about
  • media
Follows
  • about
  • media
Avoid when
  • You need contact info rows (email, phone, address) alongside the form — use `contact`
  • The page needs a full "contact us" narrative section — use `contact` with showForm enabled
Register in

marketing-landing

Exports

Import path
@wabbit/tome-blocks-agency-essentials
@wabbit/tome-blocks-agency-essentials/render
@wabbit/tome-blocks-agency-essentials/render/register
@wabbit/tome-blocks-agency-essentials/demo
@wabbit/tome-blocks-agency-essentials/meta
@wabbit/tome-blocks-agency-essentials/reserve

Changelog

0.49.x

1 fix (0.49.4)
  • 0.49.4

    Admin labels, field descriptions, demo content and messages now use American spellings.

0.47.xMinor

  • contact can print a short caption under its link-out button.

    The estimate variant gains an optional primaryAction.caption ("Opens our scheduler in a new tab"), rendered as data-contact-action-caption in the actions row, and only when the button itself renders.

    Unset or blank, the markup is identical to before; the existing contact golden and a new blank-equals-unset test hold that. Schema: one new optional localized text column on the block (primaryAction.caption), so SQL sites need a migration; Mongo sites need none.

0.46.xMinor

  • The gallery block gains an offset layout: one large lead image beside smaller ones.

    gallery gains an opt-in offset variant: one large lead image beside small ones with bold-lead captions (hooks data-gallery-offset, data-gallery-lead, data-gallery-small, data-gallery-caption). The layout-only admin fields hide for it.

    Every other variant renders exactly as before.

  • Fixes the gallery image height default, which was not one of its own options.

    Fix: the gallery element's imageHeight select defaulted to 21rem, which was not one of its options (12rem to 32rem in 2rem steps), so any gallery element saved without an explicit height failed Payload validation ("21rem is not a valid enum value for path imageHeight"). Present since the pack's extraction.

    21rem is now an option between 20rem and 22rem; the default stays 21rem, which is also the renderer's own fallback height (--_item-height: 21rem), so stored blocks and new ones render exactly as before. A new schema test checks that every select and radio default in the pack is one of its options.

    Consumers: regenerate Payload types (the imageHeight union gains '21rem'); with a SQL adapter the enum gains one value.

0.43.xMinor

  • stat and stat-bar can now cite the record behind each figure, and stat gains a sourced ledger variant.

    Each stat row takes an optional source group (statSourceGroup(): serial, label, href). A stat with a source prints p[data-stat-source] with span[data-stat-source-serial] ("No. 2026-02"), a middle dot and the label as a link: under the description on stat, under the row on stat-bar.

    Two block-level fields on both blocks: requireSource (off by default) drops any stat with no source label or link and hides the block when none is left, so a figure never shows without the record it cites; sourcePrefix (default "No.") is the word before each number. A stat without a source renders as before.

    The new sourced variant of stat is a ruled strip for such figures: a label cell (data-stat-label-cell) holding the headline and, on a demo, sampleLabel as span[data-stat-sample][data-compliance-label], then data-stat-cells of data-stat-cell, each led by its source line, then p[data-stat-value] and p[data-stat-caption]. It prints a stat's suffix only when set; the field still defaults to "%", so clear it.

    sampleLabel shows in the admin for sourced only.

    Additive change to existing markup: every stat root now carries data-block-variant beside data-variant (the public variant hook), in all variants. The block's stored-render goldens were cut from the pre-change renderer and differ only by that attribute.

    stat-bar has no change to its markup. The block catalog lists the new variant.

1 fix (0.43.2)
  • 0.43.2
    Gallery and preview demo props no longer invent site paths.

    A demo link is now the caller's placeholderHref or #, so rendered demos stop emitting real <a href>s to pages that exist on no site. 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.x

2 fixes (0.42.1)
  • 0.42.1

    Fixes four single-byte text-encoding errors in the package's source and README, so admin field descriptions show "Ages 5–7" and "We’ll reply by 10am tomorrow" instead of replacement characters.

  • 0.42.1
    team-roster's people variant gains two opt-in layout options: photoLabelPlacement: 'below' and statement.position: 'last'.

    photoLabelPlacement: 'below' renders each photo label (still data-compliance-label) as the sibling directly after the portrait instead of inside it, for themes that keep type off photographs. statement.position: 'last' moves the statement tile after the people in the DOM, so the tab order matches a design that shows it last; empty-cell positions count the grid in that order.

    Both default to the current behavior and leave the rendered markup byte-identical when unset.

0.41.xMinor

  • team-roster people can put a button after the statement tile with the new statement.link.

    statement.link (label, href) shows only with both halves and is off by default, so stored documents without it render byte-identical. The button carries data-person-statement-link.

    contact estimate gains signOff.order: 'name-first' (default line-first): the name, then the line, with data-contact-signoff-order="name-first" on the sign-off. Unset or line-first renders byte-identical.

0.39.xMinor

  • Two opt-in additions for a business that shows its coaches and answers by the hour.

    team-roster people gains people[].tag (a short tag beside the name, data-person-tag), people[].credentialTags[] (chips in a row under the fact, data-person-credentials and data-person-credential-tag) and a block-level credentialNote (set after each person's chips, data-person-credential-note). All three are optional, default-free and shown only for the people variant; empty values render nothing.

    contact estimate gains promiseOpen and promiseClosed, each a { text } group. With either set, the promise sentence renders as three copies marked data-when-neutral (from promise.text), data-when-open and data-when-closed, and the block's stylesheet chooses by <html data-tome-open="true|false">, the attribute chrome writes from the site's hours.

    With the attribute absent the neutral copy shows, so a site without hours never reads "closed". A side left empty falls back to the neutral text.

    Stored documents that set none of the new fields render byte-identical markup (pinned by golden tests).

    Purely additive: no peer is added or tightened, no field has a default, and nothing changes for a site that sets none of them.

0.38.xMinor

  • The form block gains a signup-band variant, and split-view gains part hooks.

    signup-band is a dark closing band: eyebrow, heading (an h2), body, the form slot, a promise line and a secondaryLink (label and link). The new fields show only for this variant, carry no default, and every part renders only when set.

    The root carries data-block-variant="signup-band" and data-band-tone="solid-dark"; the parts carry data-band-meta, data-band-title, data-band-body, data-band-form (on the form slot, which keeps data-form-slot and data-form-id), data-band-promise and data-band-link. A form with one field lays its field and button on one row.

    Existing variants render exactly as before.

    split-view marks a text column's content [data-sv-text] and a media column's figure [data-sv-media], next to the existing [data-sv-ratio], so a theme can address the two parts of a pair.

    split-view gains structure hooks and a 7-5 ratio: [data-sv-inner] on the columns' grid, [data-sv-col="media|text"] on each media or text column, and [data-sv-media-side="start|end"] on the root when the block has a media column (start when the media column comes first). The asymmetryRatio select gains 7-5 and its mirror 5-7, with the matching 7fr 5fr / 5fr 7fr layout.

    The signup-band variant marks its inner wrapper [data-band-inner] and the column holding the form slot and the promise [data-band-action]. Stored renders are unchanged apart from the new attributes.

0.37.xMinor

  • The contact estimate variant can close a page beside a face: a portrait, a motif, a link-out button and a sign-off.

    Four optional fields, shown only for estimate and without defaults, so existing documents render the same markup as before:

    • media (image, alt, label): a portrait beside the note. Alt text is required once an image is set; the label renders only with the image.
    • motif (image, alt): a small decorative image, alt required with the image.
    • primaryAction (label, href, newTab): a link-out button rendered through TomeLink, so a new-tab link gets data-new-tab and the hidden "(opens in a new tab)" hint. It is separate from the tel-only callAction.
    • signOff (line, name): a closing line and a name at the end of the note, above the form.

    The images relate to a new mediaCollection option (default 'media'). New theme hooks: data-contact-action, data-contact-media with data-compliance-label for its label, data-contact-motif, data-contact-signoff, data-contact-signoff-line, data-contact-signoff-name.

    The form area is unchanged: it takes a form you host through formSlot or the form builder's cardForm.

1 fix (0.37.1)
  • 0.37.1
    Block descriptions read as plain descriptions of what each block shows, not developer notes.

    53 block and variant descriptions carried package names, "Fully static — no peer required", data-variant, slots and hydration details. These are what a visitor reads in a block catalog, so each now says in plain words what the block shows and when to use it.

    The developer facts aren't lost: each one moves into the block's usage.howToUse (or, where a block has none, a code comment above its description), and the 20 that howToUse already covered are simply dropped from the description. CTA's with-form variant no longer claims to render lead-capture fields: it says it leaves room for a form the site supplies, which is what it does.

0.36.xMinor

  • enhanceReserveBands now accepts an opening-hours option, so a reservation band's sample time chips and time select respect the venue's hours.

    Call enhanceReserveBands(document, { hours, listWindows, schedule?, lastSeatingMinutes?, lastPickupMinutes?, maxSlots?, notes? }) with a @wabbit/tome-local OpenStateSource and its listWindows (passed in, so this package takes no new dependency). Chips and select options outside the chosen date's hours, or already past today, hide; a note says when the date is closed or no sample time is left; a date still on today with no time left moves to the next open date.

    The existing enhanceReserveBands(root, now) call is unchanged. The contact reserve group gains two optional localized fields, closedNote and noTimesNote, for the notes' wording; unset, a neutral default shows.

  • Phone links in the contact, high-impact-hero and local-logistics code now use the shared core tel: helper, so a site can qualify national numbers to E.164.

    The exported toTelHref helpers (and resolveContactValueLink, resolveInlineCall) accept an optional { defaultCallingCode }. The high-impact-hero reads defaultCallingCode from the render context passed to RenderBlocks({ context }); without it, hrefs stay digits-only as before.

    Requires @wabbit/tome-core 1.22.0 or later as a peer.

  • The contact renderer reads defaultCallingCode from the host's render context (RenderBlocks({ context })) for its contact rows, the estimate and reserve call buttons, and reserve call actions, so tel: links are E.164 when the site sets a default.

    Unset, links are unchanged.

2 fixes (0.36.3)
  • 0.36.3
    Eleven blocks now keep their text and boxes on the page's content lines instead of running to the window edge.

    Contact (default, split, minimal, dark), Stat Bar, Timeline, Category Strip and Product Grid place their content on content-start / content-end; their full-width rows and backgrounds stay full width. Split View wraps its columns in a new .inner element on the content lines, its section becomes a subgrid band (backgrounds still paint edge to edge), and an asymmetric ratio now stacks on phones as the single-column rule always intended.

    Team Roster's section subgrids the page grid and puts its header, epigraph and roster on the content lane, and the optional frame becomes a bordered panel on the content lines. Related Posts drops its inline padding and puts its header and cards on the content lines; its top rule still runs edge to edge.

    Editorial Footnotes runs from content-start across the same nine tracks as Editorial Figure (the whole content lane below 1024px). Editorial Figure's full-bleed variant keeps the image edge to edge and puts the caption on the content lines.

    Scrub Story's reduced-motion fallback and its CTA are inset by the page gutter (--tome-grid-padding); set --tome-cinema-scrub-inset to override it (for example 0px when your site already places the block on its content column). If your site added its own placement or padding to work around these blocks, remove it, or the content will be inset twice.

  • 0.36.3
    The form block takes your site's form as formSlot, the same contract as the contact block, so it no longer has to be filled from outside.

    The form block rendered only an empty slot for a site integration to fill, with no prop to hand it a form; every preview of it was an empty box. It now takes formSlot (rendered inside the slot) and formSlotId (written to data-form-id), and the slot always carries data-form-slot.

    An unpopulated form relationship (a bare id) now marks the slot as well. FormBlockProps is exported from ./render.

0.35.x

1 fix (0.35.0)
  • 0.35.0
    SplitView and LayoutGrid style themselves from data attributes instead of matching their own inline style text.
    • @wabbit/tome-blocks-agency-essentials: SplitView's asymmetric ratios (8-4, 4-8, 9-3, 3-9) now apply on server-rendered pages. The rules matched --sv-ratio: 8-4 in the style attribute, but server markup writes --sv-ratio:8-4 with no space, so those pages showed two equal columns until a client-side render. The section carries data-sv-ratio and the rules match that.
    • @wabbit/tome-blocks-extras: LayoutGrid cells carry data-cell-col-span and, when they span more than one row, data-cell-row-span. The tall-cell stretch and the phone reflow (tall and two-column cells first) read those attributes, so they no longer depend on how the browser serialises the inline style.

0.34.xMinor

  • contact estimate: the form slot is marked whatever fills it, and the band's padding no longer doubles.
    • The slot now always carries data-contact-form-slot. Its data-form-id came from cardForm only, so a form passed as formSlot (a tome-forms form, for instance) left it unmarked and theme rules keyed on [data-form-id] never reached it. New optional prop formSlotId: the hosted form's id, written to data-form-id (it wins over cardForm when a formSlot is passed). A site that wrapped its hosted form in its own data-form-id element can drop the wrapper.
    • The block padding (--tome-section-py) moves from .estInner to the section, the element that is the band. On the inner element it stacked with any padding a theme gave the band, so the Counsel intake ran double space above and below. Without a theme band rule the spacing is unchanged.
  • team-roster people sits on the page grid, and its columns are a token.
    • The section kept its children on 1 / -1, so the header and the portrait grid ran edge to edge with no page gutter. The section still paints full-bleed, but it now subgrids the page grid and places the header and grid on the content lane (content-start / content-end), like the pack's other blocks.
    • New token --tome-roster-people-cols: the grid's column count. Defaults are 2 on phones, 3 from 640px and 4 from 1024px (it was 3). The statement tile and the empty-cell rules are unchanged: across the row on phones, across two cells from 640px, one cell from 1024px, where the empty cells appear. The demo's emptyCells [5, 8] now open the start and end of row two.
    • The header group carries hooks: data-block-header on the group, data-block-eyebrow (section label), data-block-heading (h2) and data-block-subtext.

    The member variants are unchanged.

  • Narrow-screen rules that never fired now fire.

    These blocks wrote their phone layout as an unnamed @container query, but nothing around them declares a size container (not tome-ui, the blocks core or the starter), so the rules never matched: the high-impact-hero split and default layouts never stacked on phones. Declaring the container on the block root would not work either, because size containment turns a subgrid root into a plain grid and drops the page-grid lines its children sit on.

    The rules are viewport queries now, at the same breakpoints; the blocks are full-bleed, so the viewport is their width.

    • @wabbit/tome-blocks-marketing-starter: high-impact-hero (two columns stack to one, large headline sizes step down, at 768px and below).
    • @wabbit/tome-blocks-extras: BlogHero, ChapterHero, CustomHero, ImageHero, LowImpactHero, MediumImpactHero, PageHero, StudyHero, TypographyHero (768px) and SectionHeader (640px).
    • @wabbit/tome-blocks-content-writer: PostHero (768px).
    • @wabbit/tome-blocks-agency-essentials: SplitView stacks at 768px.

    Above those widths nothing changes.

2 fixes (0.34.1)
  • 0.34.1
    Wide layouts start at 768px again, and the FAQ and the team-roster people variant keep their inner lines on the page grid.
    • 768px is the wide layout. The narrow-screen viewport queries added in the previous release were inclusive (max-width: 768px), so at exactly 768px these blocks stacked although the platform's other queries and the designs start the wide layout there. They now end just below the breakpoint (max-width: 767.98px; 639.98px for SectionHeader): high-impact-hero (marketing-starter); BlogHero, ChapterHero, CustomHero, ImageHero, LowImpactHero, MediumImpactHero, PageHero, StudyHero, TypographyHero and SectionHeader (extras); PostHero (content-writer); SplitView (agency-essentials). Each pack's test fails a converted query that includes its breakpoint.
    • Subgrid column gaps. The faq root and the team-roster people section are subgrids of the page grid, but each also set a column gap (the FAQ through its gap shorthand, the people section through the shared roster gap). The page grid has no column gap, so every line inside them moved by half the gap: a theme's FAQ list on reading-start started 9px inside the reading column, and the people header and grid 9px inside the content lane. Both now set a row gap only (column-gap: normal, the parent's gap on a subgrid).
  • 0.34.1
    contact reserve at 1024px and wider sits on the page grid's lanes: the eyebrow in the left marginalia lane beside the heading, and the heading, intro, form, chips, "Or:" row and facts from reading-start to content-end, so the heading has room for one line.

    Before, the eyebrow stacked above a heading capped at 44rem from content-start. The band's inner column becomes a subgrid with no column gap (the eyebrow pads its own end); --tome-contact-reserve-eyebrow-offset (default 1.1rem) drops the eyebrow from the heading's top edge.

    Narrower widths and every other contact variant are unchanged.

0.33.xMinor

  • New venue variant for high-impact-hero and new reserve variant for contact, for places people visit.
    • `high-impact-hero` `venue`: the venue name set very large, with a meta line, one line and one action. The background photo sits either behind the copy, over a theme-set scrim (--tome-hero-scrim), or full width above it. It is chosen by the new optional venue.mediaPlacement field (behind, the default when empty, or above). Hooks: data-hero-media-placement, data-hero-scrim, data-hero-meta, data-hero-name and data-hero-line, plus the existing data-hero-media and data-hero-actions.
    • `contact` `reserve`: a dark booking band that links out to the venue's booking provider. It has a GET form for party, date and time (labels partyLabel, dateLabel and timeLabel, default "Party", "Date" and "Time"), built from an editor-written reserve.urlTemplate with {party}, {date} and {time} tokens, and works without JavaScript when each token is a whole query value. Under the form come labeled sample-time chips (sampleSlots[] and sampleSlotsLabel, never live availability), then an "Or:" row (lead-in altLabel, default "Or:") with a tel: call action and altActions[] (link, call, or order, which opens the ordering drawer). Every label is optional and localized; empty, it renders the English default. With no provider set, the form hides and the band leads with its actions; contact rows render as a facts row. eyebrow and callAction are now also shown for reserve.
    • New subpath `@wabbit/tome-blocks-agency-essentials/reserve`: the band's pure URL helpers, plus enhanceReserveBands(), an optional, framework-free client enhancement. It fills today's date, keeps the chips in step with the form, and fills templates the GET form cannot carry whole. The renderer stays a server component.

    All new fields are optional, variant-gated and have no default value, so existing content renders byte-identical.

0.32.xMinor

  • Counsel v1 agency pieces: a people variant for team-roster and an aside for the contact estimate variant.

    `team-roster` `people`: a statement tile, then tall portrait cards. The variant has its own optional fields, shown only when it is picked: people[] (name required, role, credential, fact, photo, photoLabel), statement (text, label) and emptyCells (1-based positions of deliberate empty cells, counted across every cell including the statement).

    Each card shows the name, role, a credential line and one personal fact in italic. photoLabel renders on the portrait as data-compliance-label, only when there is a photo.

    Empty cells are aria-hidden and appear from 1024px, where the grid has three columns. Hooks: data-block-variant="people", data-roster-statement, data-roster-person, data-person-name, data-person-role, data-person-credential, data-person-fact, data-roster-empty.

    members[] and epigraph are now hidden in the admin for people only. The other four variants render exactly as before.

    `contact` `estimate`: an optional aside group (label, plus a body of a few short paragraphs in limited rich text, bold and italic only), for a note such as a confidentiality notice. It renders last in the intro column as <aside data-contact-aside>, named by its label.

    With no label and no body text, the output is unchanged.

0.31.xMinor

  • Groundwork v1.1 block fields for the quick-ask hero and the estimate contact band.

    `high-impact-hero` `quick-ask`: proofLine gains an optional proofLead text field, rendered as a <strong data-hero-proof-lead> before the proof text (for example "Next opening:"). Fixed: at phone widths the status dot could wrap onto its own line away from its text.

    The proof line is now inline flow, and the dot shares a no-wrap run (data-hero-proof-start) with its first phrase: the lead, or the first word of the text. The rest of the line wraps normally.

    With no lead and no dot, the markup is unchanged.

    `contact` `estimate`: two optional text fields. eyebrow is a small meta-voice kicker above the heading (data-contact-eyebrow).

    formNote is a short meta line beside the form card heading (data-contact-form-note). When it is set, the heading and note share a wrapping head row (data-contact-card-head).

    When both fields are empty, the output is unchanged.

0.30.xMinor

  • The contact block's estimate variant takes an optional cardHeading, rendered at the top of the form card with a [data-contact-card-heading] hook.

    The field is shown only for estimate and renders nothing when empty, so existing estimate blocks are unchanged.

0.29.xMinor

  • Contact gains an estimate variant (a two-column request band with a form card), and phone and email values in every variant now render as tel: / mailto: links.

    Additive: the new fields are optional, carry no defaults and appear in the admin only when estimate is selected. Adding them to a Payload config adds their columns, so run your usual migration.

    • Links. A contactBlocks[].value that is exactly one email address renders as a mailto: link, and one that is exactly a phone number (7 to 15 digits, phone punctuation only) as a tel: link. Every other value, including addresses, year ranges and dates, renders as text, as before.
    • `estimate` variant. Left: the richText heading and intro, a promise (value shown as a big numeral, unit, text), a callAction (prompt, label, phone), hours[] (days, time, tabular figures) under an optional hoursLabel, and areas[] (name, rendered as chips) under an optional areasLabel. Right: a form card around the same data-form-id slot the form block renders, and an optional responseLine. Phones stack heading, form, then call, hours and areas.
    • Form picker is opt-in. By default the block still relates to no collection. Register it with contactBlock.block({ formsCollection: 'forms' }) to add a cardForm relationship; the ContactBlockConfig type is exported.
    • Filling the slot. Pass your site's form to the renderer as formSlot (ContactProps is exported from ./render); without it the slot stays empty, like the form block's.
    • Theme hooks: data-block-variant="estimate", data-contact-intro, data-contact-promise, data-contact-promise-numeral, data-contact-form-card, data-form-id, data-contact-response-line, data-contact-side, data-contact-call, data-contact-hours, data-contact-areas, data-contact-chip, and data-contact-link="phone|email" on detected values. Every contact variant root now also carries data-block-variant.

0.28.x

3 fixes (0.28.0 – 0.28.5)
  • 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.cts declarations, 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 .map files and the sourceMappingURL comments 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 snapshots extractGallerySource writes 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.0
    The timeline preview no longer names a specific platform launch.

    The last timeline milestone reads as a generic flagship service launch. Block fields and variants are unchanged.

  • 0.28.0

    Blocks now carry the neutral Tome source tag instead of the legacy source tag, and variant descriptions use plain layout names; stored content is unchanged.

0.27.x

1 fix (0.27.0)
  • 0.27.0
    The pack works on a stock Next.js site: its per-block stylesheets now ship precompiled, so the site needs no next.config plugin.

    Each renderer's .tome-css stylesheet is compiled when the pack is built, into a JS module next to it (<Name>.tome-css.js / .cjs), and the renderers import that. A site no longer has to wrap next.config with withTomeBlockStyles to use the pack, and ./render and ./render/register now load under plain Node, so seed scripts, tests and the Payload CLI can import them.

    Each block's CSS is still inlined only on pages that render the block. A site that already uses withTomeBlockStyles needs no change.

    Scoped class names change once, because they are now keyed on the package rather than on where it is installed. The raw .tome-css files stay in the package as readable source.

0.24.xMinor

  • Breaking: block stylesheets are now per-block (`.tome-css`). Each block's CSS ships only on pages that render it, instead of in every page's CSS bundle.

    The 10 stylesheets moved from X.module.css to X.tome-css, and each renderer renders <BlockStyles sheet={styles} /> from @wabbit/tome-blocks-core/block-styles.

    Required in the consuming site: wrap next.config with withTomeBlockStyles (@wabbit/tome-blocks-core/next, blocks-core 0.22.0 or later); without it the .tome-css imports fail to build. See the blocks-core README, "Per-block stylesheets".

    • The @wabbit/tome-blocks-core peer range is now >=0.22.0 <1.0.0.
    • No client renderers. Exported names and props are unchanged.
    • Block CSS now loads after all bundled CSS. A site-level rule that overrode one of this pack's classes at equal specificity, and won only by loading later, no longer wins.

0.18.x

2 fixes (0.18.0 – 0.18.3)
  • 0.18.3

    CSS files are now copied to dist/ by a post-build script instead of a tsup onSuccess hook; no behavior change, and the published dist/ is identical.

  • 0.18.0
    register() is now built with blocks-core's createPackRegistrar.

    Behavior and signature are unchanged. The @wabbit/tome-blocks-core peer floor goes up to >=0.18.0 because that is the first version exporting the helper.

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-new create-payload-app website-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 install of a pack already brings them in).

    • One shared `blocks-core` per site. Every pack, blocks-house and blocks-extras now 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-core no longer declares @wabbit/tome-core or @wabbit/tome-catalog (their optional peer graph pulled better-auth → @sveltejs/kit → vite@8 against a site's vite@7). The block-bundle product type still auto-registers when both are installed; new structural types BlockBundleProductTypeDeps, BlockBundleProductTypeRegistryLike, RegisterProductTypeHooksLike.
    • Tokens in one line: @import '@wabbit/tome-blocks-core/styles.css'; (new export; imports @wabbit/tome-ui/tokens). @wabbit/tome-ui is now a required peer of blocks-core.
    • Rich text and images render with no adapter setup. Built-in defaults render Lexical through @payloadcms/richtext-lexical/react and 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's renderers map for a site that renders <Block {...block} />.
    • Slug collisions with Payload's templates (cta, banner, archive, content, code): new applyBlockSlugOverrides(blocks, overrides) and remapRendererSlugs(renderers, overrides) (@wabbit/tome-blocks-core/slugOverrides). Defaults are unchanged; no stored data migrates.
    • `blocks-house` owns gsap and hls.js as dependencies (previously optional peers that still broke the build when missing), and registers GSAP's ScrollTrigger itself before first use.
    • Full-bleed bands actually span the grid. Eight pinnedBand blocks (cinema-pack AmbientBand, MediaPanel, PullInterlude, SceneCaption, ScenePlate, ScrubStory, StatementBand; blocks-house FullBleedInterstitial) now declare grid-column: 1 / -1 at 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`: .grid declares reading-start / reading-end below 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.

0.16.xMinor

  • Raise the react / react-dom peer floor to >=19.0.0 across the linked blocks family.

    Every pack advertised react: >=18.0.0 while @wabbit/tome-core, chrome, forms, dispatch, longform and readout all 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 devDependencies said the same thing from the other direction: react and @types/react pinned to ^18.0.0 while the root pnpm.overrides has pinned @types/react to 19.2.14 for 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 linked in .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.

  • dist now raw-Node loadable: relative specifiers get explicit extensions post-build.

    build gains && node ../../scripts/fix-dist-extensions.mjs --strict as its last step, joining the 13 packages that already ran it. tsup builds bundle: false and emits relative specifiers exactly as the TypeScript source wrote them — extensionless — which bundlers resolve and raw Node does not (ESM ERR_MODULE_NOT_FOUND; CJS worse, require('./x') finds the ESM .js twin 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.mjs now 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 --strict exited 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/.cjs appended. assert-node-loadable.mjs gained the matching carve-outs so the new repo-wide CI gate reports real defects only: a resolution failure whose path lands under node_modules is a peer SKIP (next@15 has no exports map, so next/image fails as an absolute path), and a bundler-asset load failure is an environmental SKIP (CJS surfaces it as SyntaxError: 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 import missing /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.

    • homepage deep-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.url points at the repo issue tracker, so a paying customer has a place to report a defect that is not email.
    • engines.node is >=22, matching the root engines and .nvmrc set the same day. This is a real floor, not decoration: CI on Node 20 could not expand the glob the block packs use for node --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 (root pnpm assert:manifest-metadata, wired into platform-discipline.yml beside assert:license-metadata) fails when any publishable manifest lacks description, repository.directory matching its own folder, homepage, bugs, engines.node equal to the repo floor, license, files or sideEffects. It reported 138 violations before this change and 0 after.

  • Fix a hydration mismatch in Testimonial and adopt the shared UTC-pinned date formatter across five renderers.

    @wabbit/tome-blocks-marketing-starter's Testimonial is a 'use client' component and rendered its publication date with a bare new Date(iso).toLocaleDateString(). That resolves against the RUNTIME locale and the RUNTIME time zone, so the server produced one string and the browser produced another and React reported a hydration mismatch on the block.

    The 2026-07-11 audit flagged it; it was still there on 2026-09-01.

    The same bare call sat in four server renderers — blocks-editorial-pack's MetricStrip and StatusBoard, blocks-agency-essentials' TeamRoster (twice) and Timeline. No hydration mismatch there, but the rendered output changed with the deploy host's locale and offset, which is its own kind of wrong.

    All six call sites now use formatDisplayDate from @wabbit/tome-blocks-core/utilities/formatDisplayDate with an explicit locale: 'en-US', a numeric M/D/YYYY dateStyle and timeZone: 'UTC'. The numeric shape is deliberate: it is byte-identical to what a US-locale runtime already produced, so this fixes the determinism without silently restyling anyone's dates.

    Every pack already declared @wabbit/tome-blocks-core, so no dependency changes.

    Shipped with its forcing function: a no-restricted-syntax rule in eslint.config.mjs warns on bare toLocaleDateString/toLocaleString/toLocaleTimeString in every packages/*/src/**/*.tsx and every block pack's render/ directory, and points at formatDisplayDate and at blocks-gallery's ADDED_AT_FORMATTER as the two accepted shapes.

  • README peer tables, and the gate that now requires them.

    Sixteen packages declared peerDependencies and 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's sonner peer, has its own changeset).

    Each of the sixteen gains a ## Peer dependencies section generated from its own package.json — | Peer | Range | Required |, one row per peer, the range verbatim, no (optional) read from peerDependenciesMeta, plus one sentence on what is a real dependency rather than a peer and why the optional ones are optional. The worst omissions this surfaced: @wabbit/tome-core documented 2 of its 13 peers and left out both next and @payloadcms/richtext-lexical, which are required; @wabbit/tome-admin listed 5 of 20; @wabbit/tome-readout and @wabbit/tome-sc listed none.

    Eight block packs carried a hand-typed compatibility table that had drifted a full React major — still >=18 after the peer floor moved to >=19.0.0 — and none of the eight listed react-dom at 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.mjs now FAILS a package that declares peers without a peer table (a markdown table whose header row names a Peer and a Range column — the existing Optional? and Notes third 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.

  • Updated dependencies [57875ba]

  • Updated dependencies [b01ca1f]

  • Updated dependencies [0836ef5]

  • Updated dependencies [73081e6]

  • Updated dependencies [090e984]

  • Updated dependencies [73081e6]
    • @wabbit/tome-blocks-core@0.16.0
    • @wabbit/tome-blocks-extras@0.16.0

0.15.xMinor

  • Route pack block links through the LinkAdapter instead 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 a children: prop.

    <TomeLink href={…}>…</TomeLink> is a drop-in for the <a> it replaces. It delegates to resolveLink(), 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 to string would have forced ~10 non-null assertions across the packs and changed behavior at each.

    NOOP_LINK_ADAPTER normalizes null to undefined so 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: resolveLink falls 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, childless target="_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 on blocks-core. Adding that edge to the layer graph is its own decision, not a sweep side effect.
  • Updated dependencies [510036f]
    • @wabbit/tome-blocks-core@0.15.0
    • @wabbit/tome-blocks-extras@0.15.0
7 fixes (0.15.10 – 0.15.12)
  • 0.15.12
    Republish with correctly-built artifacts, and close the hole that made it necessary.

    The 0.15.11 line shipped defaultBreakout in its version notes but not in its tarballs: source on main carried all 151 descriptors, the published dist/ carried zero. pnpm publish does not build — it packs whatever is already in dist/ — so a release ships whatever a previous, possibly unrelated, build left behind.

    Here the dist had been built from a branch that predated the metadata, and nothing in the pipeline compares artifact to source.

    Caught by grepping the _installed_ package in a consumer rather than trusting the version number.

    Systemic fix: prepublishOnly: pnpm run build added to all 41 publishable packages that lacked it (only blocks-org-pack had one — which is why it was the single package whose build ran during the previous publish). Every publish now rebuilds from source first, so a stale-dist release becomes impossible rather than merely unlikely.

    Same family as the two publish hazards already documented in this repo (workspace:* literals reaching the registry, and exact-pin dependencies forcing nested duplicate copies): the publish path had no guard that what ships matches what is committed.

  • 0.15.12
    Updated dependencies [54ff357]
    • @wabbit/tome-blocks-extras@0.15.12
  • 0.15.11
    Populate BlockMeta.defaultBreakout across all nine block packs — 151 descriptors now declare their natural width on the page grid.

    The 2026-06-28 layout-grid + nesting contract (Decision 3 / Amendment A1) defined defaultBreakout as the per-block breakout POLICY co-located with the block, replacing a central hand-maintained table. No pack had ever filled it in, so every consumer fell through to 'article' (the reading column) and a full-bleed hero previewed at prose width.

    Values are drawn from the canonical @wabbit/tome-ui BreakoutWidthValue vocabulary and assigned from each block's render CSS, not its name:

    • Root at a named grid line (content-start / content-end, full-start / full-end, marginalia-right-*, …) — defaultBreakout mirrors that exact line.
    • Root 1 / -1 + subgrid with an inner wrapper at content-start / content-end — a self-banding block: 'full-bleed'.
    • Root and inner both 1 / -1 (width-agnostic) — assigned editorially: bands/heroes 'full-bleed', page sections 'content', cards and single-column components 'breakout-md', prose/inline components 'article'.
    • Where a block already ships its own breakoutWidthField({ defaultValue }), defaultBreakout matches that value exactly rather than contradicting it.

    Distribution: content 52, full-bleed 41, article 33, breakout-md 23, breakout-lg 1, marginalia-right 1.

    Also declares pinnedBand: true on the three blocks that are unambiguously full-bleed bands whose own breakoutWidth field drives INNER content (compareColumns, editorialSection, editorialSpread), and nestable: false on 22 full-bleed heroes / band primitives / containers that carried no nestable declaration.

    Purely additive optional metadata: no descriptor field, block structure, or CSS changed, and no existing nestable: true was flipped, so the derived layoutGrid child allowlist is byte-identical (98 nestable blocks before and after).

  • 0.15.11
    Fix a systemic, invisible-text readability defect across the block packs: a text color and the surface under it were coming from sources a consumer can set independently, so the pair could split.

    Measured live on starter.wabbit.com with a canvas-based contrast parser walking every rendered text node: the worst pairs sat at 1.00–1.03:1 — near-black text on a near-black surface, copy that renders but cannot be read. Nothing in CI could see it, because every unit test and every route smoke passes with perfectly invisible text.

    This is the second time this bug family has shipped. The first sweep added --tome-color-on-solid-dark (blocks-lms-pack 0.12.1) and fixed chrome, lms-pack and catalog-pack; the themed packs were missed.

    This closes the rest and adds the tokens whose absence is why the misuse kept spreading.

    Three mechanisms, one root cause

    1. Split pairs. Blocks paired --tome-color-card — not a house token at all; the house name is --tome-color-surface — carrying a DARK literal fallback, against --tome-color-foreground, which every themed consumer does define, carrying a LIGHT one.

    A fallback pair is only safe when both sides fall back together. The same shape appeared as cross-family pairing (surface, the CARD family, paired with foreground, the PAGE family) and as clobbering: a band setting color: background on itself while its children hardcoded their own color: foreground, which wins. longform had a third variant — it read --tome-color-muted-foreground 32 times and --tome-color-muted 3 times, and neither has ever been a house token, so the entire muted tier silently fell through to currentColor and inherited whatever ink an ancestor happened to have.

    2. Alpha-dimmed text. opacity: 0.4–0.9 on a label, and color-mix(<colour> 30–70%, transparent) as a color:, composite against whatever happens to be behind them, so the ratio is unknowable at author time.

    Worst measured: 1.46:1. Several were an ancestor opacity aimed at a rule that dimmed the real text in the same container along with it.

    3. Status and brand colors used as text. warning/success/error/ destructive are FILL colors, tuned to be painted as a badge with an ink on top.

    Used as color: the default amber measures 2.13:1 and the green 2.82:1. Brand hues have the mirror problem: --tome-color-primary as text is fine on the page (near-black by default, 5.5:1 even under the starter's oxide theme) but becomes 2.22:1 inside a band whose fill the consumer chooses.

    New in @wabbit/tome-ui

    • `--tome-color-{success,warning,error,destructive,info}-text` — the missing text-weight companions. Literals with inverted [data-theme="dark"] values, each pinned to clear 4.5:1 against both --tome-color-background and --tome-color-surface. info never had a fill token either, which is why packs reached for primary. Rule of thumb: warning paints a box, warning-text writes a word.
    • `--muted-foreground` retuned (hsl(215 16% 47%) → hsl(215 20% 38%) light, 65% → 72% dark). That token is --tome-color-on-surface-muted, the tier every pack uses for captions, labels, metadata and table headers, and at the old value it reached only 4.27:1 on --card. The entire secondary text tier platform-wide sat just under AA — which is also why packs kept reaching past it for something with more presence. Standalone default only.

    Because these are literals rather than Layer 1 aliases, @wabbit/tome-cop restates them: a pack that is dark without being [data-theme="dark"] would otherwise inherit the light values.

    Fix shape, per pack

    dispatch, readout, blocks-signal-theme are permanently dark by product identity. Each owns a pack-scoped surface/ink SET (--dispatch-*, --readout-*, --signal-*) with internally consistent dark defaults compiled into every block module as inline fallbacks.

    No rule in these packs reads a house surface or text token for a panel, so no consumer theming can split the pair. The house surface-solid-dark/on-solid-dark pair was rejected here for a stated reason: it is one flat pure-black surface with a single ink, and these packs need a layered palette. tome-cop drives all three sets so its theming still applies. signal-theme's accents split into three roles — identity fill, lightened on-panel text, and per-hue ink for accent fills — because one value cannot serve both a dark panel and a light article.

    longform, content-writer, editorial-pack, marketing-starter, agency-essentials, extras follow the ambient theme and are fixed with the house vocabulary: correct pairs (surface/on-surface, background/ foreground, primary/on-primary), the new -text weights for status copy, and solid ink steps in place of alpha. Painted bands publish their own ink as a local --_on-band, and brand/status text reads `var(--_on-band, <its normal

  • 0.15.11

    Updated dependencies [1bebcdc]

  • 0.15.11
    Updated dependencies [48773ac]
    • @wabbit/tome-blocks-extras@0.15.11
  • 0.15.10
    Alignment republish: these two artifacts were the last on the registry published before the workspace:^ policy, carrying exact @wabbit dependency pins (blocks-core 0.15.0, blocks-extras 0.15.0) that force nested duplicate copies — and split blocks-core's renderer/link registries — in any consumer whose tree moves past 0.15.0.

    No source changes; the republish ships range deps.

0.14.x

1 fix (0.14.0)
  • 0.14.0
    Updated dependencies
    • @wabbit/tome-blocks-core@0.14.0
    • @wabbit/tome-blocks-extras@0.14.0

0.13.x

2 fixes (0.13.0)
  • 0.13.0

    Updated dependencies [f4d55c9]

  • 0.13.0
    Updated dependencies [eb403d4]
    • @wabbit/tome-blocks-core@0.13.0
    • @wabbit/tome-blocks-extras@0.13.0

0.11.x

14 fixes (0.11.0 – 0.11.2)
  • 0.11.2
    Updated dependencies [e11d5a2]
    • @wabbit/tome-blocks-core@0.11.2
    • @wabbit/tome-blocks-extras@0.11.2
  • 0.11.0
    sc-pack: dead CSS-copy tsup hook deleted (the pack ships zero CSS); its deliberately-lightweight profile (no meta.ts, rides tome-sc's token theme) is now documented in the source header with the convergence trigger (gallery browse surface needs meta).

    ./demo subpath rule: all 10 renderer packs now expose it — added to org/lms/catalog/sc packs plus agency-essentials (found missing in the consistency sweep); verified the demo import graph never reaches registering code.

  • 0.11.0
    Every package now declares an explicit sideEffects field (38 added; motion/engine/forms already correct).

    Registration-bearing modules (render files' registerRenderer, blocks/*/index.ts defineBlock self-registration, widget register.ts files, 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 blanket false on a package with registration or CSS.

  • 0.11.0
    Small verified fixes: agency-essentials Contact gains its missing 'use client' (it calls the rich-text adapter hook; direct RSC import crashed). chrome NavGuard now dev-warns when its capability gate fails to load while a requiredCapability is set (the fail-open contract itself is unchanged and now documented). blocks-core BLOCK_CATALOG.ts corrupted 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
    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 to resolveRichText/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-boundaries CI script (per-pack manifest; fails loudly if a directive creeps back or a count drifts). Every renderer-bearing pack now exports ./render/register (renderers map + explicit registerRenderers()), aggregated by @wabbit/tome-blocks's new registerAllRenderers() — 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).

    RenderBlock is rewritten server-safe: directive-free, optional components prop (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

    Updated dependencies [26dfa07]

  • 0.11.0

    Updated dependencies [36e537a]

  • 0.11.0

    Updated dependencies [36e537a]

  • 0.11.0

    Updated dependencies [36e537a]

  • 0.11.0

    Updated dependencies [a93f478]

  • 0.11.0

    Updated dependencies [5f78397]

  • 0.11.0

    Updated dependencies [5f78397]

  • 0.11.0

    Updated dependencies [aef2725]

  • 0.11.0
    Updated dependencies [aef2725]
    • @wabbit/tome-blocks-core@0.11.0
    • @wabbit/tome-blocks-extras@0.11.0

0.10.x

1 fix (0.10.0)
  • 0.10.0
    Updated dependencies
    • @wabbit/tome-blocks-core@0.10.0
    • @wabbit/tome-blocks-extras@0.10.0

0.9.xMinor

  • layoutGrid nesting capability audit + render hardening (Wave 2).

    Tags the nestable capability flag (and a minColSpan floor for internally-wide blocks) across the offered blocks in the core packs (extras, marketing, content, agency, editorial, signal), so the layoutGrid container'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: 2 is 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 of React.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-core is bumped to release the layoutGrid platform primitives merged earlier but never published (the registry's 0.8.0 predates that merge): BlockMeta.nestable/minColSpan, withChildPlacement, the reserved _colSpan/_rowSpan/_order field constants, and blockRegistry.getNestableBlocks(). Without this, a consumer wiring the layoutGrid allowlist would call a getNestableBlocks that its installed blocks-core@0.8.0 does 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.

  • Updated dependencies [c7d0afc]
    • @wabbit/tome-blocks-core@0.9.0
    • @wabbit/tome-blocks-extras@0.9.0
5 fixes (0.9.1 – 0.9.5)
  • 0.9.5
    Updated dependencies
    • @wabbit/tome-blocks-extras@0.9.5
  • 0.9.4
    Docs-manifest emitter pipeline (W3 ship-readiness).

    @wabbit/tome-blocks-core now ships a standalone Node ESM CLI at scripts/emit-docs-manifests.mjs that emits per-package documentation manifests (index.json, packages/<slug>.json, changelog.json) by reading what packages already carry — READMEs, the payload-free <pkg>/meta block-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 ./meta payload-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's BlockMeta was 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.md to its published files array so the next publish cascade ships changelogs the emitter can read from installed tarballs at prebuild.

  • 0.9.4
    Updated dependencies [bed3f90]
    • @wabbit/tome-blocks-core@0.9.4
    • @wabbit/tome-blocks-extras@0.9.4
  • 0.9.2
    Updated dependencies
    • @wabbit/tome-blocks-core@0.9.2
    • @wabbit/tome-blocks-extras@0.9.2
  • 0.9.1
    Updated dependencies [c07f3c8]
    • @wabbit/tome-blocks-extras@0.9.1

0.8.x

4 fixes (0.8.0)
  • 0.8.0

    Updated dependencies [249b670]

  • 0.8.0

    Updated dependencies [249b670]

  • 0.8.0

    Updated dependencies [249b670]

  • 0.8.0
    Updated dependencies [249b670]
    • @wabbit/tome-blocks-extras@0.8.0
    • @wabbit/tome-blocks-core@0.8.0

0.7.x

3 fixes (0.7.0)
  • 0.7.0

    Updated dependencies [28802fa]

  • 0.7.0

    Updated dependencies [66c611c]

  • 0.7.0
    Updated dependencies [8958d41]
    • @wabbit/tome-blocks-extras@0.7.0
    • @wabbit/tome-blocks-core@0.7.0

0.6.x

2 fixes (0.6.1 – 0.6.2)
  • 0.6.2
    Updated dependencies [4b2f368]
    • @wabbit/tome-blocks-core@0.6.2
    • @wabbit/tome-blocks-extras@0.6.2
  • 0.6.1
    feat(demo): add getDemoProps dispatchers to agency-essentials, marketing-starter, and signal-theme

    Each pack now ships a demo.ts barrel with a getDemoProps(blockSlug, variant, ctx?) dispatcher and per-block demo functions. The auto-gallery route in tome-starter can replace the noopDemoProps stub for all three bundles, eliminating the warning cards that appeared for every block × variant.

    • agency-essentials: 10 blocks × 4 variants each (about, contact, team-roster, gallery, timeline, stat, stat-bar, split-view, media, form)
    • marketing-starter: 8 blocks × 4-5 variants each (high-impact-hero, feature-hero, cta, logo-slider, pricing, testimonial, faq, banner)
    • signal-theme: 33 blocks (all signal-\* slugs); blocks with multi-variant configs covered (accordion: stacked/single, callout: tactical/lore)
    • DemoContext interface, individual block-level functions, and getDemoProps all re-exported from each pack barrel
    • richText fields intentionally omitted — GalleryRichTextProvider supplies the Lexical state at gallery render time
    • Relationship fields (pricing, testimonial) emit sentinel strings; gallery degrades gracefully

    Fixes risk R1 from the gallery gap audit.

    • @wabbit/tome-blocks-core@0.5.9

0.5.xMinor

  • Linked cohort version alignment (no functional change in this package).

4 fixes (0.5.7 – 0.5.9)
  • 0.5.9

    @wabbit/tome-blocks-core@0.5.9

  • 0.5.9

    @wabbit/tome-blocks-extras@0.5.9

  • 0.5.7

    @wabbit/tome-blocks-core@0.5.7

  • 0.5.7

    @wabbit/tome-blocks-extras@0.5.7

0.4.x

4 fixes (0.4.0 – 0.4.2)
  • 0.4.2
    Updated dependencies
    • @wabbit/tome-blocks-extras@0.4.2
    • @wabbit/tome-blocks-core@0.4.2
  • 0.4.1
    Updated dependencies
    • @wabbit/tome-blocks-extras@0.4.1
    • @wabbit/tome-blocks-core@0.4.0
  • 0.4.0

    Updated dependencies [b76f684]

  • 0.4.0
    Updated dependencies [90a694d]
    • @wabbit/tome-blocks-core@0.4.0
    • @wabbit/tome-blocks-extras@0.4.0

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 .tsx components migrated from the monolith into their owning bundle packages (./render subpath on each bundle).

    Breaking changes (internal):

    • @wabbit/tome-blocks/blocks and @wabbit/tome-blocks/bundles subpaths removed (were Sprint 2 shims)
    • packages/blocks/src/render/ category index files removed (replaced by per-bundle ./render subpaths)

    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);
  • Updated dependencies [f2202cd]
    • @wabbit/tome-blocks-core@0.3.0

Full changelog across all packages