Documentation menu

Documentation / Foundation / chrome

Chrome

Package
@wabbit/tome-chrome
Version
v0.31.0
npm install @wabbit/tome-chrome
First install from our registry?
  1. Get a free registry token from your credentials page. Every install from our registry needs one, free packages included.

  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
FoundationStable

Tome platform site-shell layer — Header global factory + 7 navbar variants + Footer global factory + 11 footer variants + variant registries + scroll-direction hide/reveal. Distinct from @wabbit/tome-blocks (page-section content) by consumption shape: chrome is configured once per site via Payload globals, not composed per page.

Overview

Tome's site-shell layer: a Header global factory with 7 navbar variants, a Footer global factory with 11 footer variants, per-surface variant registries, and a motion-adapter contract that lets @wabbit/tome-motion drive scroll behavior without tome-chrome depending on GSAP directly. app-adjacent layer per root ARCHITECTURE.md — assembles core + ui into a mountable shell rather than exposing atomic primitives. Distinct from @wabbit/tome-blocks* by consumption shape: chrome is configured once per site via two Payload globals, not composed per-page like a content block.

Peer dependencies

PeerRange
@wabbit/tome-core>=1.22.0 <2.0.0
@wabbit/tome-ui>=0.9.0 <1.0.0
next>=14
payload>=3.67.0
react / react-dom>=19.0.0
lucide-react>=0.460.0
typescript>=5.7.0

60-second quickstart

payload.config.ts:

import { buildConfig } from 'payload'
import { createChromeLayer } from '@wabbit/tome-chrome'

const { globals } = createChromeLayer({
  header: { slug: 'header' },
  footer: { slug: 'footer' },
})

export default buildConfig({
  ...baseConfig,
  globals: [...(baseConfig.globals ?? []), ...globals],
})

createChromeLayer is the canonical layer entry and returns { globals } — the named-object bundle shape, since chrome contributes Payload globals rather than collections. initChrome is a deprecated thin wrapper kept for existing call sites: it calls createChromeLayer(opts) and merges the result into the whole-Config shape it has always taken/returned (initChrome(payloadConfig, opts)).

Root layout (verified against wabbit-site-core's real usage — src/app/(frontend)/layout.tsx):

import { HeaderRenderer } from '@wabbit/tome-chrome/header'

<HeaderRenderer
  header={headerGlobalDoc}
  publicContext={{ locale: 'en' }}
  motionAdapters={chromeMotionAdapters} // from '@wabbit/tome-motion/adapters/chrome', optional
/>

Account slot

accountSlot?: ReactNode | ((ctx: 'desktop' | 'drawer') => ReactNode) on HeaderRenderer and TomeNavbarProps renders a consumer control (for example Log in / Account) immediately before the CTA buttons, in the desktop action row and in the mobile menu's button row. It is rendered by navbars 1 to 5 and 7 (not 6) and only when provided; the wrapper adds no role or tab stop, so the control owns its own semantics. A plain node renders in both places; the function form is called once per place so the two copies can differ. In Navbar7's drawer the slot and the CTA row share one row of equal-width cells, so give the drawer control width: 100%. A function cannot cross a server-to-client boundary: from a server layout pass a node, and use the function form from client code.

<HeaderRenderer
  header={headerGlobalDoc}
  publicContext={{ locale: 'en' }}
  accountSlot={(ctx) => <AccountLink variant={ctx === 'drawer' ? 'block' : 'inline'} />}
/>

Announcement slot

announcementSlot?: ReactNode on HeaderRenderer renders a code-level strip (for example a live open-now status bar or a notice) immediately before the header frame, outside it, in <div data-chrome-announcement>. The wrapper is position: sticky; top: 0 at z-index: var(--tome-chrome-announcement-z), which defaults to the frame's --tome-chrome-frame-z (the frame follows it in DOM order, so open menus still paint above the strip; raise the token to keep the strip above a header sliding up under it). It adds no role or tab stop, so the slot owns its own semantics, and an empty strip is display: none. Absent, null or false renders nothing, so existing headers are byte-identical. It is a node, not a function, so a server layout can pass a client component's element; it is a prop only, with no Header global field and no migration.

A pinned frame (sticky, or fixed in overlay mode) takes top: var(--tome-announcement-height) so it sits under the strip; Navbar 7's fixed <nav> does the same. The CMS-managed announcementBar surface of createChromeLayer is still Phase 3 and accepts only false; when it ships it renders into this same position.

<HeaderRenderer header={headerGlobalDoc} publicContext={{ locale: 'en' }} announcementSlot={<StatusStrip />} />

Header position (headerPosition)

header.headerPosition (Header global field "Header Position") is 'sticky' or 'static'. Empty means sticky, the original behavior: the frame stays at the top of the viewport. 'static' leaves the header at the top of the page so it scrolls away: the frame becomes position: relative (absolute in overlay mode, floating over the hero at the page top), keeps its z-index so menus still open above page content, and hide-on-scroll is switched off. With an announcement slot, only the strip then stays pinned. A set value is written to data-header-position on the frame, beside data-nav-theme and data-nav-scrolled; empty writes nothing. Navbar 7 positions its own <nav> (position: fixed), so it stays pinned either way. The field has no default, so after the migration existing rows stay empty and render as before. Adding it adds a column: run your Payload migration and regenerate types.

Header actions on Navbar7

Navbars 1 to 5 always render searchAdapter (when header.isSearchEnabled), languageSwitcherSlot and themeToggleSlot; Navbar7 ignores them by default, because existing Navbar7 sites already pass these props and must not gain controls unannounced. showHeaderActions?: boolean (default false, on HeaderRenderer and TomeNavbarProps) opts Navbar7 in. It is a prop only, not a Header global field, so turning it on needs no schema change or database migration. Other navbars ignore it.

When on, the desktop right group reads search, language, theme, account slot, CTAs. In the mobile drawer the language and theme controls sit in a row above the account/CTA row, and search sits beside the hamburger (as in Navbar1). Controls inherit the navbar's text color, so they stay legible over a transparent hero and in the solid state; the search trigger is at least 44px square. Absent or false, Navbar7's markup is unchanged.

<HeaderRenderer header={headerGlobalDoc} publicContext={{ locale: 'en' }} showHeaderActions searchAdapter={...} languageSwitcherSlot={<LangSwitch />} themeToggleSlot={<ThemeToggle />} />

Mobile action bar

Every navbar variant can show a phone-width bar fixed to the bottom of the screen with up to three actions: Call (a tel: link, outlined), an optional second action such as Directions (outlined) and one primary CTA (filled). It is a Header-global group, so editors set it once in the Header global under Mobile Action Bar; HeaderRenderer mounts it beside the navbar with no extra props.

Admin field pathTypeNotes
mobileActionBar.enabledcheckboxDefault false. Off renders nothing, so existing sites are unchanged.
mobileActionBar.callLabeltext (localized)Call button text; blank means "Call".
mobileActionBar.phonetextWritten for people, e.g. (555) 014-2290; becomes tel:5550142290. Screen readers hear "Call, (555) 014-2290". Blank hides Call.
mobileActionBar.ctaLabeltext (localized)Filled button text.
mobileActionBar.ctaHreftextPath, #anchor or full URL; routed through LinkComponent when one is passed. The CTA needs both label and link.
mobileActionBar.secondaryLabeltext (localized)Optional second button between Call and the primary action, e.g. "Directions".
mobileActionBar.secondaryHreftextPath, #anchor or full URL, routed through LinkComponent like the CTA. The second action needs both label and link; it works with the phone left blank.
mobileActionBar.showAtLoadcheckboxDefault false: the bar slides in after the first scroll. On: it shows from first paint and stays visible.
mobileActionBar.callWhenselectalways (default when empty), open or closed. See Open state. Needs the site to pass hours to HeaderRenderer.

Two actions share the bar as before; with all three the columns are equal for Call and the second action and wider for the primary, and labels wrap to two lines rather than truncate. Stable theme selectors: [data-action='call'], [data-action='secondary'], [data-action='cta']; data-actions is the count (1, 2 or 3). ctaOverride replaces only the primary action.

Adding the group adds columns, and the two new fields add two more (nullable on SQL): after upgrading, run your Payload migration and regenerate types.

Per-page primary action: pass ctaOverride={{ label, href }} to HeaderRenderer (for example "Try Tuesday 4:30" from the page's schedule). A complete override replaces the stored CTA, or supplies one when none is stored; a blank label or link is ignored, and the bar still needs mobileActionBar.enabled. The Call label may contain {until} (see below).

Behavior:

  • Where: only below the active navbar's desktop breakpoint: 64em for navbars 1, 3, 4, 5 and custom variants, 48em for 2 and 6, and header.desktopBreakpoint for 7 (a non-768 value adds one scoped <style>, the same mechanism Navbar7 uses).
  • When: it slides up once the page has scrolled past about 90% of the viewport height and hides again back above about 50% (the gap stops it flickering).
  • Not covering content: while it is visible below the breakpoint, its measured height (including the safe-area inset) is added to <body>'s bottom padding, on top of the site's own padding, and published as --tome-mobile-action-bar-offset on :root for other fixed elements such as chat launchers.
  • Keyboard and screen readers: a hidden bar is inert and visibility: hidden, so it is out of the tab order and the accessibility tree; a visible bar is a role="region" named "Quick actions".
  • Reduced motion: with prefers-reduced-motion: reduce, or when the motion adapter reports reduced motion (NOOP_CHROME_ADAPTERS does), it appears and disappears without the slide.
  • No JavaScript: the hidden state applies only while script runs (data-hydrated or @media (scripting: enabled)), so without JavaScript the bar is always visible below the breakpoint, and a <noscript> style pads <body> by --tome-mobile-action-bar-height (default 4.5rem). Browsers without the scripting media feature may show the bar briefly before hydration.

Theme hooks: [data-tome-mobile-action-bar] (with data-visible, data-breakpoint, data-actions, data-motion), [data-action="call"], [data-action="cta"], and the custom properties --tome-mobile-action-bar-bg, -fg, -border, -shadow, -radius, -z, -action-height, -cta-bg and -cta-fg (all prefixed --tome-mobile-action-bar), which default to tome color, space, radius and shadow tokens.

Header Call button

header.headerCall puts a compact Call action (a tel: link with a phone icon and a label) in the header bar beside the hamburger, on phones and small tablets. It sits next to the menu button and never replaces it, so navigation stays one tap away. It is hidden from the active navbar's desktop breakpoint up (the same widths the mobile action bar uses), and all seven built-in navbars place it in their mobile group, right before the menu button.

Admin field pathTypeNotes
headerCall.enabledcheckboxDefault false. Off renders nothing, so existing sites are unchanged.
headerCall.labeltext (localized)Visible label; blank means "Call".
headerCall.phonetextWritten for people. Blank uses mobileActionBar.phone, so a site with the bottom bar's number only switches this on. With no usable number in either, nothing renders.
headerCall.callWhenselectalways (default when empty), open or closed. See Open state. Written to data-call-when when set to open or closed.
headerCall.showOnDesktopcheckboxDefault false. Navbar 1 only: also renders the call in the desktop action group, before the header CTA buttons, not just beside the hamburger. With it on, the two copies carry data-header-call-placement="desktop" and "bar"; off writes no placement attribute. Other navbars ignore it.

Adding the group adds columns: after upgrading, run your Payload migration and regenerate types. Screen readers hear "Call, (555) 014-2290". Theme hooks: [data-header-call] (with data-breakpoint), and --tome-header-call-bg, -fg, -radius, -padding and -min-height (all prefixed --tome-header-call), which default to the header CTA hooks and the primary pair. HeaderCallButton and resolveHeaderCall are exported for custom header shells.

Open state (hours-aware header)

HeaderRenderer can make the call hours-aware. Pass it the business hours and the function that reads them; chrome takes no dependency on @wabbit/tome-local, so the hours type is structural (ChromeOpenStateSource, the shape of tome-local's OpenStateSource) and the function is passed in (ComputeOpenState, the signature of tome-local's getOpenState).

'use client' // the function crosses into a client island, so render HeaderRenderer from a client module

import { getOpenState } from '@wabbit/tome-local/hours'
import { HeaderRenderer } from '@wabbit/tome-chrome/header'

<HeaderRenderer
  header={header}
  publicContext={ctx}
  hours={{ timezone: 'America/Chicago', hours: profile.hours, hoursOverrides: profile.hoursOverrides }}
  computeOpenState={getOpenState}
  openScheduleKey="front-desk" // optional: a named schedule in hours.schedules; default the primary hours
  ctaOverride={{ label: 'Try Tuesday 4:30', href: '#close' }} // optional: the phone bar's primary action for this page
/>
PropNotes
hoursChromeOpenStateSource: timezone, hours, hoursOverrides, schedules. Absent: nothing below happens and every call shows.
computeOpenStateRequired with hours; chrome never computes hours itself.
openScheduleKeyWhich named schedule counts as "open".
ctaOverride{ label, href }: the phone bar's primary action. Independent of hours.

What it does, after mount (a client island, OpenStateProvider):

  • Writes data-tome-open="true" | "false" on the document element, for blocks that render open and closed copies, and mirrors it as data-open-state="open" | "closed" on the header frame. The server render writes neither, so first paint is the neutral state and a site without hours never reads "closed". Both are removed on unmount, or when the function throws.
  • Recomputes at the next transition: one timer set for closesAt while open or opensAt while closed (never more than six hours out, and again when a hidden tab becomes visible). No polling.
  • headerCall.callWhen and mobileActionBar.callWhen: open hides the call only while the desk is known to be closed, so a site with no hours, or the first paint, still shows it; closed shows it only while known to be closed.
  • {until} in headerCall.label or mobileActionBar.callLabel is filled with the closing time while open: "Call us, open till {until}" reads "Call us, open till 7" (an evening close is the bare hour, "7:30" off the hour, and a morning close keeps its period, "11 AM"). With no open state, or while closed, the token and its clause drop back to the separator before it ("Call us"), so the server render and the first client render agree.

OpenStateProvider, useOpenState, startOpenStateController, formatUntil and resolveUntilLabel are exported for custom shells.

Two fields, both off by default, change Navbar 1 only (other navbars ignore them): headerCall.showOnDesktop (above) and header.reportScrolled, which reports the scrolled state (page scrolled past 20px) to the header frame as data-nav-scrolled="true" | "false", the way Navbar 7 does, so a theme can give the bar a surface on scroll. Off, the attribute is absent and the frame renders as before. useReportNavScrolled(scrolled, enabled) takes an enabled argument (default true) and useWindowScrolled(enabled) is the scroll hook Navbar 1 uses.

Navbar 1's phone bar reads tokens from 389.98px down, each falling back to what it already did: --tome-navbar1-phone-wrap (wrap), --tome-navbar1-phone-gap, --tome-navbar1-phone-right-gap and --tome-navbar1-phone-trigger-pad (all 0.5rem), plus -narrow variants of the last three below 360px that fall back to the first set. A theme whose bar has to stay one row from 320 to 389px sets --tome-navbar1-phone-wrap: nowrap and tightens the gaps; --tome-navbar1-pad-block (default 8rem) sets the bar's block padding.

Five more Header global fields change Navbar 1 only (other navbars ignore them). Each is off by default, and with it unset the stored markup is byte-identical (pinned by tests/studio-chrome-baseline.test.tsx and the shelf snapshots).

FieldAdmin shows forWhat it doesHook
wordmarkTextNavbar 1The site name as plain set type, in place of the media logo, inside the home link on desktop, in the phone bar and in the phone menuspan[data-header-wordmark]
navAlignment ('between' default, 'center')Navbar 1, 4, 5'center' puts the links on the true center of the bar from the desktop breakpoint up (a 1fr auto 1fr grid, logo at the start, actions at the end). The layout is in chrome's own CSS, so it works unthemeddata-nav-align="center" on the section root
mobileMenuLabel ('icon' default, 'word') and mobileMenuTextNavbar 1, 5'word' swaps the hamburger icon for a text toggle reading mobileMenuText (default "Menu"). It is Navbar 5's own trigger component (MobileMenuWordTrigger), not a copybutton[data-mobile-menu-trigger="word"]
mobileBreakpoint ('64em' default, '56.25em')Navbar 1Where the full bar replaces the "Menu" trigger: '64em' is 1024px (the original), '56.25em' is 900px. The bar, the search button, the Call button and the mobile action bar all switch at the chosen widthdata-mobile-breakpoint="56.25em" on the section root (absent at the default)
markCurrentPageNavbar 1Marks the top-level link for the page being viewed, in the bar and the phone menuaria-current="page" and data-nav-current

wordmarkText precedence: a non-blank value always wins over logo and logoDark (the admin requires a logo, so "text only when there is no logo" could never apply); blank or whitespace-only renders the logo as before. Translate it per locale. Footer 7 follows the same rule with footer.wordmarkText (span[data-footer-wordmark], below).

markCurrentPage matches on an exact pathname, or, for a non-root href, on a section prefix at a segment boundary (/work is current on /work/x, not on /workshop); / matches only /. Hash-only (#x, /#x), external and protocol-relative hrefs never match. The pathname comes from usePathname() in next/navigation, the same source NavLink uses; no provider is added. With no match the markup is unchanged. Only the top-level links are marked, not sub-menu items. isCurrentSection(href, pathname) is exported beside isCurrentPath. (NavLink has always given any link that exactly matches the page aria-current="page"; the option adds the section prefix and data-nav-current.)

The phone menu's link list carries data-mobile-menu-list (Navbar 1 and Navbar 5), so a theme can number or rule the links without reaching into the sheet's structure. The sheet itself is @wabbit/tome-ui's Sheet, whose portalled panel and scrim carry data-sheet-content and data-sheet-overlay (tome-ui 0.22 and later). Navbar 1's sheet has no free-text row of its own, so there is no data-mobile-menu-text hook. All of these are additive attributes; the stored Navbar 1 markup is unchanged.

Header height variables

SiteHeaderHeightProbe (mounted by HeaderRenderer) writes four custom properties on :root. --site-header-height is the flow space a pinned header takes: its height when the frame is sticky or fixed, 0px in overlay mode and for a static header (headerPosition: 'static'), which scrolls away. --site-header-bar-height is the header's rendered height in every mode, overlay included. A hero that sits under a floating header can use it as a minimum top clearance, for example padding-top: max(var(--designed), calc(var(--site-header-bar-height, 4.5rem) + 0.75rem)), so its first line never lands under the bar on a short screen. --tome-announcement-height is the announcement strip's height, 0px when there is none, so calc(var(--tome-announcement-height) + 1rem) always resolves. --tome-chrome-sticky-height is everything pinned at the viewport top: the strip plus the frame when it is sticky or fixed. Use it for in-page anchors, scroll-margin-top: calc(var(--tome-chrome-sticky-height, 0px) + 1rem), so a jump lands below the chrome. The pure headerHeights() and chromeStickyHeight() helpers behind these values are exported for tests and server code.

Header CTA buttons

The Header global's buttons render as buttons in every navbar: filled by default, bordered when the link's appearance is outline, and a plain text link when it is inline (no fill, border or radius; the header's link color, overridable with --tome-header-cta-inline-fg; the same focus ring and 44px tap height). Colors default to --tome-color-primary / --tome-color-on-primary; the type follows the header. Theme hooks, all optional and all prefixed --tome-header-cta: -bg, -fg, -radius, -font, -size, -weight, -tracking, -case, -padding and -min-height (default 2.75rem, a 44px tap target). The variant is a class as well as data-appearance, so it holds when your LinkComponent doesn't forward data attributes.

Three Header global fields shape Navbar 4 (designVersion: '4'); each default renders exactly what the navbar always did, and the section root carries a data-* hook only when an option is on. navAlignment ('between' default, or 'center') puts the top-level nav on the true center of the bar from the desktop breakpoint up (data-nav-align="center"): a three-column grid with equal side columns, the logo at the start, and the actions plus the desktop search button in the end column. menuOpenIndicator ('fill' default, or 'underline') swaps the accent fill on an open or hovered top-level item for a transparent item with a 2px accent bar under the label at the bottom edge of the header (data-open-indicator="underline"); the current page's plain link gets the same bar, and prefers-reduced-motion turns the animation off. mobileMenuStyle ('drillIn' default, or 'accordion') expands each submenu inline in the mobile sheet (data-mobile-menu="accordion"), several at once, with simpleLinks and featuredImage blocks in a compact form (BlockRenderer takes a compact prop; other blocks ignore it), the account, language, theme and CTA area pinned to the bottom of the sheet, and the copyright at the end of the list. ctaVisibleBelowDesktop also applies here: the CTA buttons show in the bar on phones too, and with the accordion they hide while the sheet is open (data-sheet-open) because the pinned footer repeats them.

Navbar 5 (designVersion: '5') honors navAlignment: 'center' too (data-nav-align="center" on the section root): the same three-column grid puts the links on the true center of the bar from the desktop breakpoint up, whatever the widths of the logo and the actions. With it on, Navbar 5 also reports its scrolled state (page scrolled past 20px) so the header frame carries data-nav-scrolled="true|false"; with it off there is no attribute, no scroll listener and no change to the markup.

header.cartSlotPhoneOrder (Header global field "Phone Bar Cart Order", Navbar 5 only, used with cartSlotPhone: 'bar') sets where the phone bar's cart slot sits against the menu toggle: 'before-menu' (default) is the bag then the menu, 'after-menu' renders the menu toggle first and the bag after it. It changes the order in the markup, not with CSS order, so keyboard focus follows what is seen. Unset or 'before-menu' is byte-identical to before. Setting it adds a nullable select column to the Header global: run your Payload migration and regenerate types.

header.logoPhone (Navbar 5) is honored by the single-task checkout header (chromeMode="checkout") as well: below 40em the phone logo replaces the full one (data-logo-full / data-logo-phone wrappers, display: contents), so a long lockup does not crowd the "Back" link. Unset, the checkout header's markup is unchanged.

Optional tokens make every top-level desktop item match: --tome-chrome-nav-item-size (default 0.75rem, 0.875rem from 80em) and --tome-chrome-nav-item-weight (default: plain links inherit, triggers use --tome-type-weight-medium). Set both on :root and links and triggers share one size and one weight. A third, --tome-chrome-nav-item-pad-x (default --tome-space-lg), is the side padding of every top-level item, links and triggers alike, so they space evenly; lower it at narrow desktop widths if the row runs into the buttons. --tome-chrome-content-inline and --tome-chrome-content-max keep setting the bar's gutter and measure.

Every Navbar 4 mobile sheet, in both styles, locks page scroll while open (restoring whatever overflow was there), sizes itself to the dynamic viewport below the measured header (--site-header-bar-height, then the announcement strip), moves focus into the sheet on open, keeps Tab and Shift+Tab inside the toggle and the sheet, and closes on Escape with focus returned to the toggle. In drill-in mode, focus moves to the submenu heading on entry and back to the row that opened it on "Go back".

Transparent header contrast (navTheme)

header.navTheme (Header global field "Transparent Header Contrast", admin path navTheme) says which hero the header sits over before the page scrolls: 'auto' (default; the original behavior, light text and logo for a dark hero), 'light' (dark text and logo for a light hero) or 'dark' (pins the dark-hero treatment). Navbar7 is the variant with a transparent at-rest state, and it applies the value there; once scrolled or opened, its solid bar follows the Background Color tokens as before. The other navbars draw the same surface at rest and scrolled, so they have no at-rest state to switch. Every variant writes the resolved value to data-nav-theme on the header frame (the element carrying data-visible/data-overlay), so theme CSS can key off it too.

Navbar7 also exposes its scrolled state (the page has scrolled past 20px, the same state that solidifies its curtain) as data-nav-scrolled="true" / "false" on its <nav> root, "false" at rest and on the server render. It reports the state up to the header frame, which mirrors it as data-nav-scrolled once the navbar has hydrated; frames around the other navbars never carry it. Themes use it to compact the bar on scroll. A custom navbar can report its own state with useReportNavScrolled(isScrolled).

Navbar 1 theme token: --tome-navbar1-pad-block sets the header's block padding (default 8rem, as before). The phone bar wraps, and its groups may shrink, rather than overflow a narrow screen.

Navbar 7 theme tokens, each defaulting to the value it had before: --tome-header-pad-block sets the header row's block padding (default 2rem; the mobile bottom padding is half of it), --tome-header-curtain-radius the curtain's bottom-corner radius (default 1.5rem; square while the mobile drawer is open) and --tome-header-curtain-shadow the solid curtain's shadow. A theme can compact the bar with [data-nav-scrolled="true"] { --tome-header-pad-block: 1rem } or set the radius to 0 and the shadow to none.

header.menuLabel (admin "Menu Button Label", Navbar 7 only) adds visible text beside the menu icon, <span data-menu-label>. When it is set it is also the button's accessible name; unset, the icon-only button keeps the name "Toggle menu".

header.ctaVisibleBelowDesktop (admin "Keep CTA Visible Beside the Menu Button", Navbar 7 only, off by default) keeps the header CTA buttons in the bar beside the menu button while the drawer layout shows and the viewport is at least 48em wide, instead of folding them into the drawer. It matters when desktopBreakpoint is above 768: the CTA then stays visible across the tablet range. On phones the CTA stays in the drawer, and the drawer copy is hidden wherever the bar copy shows. The bar copy sits in <div data-header-cta-bar>. Setting it adds a nullable checkbox column to the Header global.

A NavLink for a new-tab link (link.newTab, including every header CTA) carries data-new-tab and a visually hidden " (opens in a new tab)" after its content, so a theme can draw an arrow from the attribute while screen readers hear the phrase. A caller-supplied aria-label gets the phrase appended instead. Other links render as before.

Every nav link rendered through NavLink (all navbars and footers) carries aria-current="page" when it points at the page being viewed: a root-relative path equal to the current pathname, ignoring a trailing slash and the query string. Links with a #fragment and external URLs never do. A caller's own aria-current wins.

The logo swap uses the existing pair, not new fields: logo is the logo for light backgrounds and logoDark the one for dark backgrounds. Over a light hero Navbar7 shows logo; over a dark hero it shows logoDark, or logo inverted by a CSS filter when no logoDark is set.

The Footer global's backgroundColor options map to declared tome-ui Layer-2 pairs: muted paints --tome-color-surface-muted with --tome-color-on-surface-muted ink, card paints --tome-color-surface / --tome-color-on-surface, and primary, secondary and accent use their --tome-color-on-* inks.

Every built-in footer writes data-footer-variant="<key>" on its root element, where the key is the variant FooterRenderer resolved (normally footer.designVersion, or '1' when that key is not registered). Themes can target a footer design without relying on the <footer> landmark, which several variants nest inside a <section>. FooterRenderer passes the same key to custom variants as the variantKey prop; put it on your root (<footer data-footer-variant={variantKey}>) to get the same hook.

Footer tokens: footers 1–5, 7 and 8 read their section's block padding from --tome-footer-pad-block (default 8rem, the previous fixed value). Footer 7 also marks its link grid data-footer-nav and reads its column count from --tome-footer-nav-cols (default 3). Footers 1, 2, 4, 6, 7, 8, 9, 10 and 11 mark their top row, the row that holds the brand or intro (logo, subline) above or beside the link columns, with data-footer-top; footers 3 (a bare logo with no row element) and 5 (no brand) have none. The counterpart, data-footer-bottom, marks the bottom bar that holds the copyright and legal links in footers 2–9 and 11, and the legal-links row in footer 10 (which has no copyright line); footer 1 sets its copyright and legal links directly in the footer with no bar element, so it has none. The other footers' link grids mix the brand column and breakpoint-specific column counts, so they have no column token.

footer.wordmarkText (Footer global text field, localized, shown in the admin for Footer 7 only) sets the site name as plain type in the brand column in place of the logo: <span data-footer-wordmark>. A non-blank value wins over logo; blank or unset renders the logo exactly as before. A theme sets the face, size and tracking.

finePrint (Footer global, shown in the admin for Footer 7) holds small-type paragraphs for disclaimers and required notices, each with an optional short lead ("Attorney advertising."). Footer 7 renders them below the link columns and above the bottom bar as <div data-footer-fine-print> with one <p> per paragraph and the lead in <span data-footer-fine-print-lead>; tokens --tome-footer-fine-print-size (default 0.75rem), --tome-footer-fine-print-measure (default 72ch) and --tome-footer-fine-print-inset (default 0, a start indent, so a theme can align the block to a column). Empty renders nothing. Other variants can render it with the exported FooterFinePrint.

cookieSettings (Footer global checkbox, off by default) adds a "Cookie Settings" button after the legal links in every built-in footer. The button only dispatches an open-cookie-settings window event for a cookie-consent manager to handle, so turn it on only when the site mounts one; off, no button renders (never a dead control). showsCookieSettings(footer) is the exported check; FooterLegalLinks itself keeps showCookieSettings (default true) for direct use.

Column rows (navItems[].subNavItems[]) have a rowType: link (the default, and every row saved before the field existed) or text. A text row is a plain line (<span data-footer-text-row>), so addresses and phone numbers no longer need # links. Give it a value and it becomes an aligned label/value pair, <dl data-footer-text-row data-footer-pair><dt>Mon–Fri</dt><dd>8:30–5:30</dd></dl>; in Footer 7 the label column is at least --tome-footer-pair-label-min (default 5.5rem) wide. Every built-in footer renders both row types through the exported FooterColumnRow, and link rows render exactly as before. A text row can be emphasis (bold: the line, or a pair's label, in <strong data-footer-text-strong>) and can groupWithPrevious: it continues the text row above with no list gap, so an office's name and address lines read as one group. A group renders as one list item, <span data-footer-row-group> holding its rows stacked with --tome-footer-row-group-gap (default 0.125rem); the exported groupFooterRows() does the folding for a custom variant. A continuing row after a link row, or first in a column, starts its own item.

Public API

ExportDescription
createChromeLayer(config?)Canonical layer entry — builds Header/Footer globals, registers built-in variants, registers the layer; returns { globals }
TomeChromeConfig, TomeHeaderConfig, TomeFooterConfigFactory config types
HeaderRenderer (@wabbit/tome-chrome/header subpath)Top-level header renderer — resolves variant, wraps motion/logo/link context
FooterRenderer (@wabbit/tome-chrome/footer subpath)Top-level footer renderer — mirrors HeaderRenderer
createHeaderGlobal(config) / createFooterGlobal(config)Build the raw Payload GlobalConfig (used internally by createChromeLayer, exposed for advanced wiring)
Footer1–Footer11 (@wabbit/tome-chrome/footer subpath)The 11 built-in footer variants — Footer11 ("Ledger") is the newest, registered via registerBuiltInFooterVariants
createHeaderGlobal(config), assembleHeaderFields (@wabbit/tome-chrome/header/factory)CSS-clean, config-time-only barrel for wiring the Header global from payload.config.ts without pulling in navbar components/CSS
createFooterGlobal(config), assembleFooterFields (@wabbit/tome-chrome/footer/factory)CSS-clean, config-time-only barrel for wiring the Footer global from payload.config.ts without pulling in footer components/CSS — mirrors @wabbit/tome-chrome/header/factory
headerVariantRegistry, footerVariantRegistry, createVariantRegistry (@wabbit/tome-chrome/registry)Per-surface variant registries — register/get/has/getAll/toFieldOptions
MobileActionBar, resolveMobileActionBar, resolveHeaderDesktopBreakpointPx, toTelHref (@wabbit/tome-chrome/header subpath)The phone-width Call + CTA bar HeaderRenderer mounts from header.mobileActionBar, and the helpers that normalize its data and pick its breakpoint, for custom header shells
HeaderCallButton, resolveHeaderCall (@wabbit/tome-chrome/header subpath)The header bar's phone-width Call action from header.headerCall (phone falls back to mobileActionBar.phone), and its resolver, for custom header shells
OpenStateProvider, useOpenState, startOpenStateController, formatUntil, resolveUntilLabel, ChromeOpenStateSource, ComputeOpenState (@wabbit/tome-chrome/header subpath)The open-state seam behind HeaderRenderer's hours and computeOpenState props; see Open state
ChromeMotionAdapterProvider, useChromeMotionAdapters, NOOP_CHROME_ADAPTERS (@wabbit/tome-chrome/adapters/motion)Context plumbing for the motion-adapter contract; NOOP keeps chrome fully functional with zero animation when tome-motion isn't installed
TomeHeaderData, TomeMobileActionBarData, TomeNavTheme, TomeFooterData, TomeNavbarProps, TomeFooterVariantProps, HeaderRendererProps, FooterRendererProps, ChromeMotionAdapters, ChromeVariant<T>, VariantRegistry<T>Full type surface (@wabbit/tome-chrome/index re-exports @wabbit/tome-chrome/types)

Deprecated alias (removal at this package's next major): initChrome(payloadConfig, opts) → createChromeLayer(opts) + manual globals merge (shape changed — initChrome still takes/returns the whole Payload Config; createChromeLayer takes only chrome's own config and returns { globals }).

The six extension-point slots

Every navbar variant and HeaderRenderer/FooterRenderer accept the same override surface, declared as individual optional props on TomeNavbarProps/HeaderRendererProps/FooterRendererProps (src/types.ts) rather than one grouped "slots" type:

  1. searchAdapter?: () => ReactNode — consumer search UI (header only)
  2. mediaAdapter?: ComponentType<{media, alt?, className?}> — image renderer override (header + footer)
  3. languageSwitcherSlot?: ReactNode — header only
  4. themeToggleSlot?: ReactNode — header only
  5. LogoComponent?: ComponentType<HeaderLogoSlotProps> — override the default <img> logo, e.g. for inline-SVG/theme-adaptive logos (header + footer, via shared HeaderLogoProvider). Every navbar honors it, Navbar 7 included: there it replaces the stacked logo/logoDark layers, sits in a [data-navbar-logo="component"] wrapper that inherits the bar's text color, and receives isDarkModeEnabled: true whenever the bar wants its light-on-dark logo (navTheme dark/auto at rest, or a dark solid background)
  6. LinkComponent?: ComponentType<HeaderLinkSlotProps> — override the default next/link path, e.g. a view-transitions router (header + footer, via shared HeaderLinkProvider)

Footer additionally accepts SocialIconComponent for socialLinks icons (chrome ships no icon set of its own). Audit friction note: because these six live as scattered optional props rather than one ChromeSlots type, a consumer wiring a custom header/footer must cross-reference TomeNavbarProps + HeaderRendererProps (or TomeFooterVariantProps + FooterRendererProps) individually to know the full override surface — there is no single type to import and satisfy.

Server / client posture (load-bearing — verify against src, not assumption)

The two renderers are server-safe. HeaderRenderer.tsx and FooterRenderer.tsx use no hooks and carry no 'use client' directive, so a Next.js App Router layout can render them directly from server code; each resolves its variant from the registry on the server and wraps it in client islands. The client pieces are separate files that declare 'use client' themselves: all 7 navbar variants (Navbar1–Navbar7), Footer10 (the parallax footer), HeaderClient, SiteHeaderHeightProbe, HeaderVisibilityFrame, MobileActionBar, the header's _shared controls (logo, links, search button, CTA buttons, mobile nav sheet) and FooterLegalLinks. Footer1–Footer9 and Footer11 have no directive and render as server components under FooterRenderer. The usual RSC rule applies to what you pass in: a custom LinkComponent, LogoComponent or mediaAdapter crosses into a client island, so it must itself be a client component (or the renderer must be called from client code).

HeaderVisibilityFrame (the scroll-driven hide-on-scroll-down/reveal-on-scroll-up wrapper) is its own 'use client' module, header/HeaderVisibilityFrame.tsx, used by HeaderRenderer and not exported from the package. It reads useChromeMotionAdapters() for scroll direction and useReducedMotion(), and is always visible when reduced-motion is active or when enableHideOnScroll is false.

What genuinely is server-safe:

  • createChromeLayer(), createHeaderGlobal(), createFooterGlobal(), factory/fields.ts — Payload config assembly, runs at config-build/admin time, never inside the React render tree.
  • @wabbit/tome-chrome/registry (createVariantRegistry, headerVariantRegistry, footerVariantRegistry) — plain Map-backed data structures, isomorphic.
  • adapters/motion.tsx (ChromeMotionAdapterProvider) is 'use client' since it's a React Context provider consumed by the client renderer tree.

Extending

Register a new navbar/footer variant via headerVariantRegistry.register({ key, label, component, overlayMode? }) (or the footer equivalent) before HeaderRenderer/FooterRenderer first render — createChromeLayer() is the conventional call site. See

Further reading

  • docs/claude-gotchas.md → Payload / Typing / Layer Design section — the "NavBar4 is a different navbar, not a flag flip" entry applies directly to anyone touching designVersion resolution

Decisions that shaped this package

  • Chrome is configuration, not content — globals, not collections — navbars/footers are set once per site rather than composed per-page like a content block, so the layer claims header/footer Payload globals instead of a RenderBlocks entry, keeping chrome's consumption shape distinct from @wabbit/tome-blocks.
  • Motion is optional via an adapter contract, never a hard dependency — chrome declares a ChromeMotionAdapters interface and ships a NOOP implementation (NOOP_CHROME_ADAPTERS, confirmed in src/adapters/motion.tsx) so scroll-direction hide/reveal and hamburger/submenu animation degrade to fully-functional-but-unanimated when @wabbit/tome-motion isn't installed.
  • Footer is a Phase-2 mirror of header, not a parallel design — createFooterGlobal/FooterRenderer/footerVariantRegistry copy the header's factory/registry/renderer/slot shape exactly so a contributor who knows one knows the other, with overlayMode deliberately kept header-only since footers don't hide-on-scroll.
  • `createChromeLayer` replaces `initChrome` as the canonical entry point, with the old whole-Config-shape call kept as a deprecated alias — part of the platform-wide convergence onto createXLayer(config?) → { bundle } so every layer factory returns an explicit shape the consumer spreads into Payload config, rather than mutating config in place.
  • Navbar7's mobile↔desktop switch point is a Header-global field, not a code fork — header.desktopBreakpoint (Payload field "Desktop Breakpoint (px)", shown only when designVersion is '7') defaults to 768, matching the CSS module's original hardcoded 48em. Every existing Header doc renders unchanged. A consumer whose desktop nav needs more room (more items, wider CTA) raises it — e.g. 1024 — and both the CSS layout and the hover-vs-click dropdown behavior switch together at that value, computed in header/navbars/navbar7Breakpoint.ts. The non-default case emits one extra scoped inline <style> (deterministic from the field value, not from viewport state) rather than a client-only data-desktop/matchMedia flag, so first paint is correct in both SSR and CSR with nothing to hydrate.
  • The mobile action bar is a Header-global field rendered beside the navbar, not a per-variant feature — header.mobileActionBar goes through assembleHeaderFields, so all seven variants (and custom ones) get it without touching their components. It renders as a sibling of HeaderVisibilityFrame, because the frame's hide-on-scroll transform would otherwise become the containing block of a position: fixed bar. Its breakpoint follows the active navbar's own mobile↔desktop switch so the bar never sits under a desktop nav, and without JavaScript it stays visible on phones rather than vanishing, since the actions it carries (call, primary CTA) are the ones a phone visitor most needs.

Exports

Import path
@wabbit/tome-chrome
@wabbit/tome-chrome/header
@wabbit/tome-chrome/header/factory
@wabbit/tome-chrome/footer
@wabbit/tome-chrome/footer/factory
@wabbit/tome-chrome/registry
@wabbit/tome-chrome/adapters/motion

Changelog

0.31.xMinor

  • The header probe publishes --tome-chrome-visible-height: how much of the viewport top the chrome covers right now.

    It matches --tome-chrome-sticky-height while the header shows and drops to the announcement strip alone while an auto-hiding header is slid away (data-visible="false"), so sticky rails and columns can rise with the header instead of holding a gap for it. chromeVisibleHeight() is exported for tests and server code.

    Nothing changes for a page that doesn't read the new property.

0.30.xMinor

  • Headers can open with a "Skip to main content" link (opt-in), and the phone menu closes when a link in it is tapped.

    The skip link is opt-in with header.skipLink: any object, even an empty one, renders <a data-skip-link href="#main-content">Skip to main content</a> as the first focusable element, off-screen until it takes focus and then a solid chip at the top left; label and href change its text (translate it per locale) and target (the page's <main> needs that id). Unset, null or false renders nothing and the header's output is unchanged byte for byte, so a site that already renders its own skip link keeps it alone.

    The checkout bar never has one.

    The phone menu (every navbar variant) is now controlled: a tapped link, to this page or another, closes it, and for an in-page link focus moves to the section instead of back to the menu button, so the page is no longer scrolled behind a menu that stays open.

  • The phone menu button now says what it will do: while the menu is open it is named "Close navigation menu" (settable with triggerCloseLabel) and reports aria-expanded; a credential kind prints in sentence case ("License", not "license").

    The rename applies only to a trigger whose own name starts with "Open"; a trigger with another name keeps it, and a closed menu renders exactly as before.

2 fixes (0.30.1 – 0.30.2)
  • 0.30.2
    Footers are a real page landmark, and Footer 11's links are easier to tap on phones.

    Eight footer variants wrapped footer in a section, which stops browsers treating it as the page footer. The wrapper is now a div, with the same class and attributes.

    Footer 11 names its nav "Footer" so it doesn't duplicate the navbar's landmark, and below 48em each of its links gets a 44px row, which makes the footer taller there.

  • 0.30.1

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

0.29.xMinor

  • Header call: a visible number, an {opens} token and closed-state labels.

    All opt-in; a header that sets none of them renders byte-identical markup, held by a test.

    headerCall.showNumber prints the phone number as text, with the label in data-header-call-label and the number in data-header-call-number (off, the number stays screen-reader-only and neither hook is emitted). {opens} in a call label is filled with the next opening while closed ("Call when we open: Wed 9:00 am"); with no known opening the token's clause drops, as {until} does.

    headerCall.closedLabel, mobileActionBar.callClosedLabel and mobileActionBar.ctaClosedLabel replace a label while the desk is known to be closed. Only the label of the primary action changes, so it is never hidden while closed.

    The open snapshot gains an optional opens string, set only while closed.

    Schema: four new optional columns on the Header global (headerCall.showNumber, headerCall.closedLabel, mobileActionBar.callClosedLabel, mobileActionBar.ctaClosedLabel; the three labels are localized), so SQL sites need a migration. Mongo sites need none.

    Header CTA buttons in the desktop row are not hours-aware.

0.28.xMinor

  • Navbar 1 panel groups can be laid out as a row, and Navbars 1 to 4 honor the header's home label.

    Optional per-subitem groupLayout ('column' | 'row') on Navbar 1 grouped panels: the first subitem of a group that sets 'row' makes the group's wrapper carry data-nav-panel-layout="row" (absent otherwise, so default markup is unchanged) for themes to lay it out full width. Navbars 1 to 4 also stop hard-coding aria-label="Home" on the logo link and use header.homeLabel via getHomeLinkLabel.

    Unset, null or blank homeLabel still yields exactly "Home" (the logo alt is deliberately not used as a fallback in these four, so default markup is unchanged).

  • Header: set where the logo links to, and a different word on the phone menu button while it is open.

    Two optional header fields, both unset by default so existing headers render unchanged. homeHref sets the logo's home link (default "/"), read by every built-in navbar (1 to 7), which each hard-coded "/" before.

    mobileMenuCloseText (Navbar 1 and NavBar5, with mobileMenuLabel: 'word') is the word shown on the toggle while the menu is open, for example "Close"; the visible text stays the accessible name and aria-expanded still comes from the sheet trigger. MobileMenuWordTrigger gains a closeWord prop and _shared exports resolveHomeHref and resolveMenuCloseWord.

  • Navbar 1 can show a grouped Courses panel with columns and a footer link.

    Navbar 1 gains an opt-in grouped Courses panel. A sub item takes panelLayout: 'columns' and panelFooterLink; each subitem takes group, groupHref and featured.

    Hooks: div[data-nav-panel-columns], div[data-nav-panel-group], p[data-nav-panel-caption], li[data-nav-featured], a[data-nav-panel-footer], and data-nav-has-columns on the bar. The phone accordion carries the same groups.

    subitems.maxRows rises from 10 to 24. A sub item with no panelLayout renders exactly as before.

0.27.xMinor

  • The phone bar can now carry a second action, such as "Directions" beside "Book a visit".

    Two new Header fields, mobileActionBar.secondaryLabel and mobileActionBar.secondaryHref, add an outlined button between Call and the primary action. It shows only when both are filled and works with the phone left blank.

    With Call, the second action and the primary action together the bar becomes three columns, and labels wrap to two lines instead of truncating. Themes can target [data-action='secondary'], and data-actions now counts up to 3.

    Header docs that leave the new fields blank render exactly as before. On a SQL database the two fields are new nullable columns, so regenerate your migration.

    Also new, opt-in: mobileActionBar.showAtLoad (a checkbox, off by default). The bar normally slides in once the visitor scrolls past the first screen; with showAtLoad on it is visible from the first paint and stays, with no scroll listener running.

    MobileActionBar takes a matching showAtLoad prop, and resolveMobileActionBar returns showAtLoad: true only when it is on. Header docs that leave it off render exactly as before.

    On a SQL database it is one more column in the same migration.

0.26.xMinor

  • Navbar 1 gains an opt-in header.mobileBreakpoint option: '64em' (the default, 1024px) or '56.25em' (900px) sets the width at which the full bar replaces the "Menu" button.

    The option writes data-mobile-breakpoint="56.25em" on the section root and the matching CSS lives in chrome's own stylesheet; the Call button and the mobile action bar follow the same width. Unset, the markup and CSS behavior are byte-identical (pinned by the stored Navbar 1 snapshot).

    The admin field shows for Navbar 1 only. The phone menu's link list (Navbar 1 and Navbar 5) now carries data-mobile-menu-list, an additive attribute for themes that number or rule the links.

    Navbar 1's sheet has no free-text row, so there is no data-mobile-menu-text hook.

1 fix (0.26.1)
  • 0.26.1

    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.25.xMinor

  • Studio chrome options.

    All are opt-in; with each unset, every stored and default render of Navbar 1 and Footer 7 is byte-identical (stored snapshots prove it).

    • header.wordmarkText (Navbar 1): the site name as plain set type, span[data-header-wordmark], in place of the media logo inside the home link (desktop bar, phone bar and phone menu). A non-blank value wins over logo/logoDark; blank keeps the logo.
    • Navbar 1 honors header.mobileMenuLabel: 'word' and header.mobileMenuText: button[data-mobile-menu-trigger="word"], default text "Menu". It reuses Navbar 5's trigger (now the shared MobileMenuWordTrigger), so the two cannot drift.
    • Navbar 1 honors header.navAlignment: 'center' (the existing field, now also shown for Navbar 1): data-nav-align="center" on the root and the links on the true center of the bar from the desktop breakpoint up, in chrome's own CSS.
    • header.markCurrentPage (Navbar 1, new checkbox): top-level links for the current page or section get aria-current="page" and data-nav-current. Exact match, or a section prefix for a non-root href (/work matches /work/x); / matches only /; hash-only and external hrefs never match. Uses the pathname NavLink already reads.
    • footer.wordmarkText (Footer 7): the same text wordmark, span[data-footer-wordmark], in the brand column.
    • Admin: the new fields have labels and descriptions and show only for the variants they apply to. New exports: isCurrentSection.
    • footer.brandRows (Footer 7): text rows under the subline in the brand column, ul[data-footer-brand-rows] of li[data-footer-brand-row]. A row is text, a link, or text then the link after it (Visits by appointment · and a link "Send an inquiry"); a row with neither renders nothing, and with the option unset or empty the footer is byte-identical (the stored Footer 7 snapshot proves it). New type TomeFooterBrandRow; admin array field shown only for Footer 7.

0.24.xMinor

  • An hours-aware header and phone bar, two Navbar 1 opt-ins, and a phone bar that cannot push the page wide.

    A site that sets nothing computes what it did: every new field is off by default, and the default header markup is unchanged.

    • Open state. HeaderRenderer takes hours (a structural ChromeOpenStateSource, the shape of tome-local's OpenStateSource), computeOpenState (the site passes tome-local's getOpenState; chrome takes no tome-local dependency) and an optional openScheduleKey. A client island, OpenStateProvider, computes after mount, writes data-tome-open="true" | "false" on the document element and data-open-state="open" | "closed" on the header frame, and recomputes with one timer at the next transition (at most six hours out; no polling). The server render writes neither, so first paint is neutral and a site without hours never reads "closed". Pass the function from a client module. Exports: OpenStateProvider, useOpenState, startOpenStateController, formatUntil, resolveUntilLabel.
    • `headerCall.callWhen` and `mobileActionBar.callWhen`. always (default), open or closed. open hides the call only while the desk is known to be closed; closed shows it only then. Set values are written to data-call-when.
    • `{until}` in the call label. headerCall.label and mobileActionBar.callLabel may contain {until}, filled with the closing time while open ("Call us, open till {until}" reads "Call us, open till 7"). With no open state the token and its clause drop.
    • `ctaOverride`. HeaderRenderer prop { label, href } replaces the phone bar's primary action for one page.
    • Navbar 1 `headerCall.showOnDesktop`. Also renders the header call in the desktop action group, before the header buttons. Copies carry data-header-call-placement="desktop" | "bar" when it is on.
    • Navbar 1 `header.reportScrolled`. Navbar 1 reports the scrolled state (page past 20px) as the frame's data-nav-scrolled, as Navbar 7 does. useReportNavScrolled(scrolled, enabled) gains an enabled argument (default true) and useWindowScrolled(enabled) is exported.
    • `--tome-navbar1-pad-block`. The header's block padding, padding-block: var(--tome-navbar1-pad-block, 8rem). The default is the old 8rem on both sides; a theme with a compact bar sets it (Roster's 68px bar is 12px).
    • The phone bar wraps instead of overflowing. .mobileTopBar may wrap and its logo and right groups may shrink (min-width: 0), so the wordmark, the call pill and the menu button drop the right group under the logo at a width too narrow for all three, rather than overflowing the page. At any width that fits it is the same single row.
    • Phone-width tokens (320 to 389px). From 389.98px down, and again below 360px, Navbar 1 reads --tome-navbar1-phone-wrap (wrap), --tome-navbar1-phone-gap, --tome-navbar1-phone-right-gap and --tome-navbar1-phone-trigger-pad (0.5rem), with -narrow variants of the last three. Each falls back to the value it had, so a theme whose bar must stay one row sets nowrap and tightens the gaps.

    The Header global gains headerCall.callWhen, headerCall.showOnDesktop, mobileActionBar.callWhen and reportScrolled (none has a stored default): run your Payload migration and regenerate types.

0.23.xMinor

  • Chrome gains a cart control seam, a reduced checkout mode, numbered nav items and a bar-padding token.

    All four are opt-in, and the default output of every navbar and footer is byte-identical (pinned by stored pre-change snapshots).

    cartSlot (HeaderRenderer prop, navbars 1 to 5 and 7) is a node, or a render function called per place, rendered after accountSlot on desktop and in the mobile menu, each copy inside span[data-cart-slot="desktop|drawer|bar"]. On Navbar 5, cartSlotPhone: 'bar' puts it in the phone bar beside the menu button instead of in the menu.

    Types: TomeCartSlot, TomeCartSlotContext; helpers renderCartSlot and withCartSlot.

    chromeMode: 'checkout' on HeaderRenderer and FooterRenderer swaps in a reduced chrome. The header shows the logo (not linked), a label (checkoutChrome.label, default "Secure checkout") and a back link (checkoutChrome.back), and drops navigation, search, CTAs, account, cart, the announcement strip and the mobile action bar.

    The footer keeps finePrint, the copyright line and the legal links, so a credits line shows wherever the pictures do. Both roots carry data-chrome-mode="checkout"; the pieces are also exported as CheckoutHeaderBar and CheckoutFooter.

    TomeNavItem.prefix (link and sub items, new admin field shown for design 5) renders span[data-nav-prefix] (decorative, aria-hidden) before the label in Navbar 5's bar and phone menu.

    Navbar 5's section block padding is now var(--tome-navbar5-pad-block, 1rem), so a theme can set the token to 0 and let its own min-height own the bar's height (the 73px header against 77px).

    The checkout header honors header.logoPhone below 40em, as Navbar 5 does: the full logo and the phone logo sit in data-logo-full and data-logo-phone wrappers and the stylesheet swaps them. With logoPhone unset the checkout bar's markup is unchanged (pinned by a stored snapshot).

    Polish: Footer 7 renders its bottom bar (data-footer-bottom) only when the footer has a copyright, legal links or the cookie-settings trigger. A footer with none of them used to render an empty bar that still carried its margin, rule and padding (about 41px of dead space).

    Footers with content render the same bytes as before (pinned by stored snapshots); the only markup that changes is the empty bar, which is gone.

    Phone bar order: a new Navbar 5 header field cartSlotPhoneOrder ('before-menu' default, 'after-menu'), used with cartSlotPhone: 'bar'. 'after-menu' renders the menu trigger first and the bar cart slot after it in the DOM, so keyboard focus follows the visual order (a CSS order would not).

    The default is today's markup, byte-identical (pinned by the stored snapshots); other navbars ignore the field.

  • Navbar 5 gains an opt-in centered navigation and reports its scrolled state, so a theme can center the links and restyle the bar on scroll.

    Setting the existing Header field navAlignment to center on a Navbar 5 header (the field now shows for designs 4 and 5) puts the links on the true center of the bar from the desktop breakpoint up, whatever the widths of the logo and the actions, and writes data-nav-align="center" on the section root. With it on, the navbar also reports whether the page has scrolled past 20px, and the header frame carries data-nav-scrolled="true|false".

    Left at the default there is no attribute, no scroll listener and no change to the markup. useReportNavScrolled(scrolled, enabled?) takes an optional second argument for a navbar that opts in conditionally.

    Navbar 5 also gains an opt-in phone menu button. The Header field mobileMenuLabel (icon by default, or word) swaps the hamburger icon for a text button, button[data-mobile-menu-trigger="word"], whose visible word (the localised mobileMenuText, default "Menu") is its accessible name and which opens the same sheet.

    Left at icon the markup is unchanged.

    Navbar 5's phone layout no longer overflows: the phone action group (search, call, cart, Menu) shrinks and wraps, and the logo group can shrink (the logo keeps its aspect ratio inside its fixed height) instead of pushing the bar past the viewport (102px at 390 with a wide wordmark). Opt-in phone lockup: the new Header field logoPhone (an upload, Navbar 5 only) is a phone-width logo, such as the wordmark alone.

    When set, the home link holds span[data-logo-full] (the main logo) and span[data-logo-phone], and below 40em only the phone one shows. Unset, the markup is byte-identical.

0.22.xMinor

  • Navbar 4 gains opt-in centered navigation, an underline open-menu indicator and an accordion mobile menu, and its mobile sheet now locks page scroll and manages focus.

    New Header global fields, all shown only for Navbar 4 and defaulting to the current look, so existing headers render unchanged: navAlignment (between | center) centers the top-level nav on the true center of the bar from the desktop breakpoint up, with the desktop search button kept in the end column; menuOpenIndicator (fill | underline) replaces the accent fill on an open or hovered item with a 2px accent bar at the bottom edge of the header, for mega triggers, dropdown triggers and the current page's link; mobileMenuStyle (drillIn | accordion) expands submenus inline in the mobile sheet with compact simpleLinks and featuredImage blocks (BlockRenderer takes a new compact prop), a pinned footer holding the account, language, theme and CTA area, and the copyright at the end of the list. ctaVisibleBelowDesktop now also applies to Navbar 4: the CTA buttons show in the bar on phones too, and with the accordion they hide while the sheet is open since the pinned footer repeats them.

    The three new fields add database columns: after upgrading, run your Payload migration and regenerate types.

    Two optional tokens, --tome-chrome-nav-item-size and --tome-chrome-nav-item-weight, set one size and one weight for every top-level desktop item, links and triggers alike; unset, each keeps its current value.

    Accessibility fixes for every Navbar 4 mobile sheet (both styles): page scroll is locked while it is open and the previous overflow restored on close; the sheet sizes to the dynamic viewport below the measured header height (--site-header-bar-height, falling back to 4.5rem) and scrolls with overflow-y: auto; focus moves into the sheet on open, Tab and Shift+Tab stay inside the toggle and the sheet, and Escape closes it and returns focus to the toggle. In drill-in mode, focus moves to the submenu heading on entry and back to the opening row on "Go back".

1 fix (0.22.1)
  • 0.22.1
    navbar4: plain top-level links get the same side padding as dropdown triggers, so links and triggers space evenly.

    New --tome-chrome-nav-item-pad-x (default --tome-space-lg) sets both.

0.21.xMinor

  • New-tab header and footer links are now announced, and Navbar 7 can keep its CTA in the bar at tablet widths through the new header.ctaVisibleBelowDesktop setting.
    • NavLink (so every nav link, footer link and header CTA button) adds data-new-tab and a visually hidden " (opens in a new tab)" when link.newTab is set. A caller-supplied aria-label gets the phrase appended instead. Other links are unchanged.
    • header.ctaVisibleBelowDesktop (Header global checkbox, Navbar 7 only, default off) keeps the CTA buttons beside the menu button from 768px up to desktopBreakpoint instead of folding them into the drawer, and hides their drawer copy at those widths. Phones keep the CTA in the drawer. With it off, or unset, the header renders exactly as before.

    Adding the checkbox adds a nullable column to the Header global, so run your Payload migration after upgrading.

0.20.xMinor

  • The header Call action and the mobile action bar can now dial internationally: pass defaultCallingCode to HeaderRenderer and a typed national number becomes an E.164 tel: link.

    <HeaderRenderer defaultCallingCode="1" /> turns (816) 555-0118 into tel:+18165550118; a number typed with a leading + is unchanged, and leaving the prop unset keeps today's digits-only hrefs. It is a code-level site option, not an admin field.

    toTelHref, resolveHeaderCall and resolveMobileActionBar take an optional { defaultCallingCode } last argument, and HeaderCallingCodeProvider / useHeaderCallingCode carry the value to HeaderCallButton (now a client module, like the other context readers). Extensions typed as ext. 12 now produce ;ext=12 instead of the digits run together.

    Requires @wabbit/tome-core 1.22.0 or later.

0.19.xMinor

  • Footers: "Cookie Settings" renders only when the footer's new cookieSettings field is on.

    Every built-in footer (1 to 11) rendered the button unconditionally, and it only dispatches an open-cookie-settings window event, so with no consent manager mounted (no current consumer mounts one) it was a dead control. The Footer global gains a cookieSettings checkbox (default off); showsCookieSettings(footer) is exported from the footer shared helpers.

    Sites that do mount a consent manager turn the field on. Adding the field to the Footer global needs a schema migration on Postgres consumers.

  • MobileActionBar: no hydration mismatch under reduced motion.

    data-motion came from the motion adapter's useReducedMotion() on the first render, which reads matchMedia only in the browser (framer's returns null on the server), so the server rendered full and a reduced-motion client reduced. The attribute is now written only after mount; before hydration the stylesheet's prefers-reduced-motion query already drops the slide.

0.18.xMinor

  • Footer text rows can be bold and can group, and Footer 7's fine print can be inset.
    • `emphasis` (text rows): renders the line, or a label/value pair's label, in <strong data-footer-text-strong>, e.g. an office name.
    • `groupWithPrevious` (text rows): the row continues the text row above with no list gap. Footer rows sit a full list gap apart, so an office's name and address lines didn't read as one group. Each group renders as one list item, <span data-footer-row-group> holding its rows stacked with --tome-footer-row-group-gap (default 0.125rem), in every built-in footer. A continuing row first in a column or after a link row starts its own item. New export groupFooterRows() does the folding for a custom variant (FooterColumnRow takes the group's rows as continuation).
    • `--tome-footer-fine-print-inset` (Footer 7): a start indent on the fine-print block (default 0), so a theme can align it to a column.

    Rows that set neither flag render exactly as before. Both fields are new optional checkboxes on the footer global's column rows; on Postgres, generate a migration.

0.17.xMinor

  • Header announcement slot, a static header option and a footer bottom-bar hook.

    The header options are optional, and existing headers render byte-identically when they are unset.

    Migration: `headerPosition` is a new Header global field and adds a column. Sites that register the Header global must run their Payload migration (payload migrate:create, then payload migrate) and regenerate types (payload generate:types) after upgrading. The field has no default, so existing rows stay empty and keep today's sticky header with unchanged markup.

    announcementSlot is a renderer prop only and needs no migration.

    • `announcementSlot?: ReactNode` on `HeaderRenderer`. A code-level strip (for example a live open-now status bar or a notice) rendered immediately before the header frame, outside it, in <div data-chrome-announcement>. The wrapper is sticky at top: 0 with z-index: var(--tome-chrome-announcement-z), defaulting to the frame's --tome-chrome-frame-z. It adds no role or tab stop, and an empty strip is hidden. A pinned frame (sticky, or fixed in overlay mode) and Navbar 7's fixed <nav> take top: var(--tome-announcement-height), so they sit under the strip. The CMS announcementBar surface is unchanged: it is still Phase 3 and accepts only false; when it ships it renders into this position.
    • `headerPosition: 'sticky' | 'static'` (Header global field "Header Position"; empty means sticky). 'static' keeps the header at the top of the page so it scrolls away: the frame becomes position: relative (absolute in overlay mode) and keeps its z-index so menus still open above the page, and hide-on-scroll is off. With an announcement slot, only the strip stays pinned. A set value is written to data-header-position on the frame, beside data-nav-theme and data-nav-scrolled. Navbar 7 positions its own <nav>, so it stays pinned either way.
    • Two more height variables from the header probe, on :root: --tome-announcement-height (the strip's measured height, 0px without one) and --tome-chrome-sticky-height (the strip plus a sticky or fixed frame). Use the latter for scroll-margin-top so in-page anchors land below the pinned chrome. --site-header-height is 0px for a static header, which scrolls away. New exports: chromeStickyHeight(), resolveHeaderPosition() and the TomeHeaderPosition type.
    • Footer `data-footer-bottom` (the counterpart to data-footer-top): an empty attribute on the bar that holds the copyright and legal links in footers 2–9 and 11, and on the legal-links row in footer 10, which has no copyright line. Footer 1 has no bar element, so it has no hook. This change only adds the attribute; the rest of the footer markup is unchanged.
1 fix (0.17.1)
  • 0.17.1
    Fix: Navbar 4 menus now close when you follow a link in them, and the mobile menu animates open and closed.
    • Menus close on navigation. The header usually sits in a layout that survives client-side navigation, so an open menu stayed open on the new page. Following any link in a mega panel or the mobile sheet now closes it. A layout: 'dropdown' submenu, which opens on hover and focus, now blurs the clicked link and stays hidden until the pointer leaves it or focus comes back, instead of staying open under the cursor. Its trigger's aria-expanded follows.
    • Mobile sheet motion. The sheet stays mounted and fades and slides in and out, keyed off data-state="open" | "closed". When closed it is inert, so it is out of the tab order and the accessibility tree. With reduced motion it fades without the slide. Escape now closes it.
    • Hamburger icon cross-fades to the close icon with a quarter turn. The toggle carries data-open while the sheet is open.

0.16.xMinor

  • Counsel v1 chrome and forms hooks.

    Every new field is optional, and existing output is unchanged when it is empty.

    Migration: the new Payload fields add columns and tables. Sites that register the Footer global, the forms collection or the tomeForm block must run their Payload migration (payload migrate:create, then payload migrate) and regenerate types (payload generate:types) after upgrading. The footer column-row link group becomes conditional (hidden on text rows), so its columns become nullable in that migration; rowType defaults to link, so existing rows stay links.

    @wabbit/tome-chrome

    • Navbar 7 `data-nav-scrolled`. Navbar 7's existing isScrolled state (page scrolled past 20px) is exposed as data-nav-scrolled="true" / "false" on its <nav> root ("false" at rest and on the server render) and mirrored onto the header frame (the element carrying data-visible / data-overlay / data-nav-theme) once the navbar hydrates. Frames around the other navbars never carry it. Custom navbars can opt in with the new useReportNavScrolled(isScrolled) export.
    • Footer `finePrint` (array of { lead?, text }, admin-visible for designVersion 7): small-type paragraphs Footer 7 renders below the link columns and above the bottom bar as [data-footer-fine-print], each lead in [data-footer-fine-print-lead]. Tokens --tome-footer-fine-print-size (default 0.75rem) and --tome-footer-fine-print-measure (default 72ch). Exported as FooterFinePrint for other variants.
    • Plain-text column rows. navItems[].subNavItems[] gains rowType (link default | text), text and an optional value. A text row renders <span data-footer-text-row>; with a value it is a label/value pair, <dl data-footer-text-row data-footer-pair><dt>…</dt><dd>…</dd></dl> (Footer 7: label column at least --tome-footer-pair-label-min, default 5.5rem). All eleven footers render both row types through the new shared FooterColumnRow; link rows render byte-identically. New types: TomeFooterColumnRow, TomeFooterLinkRow, TomeFooterTextRow, TomeFooterFinePrintParagraph.
    • Navbar 7 theme tokens: --tome-header-pad-block (header row block padding, default 2rem, mobile bottom half of it), --tome-header-curtain-radius (default 1.5rem) and --tome-header-curtain-shadow (default the previous shadow). Computed styles are unchanged when they are unset. The curtain radius moves from an inline style to the stylesheet, so Navbar 7's curtain style attribute no longer carries border-bottom-*-radius, and a non-default desktopBreakpoint override writes the padding through the same token.
    • Navbar 7 curtain fill and blur tokens: --tome-header-curtain-bg (the solid curtain's fill when the nav background is transparent, default the previous 80% page ground) and --tome-header-curtain-blur (its backdrop blur, default 12px, also used by the token-background states). Computed styles are unchanged when they are unset.
    • `aria-current="page"` on any NavLink (every navbar and footer) that points at the current pathname (root-relative, trailing slash and query ignored, #fragment and external links excluded). Links to the current page gain the attribute, an intended markup change; a caller's own aria-current wins.
    • `header.menuLabel` (text, localized, admin-visible for Navbar 7): optional visible text in the menu button beside the icon, [data-menu-label]. When set it is the button's accessible name; unset keeps the icon-only button and its "Toggle menu" name.

    @wabbit/tome-forms

    • `appearance.stepsDisplay: 'wizard' | 'stacked'` (default wizard, today's behavior). Stacked renders every step as a numbered <fieldset> (<legend data-form-step-legend> with <span data-form-step-number>), one submit button, and validates every visible step on submit; steps a skipStep rule skips stay hidden and unvalidated, and steps after the isFinalStep step are not shown. Root hook data-form-steps="stacked" on the <section>. defineForm rejects other values.
    • `appearance.submitNote`: a line beside the submit button, [data-form-submit-note].
    • Both are on the code appearance config, the admin forms collection's Appearance group, and the tomeForm block's appearance group (no block default, so an empty option keeps the form's own setting).
    • `tomeForm` block `fieldDefaults[] { name, value }`: per-placement starting values (e.g. a practice page preselects the matter type), converted for the field type and applied over the definition's defaultValues and under a restored draft, so the visitor's own input always wins.
    • `TomeFormBlock` (@wabbit/tome-forms/blocks): the tomeForm block renderer. It forwards fieldDefaults and only the block's stepsDisplay / submitNote when set, so a site gets the stacked intake just by setting the block options. The block's older appearance options (layout, progressIndicator, themeOverride) were never forwarded and still are not, so a block without the new options renders byte-identically. TomeForm itself gains appearance and fieldDefaults props for direct callers; without them it renders exactly as before. Also exported: pickBlockAppearance, mergeAppearance.
    • Starting values are server-rendered. A field's defaultValue and the placement's fieldDefaults are written into the initial HTML (<option selected>, value, checked), so there is no placeholder flash before hydration. Fields without a starting value render unchanged; forms whose fields declare a defaultValue now show it in the server HTML.
    • A saved `localStorage` draft is now restored right after mount (with reset()), instead of during the first client render. The first client render now matches the server HTML, where the draft read caused a hydration mismatch before. The draft still wins over starting values.
    • BREAKING: `react-hook-form` peer floor raised from `>=7.0.0` to `>=7.60.0` (devDependency ^7.60.0). The draft restore calls reset(values, { keepFieldsRef: true }), and 7.60.0 is the first release whose reset honors keepFieldsRef (absent from the 7.59.0 types and runtime). Sites on an older react-hook-form must upgrade it.

0.15.xMinor

  • Groundwork v1.1 chrome batch.
    • Fix: footer `backgroundColor` options now paint declared tokens. getFooterBackgroundStyle interpolated the option name, so muted resolved to --tome-color-muted / --tome-color-muted-foreground and card to --tome-color-card, none of which tome-ui declares, and every brand option's ink used a -foreground name that does not exist at Layer 2. Each option now maps to its declared pair: muted → --tome-color-surface-muted / --tome-color-on-surface-muted, card → --tome-color-surface / --tome-color-on-surface, primary/secondary/accent → --tome-color-{role} / --tome-color-on-{role}. An unknown value now renders transparent with inherited ink. Applies to every footer variant (they all share the helper).
    • Fix: header `backgroundColor` / `backgroundEffect` now paint declared tokens. getHeaderBackgroundColor mapped muted/card to the undeclared --tome-color-muted/--tome-color-card and built translucent/glass from nonexistent --tome-color-*-hsl companions. It now maps muted → --tome-color-surface-muted, card → --tome-color-surface, and composes translucent (70%) and glass (50%) with color-mix(in oklch, var(<fill>) N%, transparent), so no extra tokens are needed. Covers navbars 1, 3, 4 (including the mega-menu surface), 5 and 6. An unknown token now renders the flat background.
    • `data-footer-top` hook on the top row (brand/intro above or beside the link columns) of footers 1, 2, 4, 6, 7, 8, 9, 10 and 11.
    • Header Call button (`headerCall` group: `enabled`, `label`, `phone`). A compact tel: action with a phone icon and a "Call" label beside the hamburger in all seven navbars, hidden from the navbar's desktop breakpoint up; the menu button stays. The phone falls back to mobileActionBar.phone. Hook: [data-header-call]; tokens --tome-header-call-*. Exports HeaderCallButton and resolveHeaderCall. Off by default; adds columns, so run your Payload migration and regenerate types.
    • Header CTA `inline` appearance renders as a plain text link (no button chrome, the header's link color, the shared focus ring) instead of falling back to filled. New optional hook --tome-header-cta-inline-fg.
    • Fix: Navbars 5, 6 and 7 key list items by position when a nav item has no `id`. TomeNavItem.id is optional (code-defined menus have none), so those navbars logged React's duplicate-key warning; they now fall back to the item index, as Navbars 1 and 2 already did.

0.14.xMinor

  • The header publishes its real height in every mode.

    SiteHeaderHeightProbe now writes --site-header-bar-height on :root alongside --site-header-height. The existing variable is unchanged: it is the flow space the header takes, so it reads 0px in overlay mode.

    The new one is the bar's rendered height even when the header floats, so a hero under a floating header can keep its first line clear of the bar on a short screen (padding-top: max(designed, calc(var(--site-header-bar-height, 4.5rem) + gap))). The calculation is exported as headerHeights().

    Nothing renders differently until a site reads the new variable.

1 fix (0.14.1)
  • 0.14.1
    Header links to /#id now jump to that section on the current page when the page has it.

    A "Start a project" button pointing at /#scope goes to the page's own #scope form when one exists, and to /#scope otherwise. The link is re-evaluated after mount and on every route change; server markup is unchanged.

0.13.xMinor

  • Header CTA buttons look like buttons.

    Until now the Header global's buttons rendered as bare text with padding (no fill, no border), so a site's main header action read as another nav link. They now render filled by default (--tome-color-primary / --tome-color-on-primary) and bordered when the link's appearance is outline, with a 2.75rem minimum height (44px tap target) and a focus ring.

    • New theme hooks, all optional and prefixed --tome-header-cta: -bg, -fg, -radius, -font, -size, -weight, -tracking, -case, -padding, -min-height. The type defaults to the header's own.
    • The variant is applied as a class as well as data-appearance, so it survives a LinkComponent that doesn't forward data attributes.

    Visible change: any site with header buttons sees them filled in its primary color after upgrading. Set --tome-header-cta-bg / -fg to keep a different pair, or give the button appearance: 'outline'.

1 fix (0.13.1)
  • 0.13.1
    Header CTA button text keeps its color under a site's link reset.

    The filled and outline rules were single-class selectors, so a consumer rule such as :root a { color: inherit } won and the label took the header's text color (cream on gold on wabbit.com). The variant rules are now .cta.filled / .cta.outline.

    No change where no such reset exists.

0.12.xMinor

  • Navbar 7 now honors a LogoComponent override, and footers gain --tome-footer-pad-block, --tome-footer-nav-cols and a data-footer-nav hook.

    Navbar 7 used to render its stacked logo/logoDark images even when HeaderRenderer received a LogoComponent. The override now replaces them inside a [data-navbar-logo="component"] wrapper that inherits the bar's text color, and receives isDarkModeEnabled: true whenever the bar wants its light-on-dark logo.

    Without an override, the navTheme image behavior is unchanged.

    Footers 1–5, 7 and 8 read their block padding from --tome-footer-pad-block (default 8rem). Footer 7's link grid carries data-footer-nav and reads its column count from --tome-footer-nav-cols (default 3).

    The defaults match the previous fixed values, so nothing changes until a theme sets them.

1 fix (0.12.1)
  • 0.12.1
    Navbar open state for assistive tech.

    The Navbar4 layout: 'dropdown' trigger and the Navbar2 sub-menu trigger now expose aria-expanded (mirroring the CSS :hover / :focus-within that opens the flyout, via a new useFlyoutState helper) and aria-controls pointing at the panel. The Navbar4 hamburger exposes aria-expanded and aria-controls for the mobile sheet.

    No visual change. Known gap: Navbar2's sub-menu trigger is still a non-focusable <span>, so keyboard users can't open it; turning it into a button needs a styling pass.

0.11.xMinor

  • The Header global gains an optional mobileActionBar group: a phone-width bar fixed to the bottom of the screen with a Call (tel:) action and one primary CTA, available on every navbar variant.

    Migration: this adds fields to the Header global, so run your Payload migration (payload migrate:create, then payload migrate on SQL databases) and regenerate your Payload types after upgrading. Nothing renders until an editor turns it on: mobileActionBar.enabled defaults to false, and Header documents without the group render byte-identically.

    The group holds enabled, callLabel (localized; blank means "Call"), phone (written for people, normalized to a tel: link), ctaLabel (localized) and ctaHref; each action renders only when its fields are complete. HeaderRenderer mounts the bar beside the navbar, with no new props.

    It shows only below the active navbar's desktop breakpoint (64em for navbars 1, 3, 4, 5 and custom variants, 48em for 2 and 6, desktopBreakpoint for 7), slides in once the page has scrolled past about the first viewport and hides again near the top, respects safe-area insets, adds its height to the body's bottom padding while visible (also published as --tome-mobile-action-bar-offset), and is inert while hidden. Under reduced motion it appears without the slide; without JavaScript it is always visible on phones.

    Theme hooks: [data-tome-mobile-action-bar], [data-action="call"], [data-action="cta"] and --tome-mobile-action-bar-* custom properties. New exports from ./header: MobileActionBar, resolveMobileActionBar, resolveHeaderDesktopBreakpointPx, toTelHref; new type TomeMobileActionBarData.

  • The Header global gains a navTheme option ("Transparent Header Contrast") so Navbar7's transparent at-rest state can sit over a light hero, and every built-in footer now carries data-footer-variant.

    Migration: navTheme is a new Header global field, so run your Payload migration (payload migrate:create, then payload migrate on SQL databases) and regenerate your Payload types after upgrading. It defaults to 'auto', which keeps the existing look.

    navTheme is 'auto' (default: light text and logo for a dark hero, as before), 'light' (dark text and the main logo for a light hero) or 'dark'. Navbar7 applies it to its transparent at-rest state; the scrolled and open solid state still follows the Background Color tokens.

    The logo swap uses the existing logo / logoDark pair, with the CSS-inverted logo as the fallback when no logoDark is set. Every variant writes the resolved value to data-nav-theme on the header frame for theme CSS.

    New type TomeNavTheme.

    Footers: each built-in footer writes data-footer-variant="<key>" on its root element, using the key FooterRenderer resolved (the fallback variant's key when designVersion is not registered), so themes can target a footer design without relying on the landmark element. TomeFooterVariantProps gains an optional variantKey, which FooterRenderer passes to custom variants so they can emit the same attribute.

0.10.xMinor

  • Header navbars accept an optional accountSlot for a Log in / Account control, and Navbar7 can now show the search, language and theme controls when showHeaderActions is on.

    accountSlot?: ReactNode | ((ctx: 'desktop' | 'drawer') => ReactNode) is new on HeaderRenderer and TomeNavbarProps (new exported types TomeAccountSlot and TomeAccountSlotContext). A node renders in both places; a function is called once per place so the desktop and drawer copies can differ.

    Navbar7 renders it immediately before the CTA buttons in its desktop right group and in its drawer button row, which becomes a row of equal-width cells only when the slot is provided. Navbars 1 to 5 render it after the theme toggle and before the CTAs on desktop and in their mobile menus.

    Navbar6 does not render it. Nothing changes when the prop is absent: existing markup is byte-identical, and the wrapper adds no role or tab stop.

    showHeaderActions?: boolean (default false) is new on HeaderRenderer and TomeNavbarProps; navbars 1 to 5 already render searchAdapter, languageSwitcherSlot and themeToggleSlot, but Navbar7 ignored them, and sites that already pass them stay unchanged until they opt in. When on, Navbar7's desktop group reads search, language, theme, account slot, CTAs; in the mobile drawer the language and theme controls sit in a row above the account/CTA row and search sits beside the hamburger.

    The controls inherit Navbar7's text color and the search trigger is 44px square. It is a prop, not a Header global field, so there is no schema change or migration.

    Other navbars ignore it. A function value cannot cross a server-to-client boundary, so pass a node from a server layout.

1 fix (0.10.1)
  • 0.10.1
    The Navbar7 mobile menu now gives the account control and the CTA button truly equal widths.

    The drawer row used flex: 1 1 0, so a cell's padding and border still counted toward its base size and a padded account control rendered wider than the CTA cell. The row is now a grid of equal columns (grid-auto-columns: minmax(0, 1fr)).

    Markup is unchanged.

0.9.xMinor

  • Export Footer11 (the Ledger footer) from ./footer and the root barrel.

    It has been registered as variant key '11' in footerVariantRegistry since it landed, so it was selectable in the Footer global but could not be imported by a consumer — README, package description and source comments all still said 10 footers. Now 11 everywhere.

  • 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.

  • One LayerFactoryConfig every layer factory's config extends, and one factory verb.

    Fourteen layer packages end in the same one call a consumer writes into payload.config.ts, and no two agreed on what config may contain: full seam vocabulary in three (org, lms, ledger), partial in six, NONE in six (2026-09-01 sale-readiness audit §5.3). A site that learned adminGroup from org and hooks from sc discovered, package by package, that six factories accept neither — not because the seam had been rejected, but because nothing said it existed.

    New in core (a NEW exports-map subpath, hence the minor): @wabbit/tome-core/utilities/layerFactoryConfig exports the LayerFactoryConfig interface — adminGroup, access (per-collection override map), hooks (appended via mergeHooks, never replacing), extraFields, fieldOverrides, omitFields, fieldOrder, slugs — and applyLayerFactoryConfig(collections, config), which honors the whole vocabulary in one call and one fixed order (adminGroup → access → hooks → field shape, the last delegated to fields/fieldShape's applyFieldShape so the order cannot drift between layers). Pure: new array, new objects, identity return on an empty config.

    It is a separate subpath from ./utilities/layerRegistry deliberately — that module is in core's sideEffects array, and a pure type/vocabulary module should not drag a declared side-effecting module into every factory's type graph.

    The slug convention is documented rather than forced, because both live shapes are right for what they do: a typed slugs?: Partial<XSlugs> map for the slugs a layer OWNS (org, sc, accounts — the typed key set makes a typo a compile error, and a homomorphic mapped type satisfies the base's Record<string, string | undefined>), and named <name>Slug?: string scalars for relationship targets in OTHER layers (memberSlug, mediaSlug, eventSlug, rolesSlug) — those are pointers out of a layer, not entries in its key set.

    *Every `createLayer config now extends it.** Twelve extend LayerFactoryConfig directly and APPLY it through applyLayerFactoryConfig (accounts, catalog, crm, crowdfund, deals, fulfillment, lms, marketing, org, sc) or through a targeted application (chrome). Additive in every case: for the six that accepted none of the seams (deals, economy, gamification, marketing, plus forms/intake, see below), the fields are new; for the rest, adminGroup` and friends keep their existing meaning and the applier is a no-op when they are omitted.

    Two packages accept the vocabulary but do NOT yet apply it, and say so in their type's JSDoc in the required form ("accepted, not yet applied — trigger: …"). economy and gamification both declare @wabbit/tome-core as an OPTIONAL peer and hold zero runtime imports of it — gamification reaches registerLayer through a lazy require() in a try/catch for exactly this reason.

    applyLayerFactoryConfig is a runtime VALUE, so importing it at module scope would convert an optional peer into a required one and break every site that installs those packages without core; copying the applier locally is barred by assert:no-forked-primitives. The trigger is stated: the day core becomes a required peer, delete the note and add one line.

    Both take the type via import type, which is erased at runtime.

    Two packages drop seams EXPLICITLY rather than accept-and-ignore. chrome extends Omit<LayerFactoryConfig, 'access' | 'hooks' | 'extraFields' | 'fieldOverrides' | 'omitFields' | 'fieldOrder' | 'slugs'> because it returns Payload GLOBALS, not collections — those seven are keyed by collection slug and typed against CollectionConfig, and chrome's slugs already have direct per-surface knobs (header.slug, footer.slug) a parallel map could contradict.

    The one seam it keeps, adminGroup, IS applied: globals carry admin.group exactly as collections do. rpg extends Omit<LayerFactoryConfig, 'access'> because CharacterSheetsConfig is a single collection's config that doubles as the layer factory's config, and its own access already means "this collection's access object" — one level shallower than the base's slug-keyed map.

    Two meanings under one name is the confusion this interface exists to end.

    Factory-verb convergence. Three verbs were live. createWorkflowLayer(config?) is new in @wabbit/tome-workflow (a new export — hence the minor) and returns a spreadable, deliberately EMPTY CollectionConfig[]: this layer is an engine, not a collection set, so the empty array is the honest answer and lets ...createWorkflowLayer() compose exactly like every sibling.

    Its WorkflowLayerConfig omits every seam for the same reason, and exists as the stable place a real option will land. createGamificationLayer and createRpgLayer are pure aliases of registerGamificationLayer / registerRpgLayer.

    initWorkflow, registerGamificationLayer and registerRpgLayer are all @deprecated with sunset at each package's next major; none is removed.

    Forcing function: scripts/assert-layer-factory-contract.mjs + pnpm assert:layer-factory-contract, wired into platform-discipline.yml after assert:layer-version (source reading only, pre-build). Every exported create*Layer must take a config parameter whose type resolves to LayerFactoryConfig — through extends, an intersection, or an explicit Omit<…> — with verb aliases followed to their register*/init* target.

    Before this change it reported 12 violations and 0 conforming; it now reports 15 conforming, 0 violations. Deliberately NOT checked: whether a factory actually applies what it accepts, because a machine cannot tell a documented deferral from an accident, and a gate that forced silent application would be worse than one that forces a stated deferral. docs/guides/create-a-new-layer-package.md gains a "The factory contract" section stating the rule and the three permitted responses.

    Three factories are ALLOWLISTED with a reason each: createAiLayer returns credential wiring and owns no collections, so every seam is meaningless to it; createFormsLayer and createIntakeLayer are owned by the forms+intake access wave running in parallel, whose changes rewrite the same files.

    Peer floors: accounts, catalog, chrome, crm, deals, economy, fulfillment, gamification, marketing and rpg raise @wabbit/tome-core to >=1.14.0 <2.0.0. The new subpaths do not exist below that, and a too-low floor is how ERR_PACKAGE_PATH_NOT_EXPORTED reached crowdfund's consumers once already.

    These are marked patch because the config widening is purely additive; the raised required-peer floor is the reason a release manager may prefer to cut them as minors instead.

  • Pin each layer's registered version to package.json instead of a hand-typed literal.

    registerLayer(name, { version }) is the contract a consumer reads back through hasLayer/getLayer to gate on a layer's capability. Eight packages passed a literal that nobody compared to the manifest, so an up-to-date install advertised an old contract and every gate keyed on it failed silently — nothing throws when a version string is stale.

    PackageRegisteredActual
    @wabbit/tome-rpg'0.1.2'0.2.2
    @wabbit/tome-gamification'0.1.0'0.3.1
    @wabbit/tome-crm'0.3.0'0.5.0
    @wabbit/tome-ai'0.1.0'0.4.0
    @wabbit/tome-formsTOME_FORMS_VERSION = '0.1.0'0.3.2
    @wabbit/tome-intakeTOME_INTAKE_VERSION = '0.1.0'0.3.1
    @wabbit/tome-marketing'0.1.0'0.4.0
    @wabbit/tome-chrome'0.6.0'0.8.5

    Each package now carries a leaf src/version.ts exporting <NAME>_LAYER_VERSION, read by its registerLayer call — the shape nine sibling packages (accounts, catalog, crowdfund, deals, economy, fulfillment, ledger, lms, org, workflow) already used and stayed accurate with. Forms' and intake's module-local TOME_*_VERSION consts move into that module: a _named_ constant was never the guarantee, a _pinned_ one is.

    The forcing function ships with the fix. pnpm assert:layer-version (new, wired into platform-discipline.yml pre-build) parses every registerLayer call in the repo, resolves its version argument through literals and consts, and fails on any disagreement with the manifest — so this cannot recur in a package that never gets around to writing the test.

    Seven of these eight were found by the 2026-09-01 sale-readiness audit; chrome was found by the assert itself on its first run.

    crm, forms, intake, marketing and rpg gained their first test suite in the process (tests/layer-version.test.ts) and were removed from the assert:test-floor starting-debt allowlist.

    No runtime behavior changes for a consumer already on a current install — the version a layer reports simply becomes true.

  • 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.

11 fixes (0.9.1 – 0.9.9)
  • 0.9.9
    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.9.8
    Navbar overrides of tome-ui components now win by specificity, not stylesheet order.

    Where a navbar passes its own class to a tome-ui component (NavigationMenu, its List, Trigger and Content, Button, SheetContent, SheetTitle) and both set the same property at equal specificity, which one won depended on the order Next emitted the two stylesheets, and that order can change whenever a site's CSS chunks change. On wabbit-site-core it flipped and collapsed Navbar 4's desktop link spacing to tome-ui's ~4.5px gap and showed the mobile menu button at desktop width.

    Each such override now has a .x.x companion rule carrying only the contested properties (17 across Navbar 3, 4, 5, 6 and MobileNavSheet). tome-ui's variant and state rules, which already win on specificity, are untouched, so no navbar changes where the override was already winning: tome-starter (Navbar 5) is pixel-identical before and after.

  • 0.9.7
    Fix: Navbar 5's plain nav links and dropdown triggers now share one consistent, accessible interaction style, and the logo's home link gets a real accessible name.

    Plain links were muted at rest and filled cream on both hover and plain :focus (the browser's default outline); dropdown triggers stayed ink (--tome-color-foreground) at rest. All items now rest muted, fill on hover, and share one :focus-visible ring using the same tokens as @wabbit/tome-ui's NavigationMenu trigger — no more mismatched default-outline-vs-ring behavior between links and dropdowns.

    Plain links in Navbar 5 are also now wrapped in NavigationMenuItem (a proper <li>) so the menu <ul> only has <li> children, matching Navbar 1/6/7.

    The logo's home link had no accessible name of its own in Navbar 5, 6 and 7, so screen readers fell back to the logo image's alt text (often a placeholder like "Demo placeholder logo"). HeaderLogo accepts an optional alt override (defaults to the logo media's own alt, as before); the Header global gains an optional homeLabel text field ("Home Link Label") consumers can set to their site name.

    Navbar 5/6/7's home link now carries an aria-label built from homeLabel, falling back to the logo's alt text when it reads as real content, else "Home" — and blanks the rendered <img>'s alt (via HeaderLogo's new alt="") so the name isn't announced twice. Navbar 1/2/3/4 already hardcode aria-label="Home" and are unaffected.

    No migration needed — homeLabel is optional on the existing Mongo-backed Header global.

  • 0.9.6
    Dark bands can follow the site's palette, and their accent text passes AA.

    --tome-color-surface-solid-dark now reads an optional ThemeConfig-owned --surface-solid-dark input (for example the site's ink); unset, it stays black. A new --tome-color-primary-on-solid-dark token is the brand accent for text on that surface.

    It lifts primary's OKLCH lightness to a 0.66 floor with hue and chroma kept, which clears 5.5:1 on near-black across hues; an already-light primary passes through unchanged. A color-mix fallback covers engines without relative color syntax, and a site can pin an exact value with --primary-on-solid-dark.

    The starter's oxide had measured 3.85:1 on black and 3.39:1 on its ink, against AA's 4.5:1. footer11's column labels use the new token, falling back to primary on a tome-ui without it.

  • 0.9.5
    Navbar7's desktopBreakpoint breakpoint-override CSS is now emitted as a hoisted stylesheet (<style href precedence>) instead of a plain child <style> tag inside <nav>.

    Previously, setting desktopBreakpoint to any value other than the default (768) inserted a <style> element as the first child of <nav>, shifting the position of every element after it. A consumer styling Navbar7 with structural selectors on the nav's own children (for example nav[data-nav-bg] > div:first-child for the background curtain, or > div:nth-child(N) for another structural element) would see those selectors resolve to the wrong element — breaking layout at every viewport width, not only inside the overridden breakpoint range.

    The default breakpoint (768) was unaffected because it renders no override <style> at all.

    The override CSS is unchanged; only its placement changed. It no longer appears anywhere inside <nav> — React hoists it into <head> during server rendering, deduped by an href derived from the configured breakpoint.

    Consumers who added defensive workarounds for the shifted structural indices (skipping past a <style> child, adjusting :nth-child offsets) should remove them.

  • 0.9.4
    Navbar7 (Expandable Curtain) now takes an optional desktopBreakpoint field on the Header global, moving its mobile↔desktop switch off the fixed 768px point.

    The Payload field ("Desktop Breakpoint (px)") shows only when designVersion is '7'. Left unset, it defaults to 768 — every existing Header document renders and behaves exactly as before.

    Set to e.g. 1024, both the CSS layout and the hover-vs-click dropdown behavior move together to the new value; they read the same resolved number, so they can no longer disagree with each other.

    This is purely additive: no new required field, no changed default, no peer or type change, so it ships as a patch per this package's 0.x convention.

  • 0.9.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.9.3

    HeaderActions, HeaderCTAButtons and HeaderSearchButton no longer declare 'use client', so a server-rendered custom navbar can use them directly, including passing a searchAdapter function; the built-in navbars render exactly as before.

  • 0.9.2
    Internal refactor: collection slugs are now typed through the shared typedSlug() helper instead of inline casts.

    No API or behavior change.

  • 0.9.2
    NavGuard now passes its capability to @wabbit/tome-core's CapabilityGate as the requires list the gate actually accepts.

    It previously passed a singular capability prop, which left requires undefined and crashed any guarded nav item with Cannot read properties of undefined (reading 'join') in every install where tome-core was present; the fail-open path (gate package absent) was the only one that ever worked. The new test suite pins both paths through a test seam, so the contract can no longer drift silently.

  • 0.9.1
    Navbar7 now closes its mobile drawer and any open mega-dropdown on every route change (usePathname effect) and immediately on link click, instead of leaving them open indefinitely.

    The App Router keeps a shared layout's header mounted across <Link> navigation, so Navbar7 was never unmounted between routes — its isMobileMenuOpen/activeDropdownId state carried over to the destination page, leaving the curtain expanded (and body scroll locked) over the newly-navigated content. Fixes the "mega menu / drawer stays open after clicking a link" defect reported on a production consumer site 2026-09-23.

0.8.xMinor

  • Footer 11 (Ledger): new built-in footer variant — dark editorial close on the inverse token set (surface-solid-dark / on-inverse / border-inverse), 5/7 brand-vs-nav split, mono uppercase group kickers ruled in the primary accent, mono meta row with copyright + legal links.

    Registered as designVersion '11'; renders the subline (added to SUBLINE_VERSIONS). Built for the tome-starter showcase (footer mockup round option B, 2026-07-19) but themeable for any consumer.

5 fixes (0.8.1 – 0.8.5)
  • 0.8.5
    Platform follow-up fixes across three packages.

    @wabbit/tome-core (minor): createBetterAuth() now exposes email-delivery pass-throughs so production consumers can actually verify signups and reset passwords: emailVerification (better-auth's whole config block — sendVerificationEmail, sendOnSignUp, autoSignInAfterVerification, expiresIn, lifecycle hooks), sendResetPassword, and resetPasswordTokenExpiresIn, all typed against better-auth's own BetterAuthOptions. Previously the factory offered no way to wire these, so any deployment that left requireEmailVerification on (the production default) shipped an un-verifiable signup dead end — better-auth sent nothing and sign-in threw EMAIL_NOT_VERIFIED.

    Defaults are unchanged when the new options are not provided.

    @wabbit/tome-chrome (patch): the mobile nav Sheet in Navbar5 and the shared MobileNavSheet (used by Navbar1/Navbar2) now renders a visually-hidden SheetTitle ("Navigation"; configurable via sheetTitle on MobileNavSheet) and opts out of aria-describedby, fixing Radix's "DialogContent requires a DialogTitle" accessibility warning and its missing-Description sibling.

    @wabbit/tome-blocks-extras (patch): renderers no longer paint lucide icon NAMES as literal text. FeatureHeroWithCards (PascalCase names like "Timer"), FeatureWithIconGrid, CardGrid, CardBlock, and LexicalBanner (kebab-case names like "zap", "calendar") now resolve authored icon strings through a shared name→component map (<Icon aria-hidden size="1em" />, slot font-size owns sizing).

    Unmapped name-shaped strings render nothing; emoji/free text still render as text. Adds lucide-react as peer >=0.460.0 + dev, matching the catalog-pack/chrome convention.

  • 0.8.4
    Starter-launch fixes across four packages.
    • tome-chrome: Navbar5's desktop menu now hides on mobile — the responsive .desktopMenu class moved to a wrapper <div> so tome-ui's navigation-menu root rule (display: flex) no longer clobbers the display: none toggle below 64em (the bar was blowing out to ~500px on phones, pushing the hamburger off-canvas).
    • tome-blocks-lms-pack: CourseCard no longer renders the rating star twice — the JSX ★ is removed; the styleable .tome-course-card__rating::before star in styles.css is the single source.
    • tome-blocks-catalog-pack: CategoryStrip renders real lucide icons for kebab-case icon names (target, joystick, book-open, settings, package) instead of painting the raw name as text; unmapped names render nothing, authored emoji still render. Adds lucide-react as a peer dependency (>=0.460.0, matching tome-chrome).
    • tome-blocks-content-writer: archive, related-posts, and blog catalog copy (meta description / usage.summary) now leads with the supported mode and frames unimplemented query-driven modes as roadmap scope instead of "renders nothing". No behavior change.
    • tome-blocks-org-pack: CampaignBanner drops its 20rem min-height when no bannerUrl is set — the floor exists to give the banner image room; without one it rendered a tall empty box above the bottom-anchored content.
  • 0.8.3
    Footer variants now honor the same container tokens the navbars already expose: --tome-chrome-content-max and --tome-chrome-content-inline.

    Footers 1–9 and 11 previously hardcoded max-width: 1200px (1400px for footer9) and padding-inline: 1rem, so a consumer whose page grid is wider than 1200px could not align its footer without overriding CSS modules from outside the package — even though tome-ui's grid margin column and the navbar default share the identical clamp(1rem, 4vw, 3rem) formula. Each fallback preserves that variant's historical value, so a consumer setting neither token renders identically; this is opt-in alignment, not a visual change. footer10 has no .container rule and is untouched.

    Also: footer11's .navCols hardcoded three columns at >= 48em, orphaning any fourth nav group onto its own row — now repeat(auto-fit, minmax(9rem, 1fr)), which still resolves to three equal columns for three groups.

  • 0.8.2
    New token --tome-color-on-solid-dark (light text paired with --tome-color-surface-solid-dark).

    The inverse family's pairing contract is now documented: on-inverse is dark text FOR surface-inverse (white) — pairing it with the black solid-dark surface renders black-on-black. Fixed the consumers that made that pairing: chrome Footer 11 (Ledger), lms-pack's enrollment-cta dark variant, catalog-pack's FeaturedProduct/PriceTable dark variants — all now use on-solid-dark with a surface-inverse fallback for older tome-ui.

  • 0.8.1

    Footer 11 (Ledger): the footer global's default backgroundColor ('background') no longer overrides the variant's dark band via inline style — the default now means "no override" for the self-dark variant, so the ledger renders dark out of the box; any explicitly chosen non-default token still wins.

0.7.xMinor

  • Naming convergence (all additive; every old name keeps working as a @deprecated alias until that package's next major).

    create* is canonical for collection/layer factories (define* stays reserved for the blocks descriptor system): crm/deals/marketing/intake/forms gain create*Collection names for their former define*Collection factories. Layer entries converge on createXLayer(config?) → bundle: createCrmLayer/createDealsLayer/createMarketingLayer/createCatalogLayer/createEconomyLayer/createChromeLayer/createLmsLayer/createAiLayer (+ createFormsLayer/createIntakeLayer), returning bare CollectionConfig[] where the layer contributes only collections or an honest named bundle where it hands back more (chrome: { globals }; lms/ai: { collections, hooks }); void-returning initCatalog/initEconomy stay as the single registration call sites, delegated to internally.

    Naming note for forms consumers: createFormsCollection (singular factory) vs createFormsCollections (plural composer) vs createFormsLayer (layer entry) — each docblock states the distinction.

  • Peer/dependency contracts now tell the truth. blocks-core: importing the root barrel no longer hard-crashes when the optional peers (@wabbit/tome-core, @wabbit/tome-catalog) are absent — productHooks registration is lazily guarded; NEW explicit registerBlockBundleProductType() export (root barrel + ./registry/productHooks subpath) for deterministic, format-safe registration from payload.config.ts (the import-time auto path no-ops under native ESM, which affects generate:types-visible product-type options — call the explicit API when composing catalog). chrome: next is now a required peer (>=14) — it was declared optional while next/navigation/next/link were hard-imported. readout: declares its real next peer; createReadoutBlocks({ accentPalette }) is now implemented (field-tree narrowing, dispatch's mechanism) instead of a documented no-op. blocks-lms-pack / blocks-catalog-pack: @wabbit/tome-core moves from hard dependencies to optionalDependencies, matching org-pack and the packs' own documented degrade-gracefully design.

  • Chrome shell goes server-safe (the audit's remaining clientization item): HeaderRenderer/FooterRenderer drop 'use client' — the sole hook consumer (HeaderVisibilityFrame) is extracted to its own client module, and the seven static header block components are directive-free; dist-verified that exactly one chrome file ships the directive. tome-ui's Breadcrumb/Separator/ScrollArea likewise.

    Consumer pages no longer clientize the full navbar/footer variant set by importing the renderers. blocks-extras gains a ./render/shared subpath (hero background layer + link-list, hook-free so it serves RSC and client call sites) adopted by the four hero blocks that had verbatim copies.

  • registerLayer is now statically imported (forms/intake pattern) instead of lazily require()d in ten layer packages' init/register paths.

    The lazy pattern silently no-ops under Payload's native-ESM CLI (generate:types / generate:importmap), so layer registration could vanish without error. Packages whose tome-core peer is genuinely optional (economy, ai, gamification) deliberately keep the guarded lazy path; tome-core's admin-nav/self-register.ts deliberately keeps its subpath require() (documented ESM/CJS dual-cache fix — do not convert).

  • 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.

  • 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).

  • Re-render and cleanup fixes: chrome's HeaderClient dead theme state + unreachable effect deleted; Navbar6/7 body-scroll-lock now saves and restores the pre-existing overflow value (LearnerSidebar pattern) instead of clobbering to ''; Navbar7's scroll listener is rAF-throttled. marketing-starter's Testimonial derives the clamped slide index during render instead of an effect. forms' FieldRenderer is wrapped in React.memo (call-site props verified stable), cutting whole-step re-render work per keystroke in multi-field forms. lms-ui's useLearnerPrefs gains optional initialPrefs server-seeding (non-breaking) + in-flight dedup with TTL for the unseeded path.

0.6.x

2 fixes (0.6.1 – 0.6.2)
  • 0.6.2
    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.6.1
    Platform-wide peer-range sweep: every workspace:*/workspace:^ entry in peerDependencies replaced with an explicit semver range (@wabbit/tome-core >=1.0.0 <2.0.0, tome-ui >=0.9.0 <1.0.0, tome-motion >=0.2.0 <1.0.0, tome-catalog >=1.1.0 <2.0.0, tome-admin >=0.5.0 <1.0.0; tome-crm ranges standardized to >=0.2.0 <1.0.0).

    The workspace protocol publishes as an exact-version pin, so every substrate bump stranded installed dependents — the breakage class proven by marketing@0.1.0/deals@0.1.1 requiring tome-crm@0.2.0 exactly. devDependencies keep workspace:* for the local link. (@wabbit/tome-admin-pro got the same source fix but is rc-versioned; it carries the change on its next intentional release.)

    tome-crm additionally gains a once-per-process production warning when the capability-registry fallback grants access — the bootstrap heuristic (any authenticated user passes crm:read) now announces itself instead of running silently on sites that forgot to seed capability grants (2026-06-10 audit hardening item).

    Graph-truth additions (same hygiene wave): tome-deals declares its lazy print integration as an optional peer (@wabbit/tome-print >=0.1.0 <1.0.0); tome-intake declares its lazy catalog routing strategy (@wabbit/tome-catalog >=1.1.0 <2.0.0, optional). These were undeclared dynamic imports — invisible to consumers and to pnpm's build topology.

0.4.xMinor

  • feat(chrome): NavBar4 submenu layout — compact dropdown vs mega-menu

    Submenu blocks (designVersion 4) gain a Submenu Layout field: mega (default, unchanged — the full-width panel centered under the bar) or dropdown (new — a compact panel pinned directly beneath its own trigger).

    The dropdown layout renders outside the shared Radix mega-menu viewport (a CSS hover / focus-within flyout anchored to its NavigationMenuItem), so it sits under its trigger instead of centering under the bar, and it leaves the mega-menu's positioning untouched. Best for a short list of links; mega stays best for cover cards / multi-column content.

    The flyout's trigger mirrors the mega trigger, and its panel surface reads the same --tome-nav-surface-* vars, so the megaMenuBackgroundColor field recolors it identically. Mobile (the drill-in sheet) is unaffected — it already lists submenu blocks regardless of layout.

0.3.xMinor

  • feat(chrome): NavBar4 admin-configurable mega-menu panel color

    New Header field Mega-menu Background Color (megaMenuBackgroundColor), shown only for designVersion 4. Default keeps the standard tome-ui popover surface (light card + border).

    Any Layer-2 token (Background / Foreground / Primary / Secondary / Accent / Muted / Card / Transparent) paints the NavBar4 mega-menu panel with that color and drops the popover border so the color reads cleanly — e.g. set Secondary to match an eggplant bar.

    Implemented by setting the --tome-nav-surface-bg / --tome-nav-surface-border CSS vars (added in @wabbit/tome-ui@0.9.1) on the NavigationMenu root; the shadow is kept for depth. Requires @wabbit/tome-ui >= 0.9.1.

    No effect on other navbar variants or when the field is left at Default.

0.2.xMinor

  • feat(chrome): LinkComponent slot on HeaderRenderer

    <HeaderRenderer> now accepts an optional LinkComponent prop. When provided, every <NavLink> instance — across all 7 navbar variants AND the 5 header block types (CardGrid, CategoryGrid, FeatureList, FeaturedImage, FeaturedBanner, SimpleLinks) — routes through that component instead of next/link's Link.

    Mirrors the existing LogoComponent pattern: chrome propagates the override via internal context (HeaderLinkProvider), so variant + block code stays unchanged. The slot type HeaderLinkSlotProps is the standard anchor surface (href, className, children, plus forwarded HTML attributes), so consumers can drop in next/link, a route-transition wrapper, a Remix/Astro <Link>, or any other anchor-shaped component without prop translation.

    Default behavior is unchanged: when LinkComponent is omitted, NavLink continues to use next/link. External links and link.newTab paths still render plain <a> regardless of the override (Phase-1.5 behavior preserved verbatim).

    Resolves the framework-agnostic TODO at _shared/NavLink.tsx:2. Unblocks consumers that need View-Transitions-API hooks or per-link side effects (analytics, prefetch policy) without forking the chrome variants.

1 fix (0.2.1)
  • 0.2.1
    fix(chrome): NavBar4 mega-menu block fill + nav-link gap

    Two NavBar4 (designVersion 4) defaults that every consumer was fighting:

    • Mega-menu blocks rendered at half width. A refactor split the starter's single <BlockRenderer blocks={...}/> into one <BlockRenderer> per block, but blockRenderer.module.css .wrapper kept its repeat(2, minmax(0, 1fr)) grid. Since each wrapper now holds exactly one block (and every block component returns a single root), the block sat in column 1 at half width with an empty column 2 — collapsing featuredImage cover cards to a sliver when combined with a consumer mega-menu grid. The wrapper is now a single column; multi-block layout is owned by megaContent / the consumer, not the per-block wrapper.
    • Nav links jammed together. .desktopList set no gap and inherited the NavigationMenu primitive's ~4.5px, mashing multi-word labels. It now has a readable 1.5rem gap (2rem at ≥80em).

    No API or class-name changes. The 18rem featuredImage card cap, default stacked mega-menu layout, and all other variant behavior are unchanged — consumers that want a horizontal card row still grid megaContent themselves.

0.1.x

2 fixes (0.1.20)
  • 0.1.20
    NavLink: resolve href from populated reference slug (was using doc id).

    Chrome's link() field is configured with maxDepth: 1, so internal links arrive with the referenced doc populated and slug attached. NavLink's resolveHref was casting reference.value to { id: string } and producing /${relationTo}/${id}, which sent every navbar link to /pages/{mongo-id} instead of /{slug}.

    New resolution:

    • /{slug} for relationTo: 'pages' (the dominant Payload root convention)
    • / when slug === 'home'
    • /{relationTo}/{slug} for non-pages collections
    • /{relationTo}/{id} fallback when slug is missing or value is a raw id string

    Consumers whose routing diverges from this contract should pre-resolve to link.url upstream of NavLink. TomeLink.reference.value widened to include the populated-doc shape so callers no longer need an as { id: string } cast.

  • 0.1.20
    Updated dependencies [1d90b24]
    • @wabbit/tome-ui@0.6.1

Full changelog across all packages