Documentation menu
Documentation / Foundation / chrome
Chrome
npm install @wabbit/tome-chromeFirst install from our registry?
Get a free registry token from your credentials page. Every install from our registry needs one, free packages included.
Add the registry and your token to the
.npmrcat the root of your project, with your token in place ofYOUR_TOKEN:@wabbit:registry=https://npm.wabbit.com/ //npm.wabbit.com/:_authToken=YOUR_TOKEN
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
| Peer | Range |
|---|---|
@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 path | Type | Notes |
|---|---|---|
mobileActionBar.enabled | checkbox | Default false. Off renders nothing, so existing sites are unchanged. |
mobileActionBar.callLabel | text (localized) | Call button text; blank means "Call". |
mobileActionBar.phone | text | Written for people, e.g. (555) 014-2290; becomes tel:5550142290. Screen readers hear "Call, (555) 014-2290". Blank hides Call. |
mobileActionBar.ctaLabel | text (localized) | Filled button text. |
mobileActionBar.ctaHref | text | Path, #anchor or full URL; routed through LinkComponent when one is passed. The CTA needs both label and link. |
mobileActionBar.secondaryLabel | text (localized) | Optional second button between Call and the primary action, e.g. "Directions". |
mobileActionBar.secondaryHref | text | Path, #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.showAtLoad | checkbox | Default false: the bar slides in after the first scroll. On: it shows from first paint and stays visible. |
mobileActionBar.callWhen | select | always (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:
64emfor navbars 1, 3, 4, 5 and custom variants,48emfor 2 and 6, andheader.desktopBreakpointfor 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-offseton:rootfor other fixed elements such as chat launchers. - Keyboard and screen readers: a hidden bar is
inertandvisibility: hidden, so it is out of the tab order and the accessibility tree; a visible bar is arole="region"named "Quick actions". - Reduced motion: with
prefers-reduced-motion: reduce, or when the motion adapter reports reduced motion (NOOP_CHROME_ADAPTERSdoes), it appears and disappears without the slide. - No JavaScript: the hidden state applies only while script runs (
data-hydratedor@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(default4.5rem). Browsers without thescriptingmedia 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 path | Type | Notes |
|---|---|---|
headerCall.enabled | checkbox | Default false. Off renders nothing, so existing sites are unchanged. |
headerCall.label | text (localized) | Visible label; blank means "Call". |
headerCall.phone | text | Written 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.callWhen | select | always (default when empty), open or closed. See Open state. Written to data-call-when when set to open or closed. |
headerCall.showOnDesktop | checkbox | Default 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
/>| Prop | Notes |
|---|---|
hours | ChromeOpenStateSource: timezone, hours, hoursOverrides, schedules. Absent: nothing below happens and every call shows. |
computeOpenState | Required with hours; chrome never computes hours itself. |
openScheduleKey | Which 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 asdata-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
closesAtwhile open oropensAtwhile closed (never more than six hours out, and again when a hidden tab becomes visible). No polling. headerCall.callWhenandmobileActionBar.callWhen:openhides the call only while the desk is known to be closed, so a site with no hours, or the first paint, still shows it;closedshows it only while known to be closed.{until}inheaderCall.labelormobileActionBar.callLabelis 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.
Navbar 1 opt-ins
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.
Navbar 1 Studio options
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).
| Field | Admin shows for | What it does | Hook |
|---|---|---|---|
wordmarkText | Navbar 1 | The 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 menu | span[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 unthemed | data-nav-align="center" on the section root |
mobileMenuLabel ('icon' default, 'word') and mobileMenuText | Navbar 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 copy | button[data-mobile-menu-trigger="word"] |
mobileBreakpoint ('64em' default, '56.25em') | Navbar 1 | Where 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 width | data-mobile-breakpoint="56.25em" on the section root (absent at the default) |
markCurrentPage | Navbar 1 | Marks the top-level link for the page being viewed, in the bar and the phone menu | aria-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.
Navbar 4 options
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.
Footer variant hook
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 7 wordmark
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.
Footer fine print and text rows
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
| Export | Description |
|---|---|
createChromeLayer(config?) | Canonical layer entry — builds Header/Footer globals, registers built-in variants, registers the layer; returns { globals } |
TomeChromeConfig, TomeHeaderConfig, TomeFooterConfig | Factory 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:
searchAdapter?: () => ReactNode— consumer search UI (header only)mediaAdapter?: ComponentType<{media, alt?, className?}>— image renderer override (header + footer)languageSwitcherSlot?: ReactNode— header onlythemeToggleSlot?: ReactNode— header onlyLogoComponent?: ComponentType<HeaderLogoSlotProps>— override the default<img>logo, e.g. for inline-SVG/theme-adaptive logos (header + footer, via sharedHeaderLogoProvider). Every navbar honors it, Navbar 7 included: there it replaces the stackedlogo/logoDarklayers, sits in a[data-navbar-logo="component"]wrapper that inherits the bar's text color, and receivesisDarkModeEnabled: truewhenever the bar wants its light-on-dark logo (navThemedark/auto at rest, or a dark solid background)LinkComponent?: ComponentType<HeaderLinkSlotProps>— override the defaultnext/linkpath, e.g. a view-transitions router (header + footer, via sharedHeaderLinkProvider)
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) — plainMap-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 touchingdesignVersionresolution
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/footerPayload globals instead of aRenderBlocksentry, keeping chrome's consumption shape distinct from@wabbit/tome-blocks. - Motion is optional via an adapter contract, never a hard dependency — chrome declares a
ChromeMotionAdaptersinterface and ships a NOOP implementation (NOOP_CHROME_ADAPTERS, confirmed insrc/adapters/motion.tsx) so scroll-direction hide/reveal and hamburger/submenu animation degrade to fully-functional-but-unanimated when@wabbit/tome-motionisn't installed. - Footer is a Phase-2 mirror of header, not a parallel design —
createFooterGlobal/FooterRenderer/footerVariantRegistrycopy the header's factory/registry/renderer/slot shape exactly so a contributor who knows one knows the other, withoverlayModedeliberately 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 whendesignVersionis'7') defaults to768, matching the CSS module's original hardcoded48em. 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 inheader/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-onlydata-desktop/matchMediaflag, 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.mobileActionBargoes throughassembleHeaderFields, so all seven variants (and custom ones) get it without touching their components. It renders as a sibling ofHeaderVisibilityFrame, because the frame's hide-on-scroll transform would otherwise become the containing block of aposition: fixedbar. 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-heightwhile 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;labelandhrefchange its text (translate it per locale) and target (the page's<main>needs that id). Unset,nullorfalserenders 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 reportsaria-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
footerin asection, which stops browsers treating it as the page footer. The wrapper is now adiv, 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.showNumberprints the phone number as text, with the label indata-header-call-labeland the number indata-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.callClosedLabelandmobileActionBar.ctaClosedLabelreplace 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
opensstring, 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 carrydata-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-codingaria-label="Home"on the logo link and useheader.homeLabelviagetHomeLinkLabel.Unset, null or blank
homeLabelstill 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.
homeHrefsets 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, withmobileMenuLabel: 'word') is the word shown on the toggle while the menu is open, for example "Close"; the visible text stays the accessible name andaria-expandedstill comes from the sheet trigger.MobileMenuWordTriggergains acloseWordprop and_sharedexportsresolveHomeHrefandresolveMenuCloseWord.Navbar 1 can show a grouped Courses panel with columns and a footer link.
Navbar 1 gains an opt-in grouped Courses panel. A
subitem takespanelLayout: 'columns'andpanelFooterLink; each subitem takesgroup,groupHrefandfeatured.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], anddata-nav-has-columnson the bar. The phone accordion carries the same groups.subitems.maxRowsrises from 10 to 24. Asubitem with nopanelLayoutrenders 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.secondaryLabelandmobileActionBar.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'], anddata-actionsnow 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; withshowAtLoadon it is visible from the first paint and stays, with no scroll listener running.MobileActionBartakes a matchingshowAtLoadprop, andresolveMobileActionBarreturnsshowAtLoad: trueonly 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.mobileBreakpointoption:'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-texthook.
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 overlogo/logoDark; blank keeps the logo.- Navbar 1 honors
header.mobileMenuLabel: 'word'andheader.mobileMenuText:button[data-mobile-menu-trigger="word"], default text "Menu". It reuses Navbar 5's trigger (now the sharedMobileMenuWordTrigger), 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 getaria-current="page"anddata-nav-current. Exact match, or a section prefix for a non-root href (/workmatches/work/x);/matches only/; hash-only and external hrefs never match. Uses the pathnameNavLinkalready 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]ofli[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 typeTomeFooterBrandRow; 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.
HeaderRenderertakeshours(a structuralChromeOpenStateSource, the shape of tome-local'sOpenStateSource),computeOpenState(the site passes tome-local'sgetOpenState; chrome takes no tome-local dependency) and an optionalopenScheduleKey. A client island,OpenStateProvider, computes after mount, writesdata-tome-open="true" | "false"on the document element anddata-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),openorclosed.openhides the call only while the desk is known to be closed;closedshows it only then. Set values are written todata-call-when. - `{until}` in the call label.
headerCall.labelandmobileActionBar.callLabelmay 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`.
HeaderRendererprop{ 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 anenabledargument (defaulttrue) anduseWindowScrolled(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.
.mobileTopBarmay 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-gapand--tome-navbar1-phone-trigger-pad(0.5rem), with-narrowvariants of the last three. Each falls back to the value it had, so a theme whose bar must stay one row setsnowrapand tightens the gaps.
The Header global gains
headerCall.callWhen,headerCall.showOnDesktop,mobileActionBar.callWhenandreportScrolled(none has a stored default): run your Payload migration and regenerate types.- Open state.
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 afteraccountSloton desktop and in the mobile menu, each copy insidespan[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; helpersrenderCartSlotandwithCartSlot.chromeMode: 'checkout'onHeaderRendererandFooterRendererswaps 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 carrydata-chrome-mode="checkout"; the pieces are also exported asCheckoutHeaderBarandCheckoutFooter.TomeNavItem.prefix(link and sub items, new admin field shown for design 5) rendersspan[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 to0and let its ownmin-heightown the bar's height (the 73px header against 77px).The checkout header honors
header.logoPhonebelow 40em, as Navbar 5 does: the full logo and the phone logo sit indata-logo-fullanddata-logo-phonewrappers and the stylesheet swaps them. WithlogoPhoneunset 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 withcartSlotPhone: '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 CSSorderwould 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
navAlignmenttocenteron 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 writesdata-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 carriesdata-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(iconby default, orword) swaps the hamburger icon for a text button,button[data-mobile-menu-trigger="word"], whose visible word (the localisedmobileMenuText, default "Menu") is its accessible name and which opens the same sheet.Left at
iconthe 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) andspan[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 compactsimpleLinksandfeaturedImageblocks (BlockRenderertakes a newcompactprop), a pinned footer holding the account, language, theme and CTA area, and the copyright at the end of the list.ctaVisibleBelowDesktopnow 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-sizeand--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 withoverflow-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.ctaVisibleBelowDesktopsetting.NavLink(so every nav link, footer link and header CTA button) addsdata-new-taband a visually hidden " (opens in a new tab)" whenlink.newTabis set. A caller-suppliedaria-labelgets 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 todesktopBreakpointinstead 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
defaultCallingCodetoHeaderRendererand a typed national number becomes an E.164tel:link.<HeaderRenderer defaultCallingCode="1" />turns(816) 555-0118intotel:+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,resolveHeaderCallandresolveMobileActionBartake an optional{ defaultCallingCode }last argument, andHeaderCallingCodeProvider/useHeaderCallingCodecarry the value toHeaderCallButton(now a client module, like the other context readers). Extensions typed asext. 12now produce;ext=12instead of the digits run together.Requires
@wabbit/tome-core1.22.0 or later.
0.19.xMinor
Footers: "Cookie Settings" renders only when the footer's new
cookieSettingsfield is on.Every built-in footer (1 to 11) rendered the button unconditionally, and it only dispatches an
open-cookie-settingswindow event, so with no consent manager mounted (no current consumer mounts one) it was a dead control. The Footer global gains acookieSettingscheckbox (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-motioncame from the motion adapter'suseReducedMotion()on the first render, which readsmatchMediaonly in the browser (framer's returns null on the server), so the server renderedfulland a reduced-motion clientreduced. The attribute is now written only after mount; before hydration the stylesheet'sprefers-reduced-motionquery 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(default0.125rem), in every built-in footer. A continuing row first in a column or after a link row starts its own item. New exportgroupFooterRows()does the folding for a custom variant (FooterColumnRowtakes the group's rows ascontinuation). - `--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.
- `emphasis` (text rows): renders the line, or a label/value pair's label, in
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, thenpayload 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.announcementSlotis 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 attop: 0withz-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>taketop: var(--tome-announcement-height), so they sit under the strip. The CMSannouncementBarsurface is unchanged: it is still Phase 3 and accepts onlyfalse; 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 becomesposition: relative(absolutein 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 todata-header-positionon the frame, besidedata-nav-themeanddata-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,0pxwithout one) and--tome-chrome-sticky-height(the strip plus a sticky or fixed frame). Use the latter forscroll-margin-topso in-page anchors land below the pinned chrome.--site-header-heightis0pxfor a static header, which scrolls away. New exports:chromeStickyHeight(),resolveHeaderPosition()and theTomeHeaderPositiontype. - 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.
- `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
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'saria-expandedfollows. - Mobile sheet motion. The sheet stays mounted and fades and slides in and out, keyed off
data-state="open" | "closed". When closed it isinert, 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-openwhile the sheet is open.
- 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
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
formscollection or thetomeFormblock must run their Payload migration (payload migrate:create, thenpayload migrate) and regenerate types (payload generate:types) after upgrading. The footer column-rowlinkgroup becomes conditional (hidden on text rows), so its columns become nullable in that migration;rowTypedefaults tolink, so existing rows stay links.@wabbit/tome-chrome- Navbar 7 `data-nav-scrolled`. Navbar 7's existing
isScrolledstate (page scrolled past 20px) is exposed asdata-nav-scrolled="true"/"false"on its<nav>root ("false"at rest and on the server render) and mirrored onto the header frame (the element carryingdata-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 newuseReportNavScrolled(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(default0.75rem) and--tome-footer-fine-print-measure(default72ch). Exported asFooterFinePrintfor other variants. - Plain-text column rows.
navItems[].subNavItems[]gainsrowType(linkdefault |text),textand an optionalvalue. A text row renders<span data-footer-text-row>; with avalueit 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, default5.5rem). All eleven footers render both row types through the new sharedFooterColumnRow; link rows render byte-identically. New types:TomeFooterColumnRow,TomeFooterLinkRow,TomeFooterTextRow,TomeFooterFinePrintParagraph. - Navbar 7 theme tokens:
--tome-header-pad-block(header row block padding, default2rem, mobile bottom half of it),--tome-header-curtain-radius(default1.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 curtainstyleattribute no longer carriesborder-bottom-*-radius, and a non-defaultdesktopBreakpointoverride 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, default12px, 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,#fragmentand external links excluded). Links to the current page gain the attribute, an intended markup change; a caller's ownaria-currentwins. - `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 askipSteprule skips stay hidden and unvalidated, and steps after theisFinalStepstep are not shown. Root hookdata-form-steps="stacked"on the<section>.defineFormrejects other values. - `appearance.submitNote`: a line beside the submit button,
[data-form-submit-note]. - Both are on the code
appearanceconfig, the adminformscollection's Appearance group, and thetomeFormblock'sappearancegroup (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): thetomeFormblock renderer. It forwardsfieldDefaultsand only the block'sstepsDisplay/submitNotewhen 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.TomeFormitself gainsappearanceandfieldDefaultsprops for direct callers; without them it renders exactly as before. Also exported:pickBlockAppearance,mergeAppearance. - Starting values are server-rendered. A field's
defaultValueand the placement'sfieldDefaultsare 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 adefaultValuenow 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 callsreset(values, { keepFieldsRef: true }), and 7.60.0 is the first release whoseresethonorskeepFieldsRef(absent from the 7.59.0 types and runtime). Sites on an older react-hook-form must upgrade it.
- Navbar 7 `data-nav-scrolled`. Navbar 7's existing
0.15.xMinor
Groundwork v1.1 chrome batch.
- Fix: footer `backgroundColor` options now paint declared tokens.
getFooterBackgroundStyleinterpolated the option name, somutedresolved to--tome-color-muted/--tome-color-muted-foregroundandcardto--tome-color-card, none of which tome-ui declares, and every brand option's ink used a-foregroundname 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.
getHeaderBackgroundColormappedmuted/cardto the undeclared--tome-color-muted/--tome-color-cardand built translucent/glass from nonexistent--tome-color-*-hslcompanions. It now mapsmuted→--tome-color-surface-muted,card→--tome-color-surface, and composes translucent (70%) and glass (50%) withcolor-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 tomobileActionBar.phone. Hook:[data-header-call]; tokens--tome-header-call-*. ExportsHeaderCallButtonandresolveHeaderCall. 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.idis 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.
- Fix: footer `backgroundColor` options now paint declared tokens.
0.14.xMinor
The header publishes its real height in every mode.
SiteHeaderHeightProbenow writes--site-header-bar-heighton:rootalongside--site-header-height. The existing variable is unchanged: it is the flow space the header takes, so it reads0pxin 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 asheaderHeights().Nothing renders differently until a site reads the new variable.
1 fix (0.14.1)
- 0.14.1
Header links to
/#idnow jump to that section on the current page when the page has it.A "Start a project" button pointing at
/#scopegoes to the page's own#scopeform when one exists, and to/#scopeotherwise. 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
buttonsrendered 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'sappearanceisoutline, 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 aLinkComponentthat 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/-fgto keep a different pair, or give the buttonappearance: 'outline'.- New theme hooks, all optional and prefixed
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
LogoComponentoverride, and footers gain--tome-footer-pad-block,--tome-footer-nav-colsand adata-footer-navhook.Navbar 7 used to render its stacked
logo/logoDarkimages even whenHeaderRendererreceived aLogoComponent. The override now replaces them inside a[data-navbar-logo="component"]wrapper that inherits the bar's text color, and receivesisDarkModeEnabled: truewhenever the bar wants its light-on-dark logo.Without an override, the
navThemeimage behavior is unchanged.Footers 1–5, 7 and 8 read their block padding from
--tome-footer-pad-block(default8rem). Footer 7's link grid carriesdata-footer-navand reads its column count from--tome-footer-nav-cols(default3).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 exposearia-expanded(mirroring the CSS :hover / :focus-within that opens the flyout, via a newuseFlyoutStatehelper) andaria-controlspointing at the panel. The Navbar4 hamburger exposesaria-expandedandaria-controlsfor 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
mobileActionBargroup: 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, thenpayload migrateon SQL databases) and regenerate your Payload types after upgrading. Nothing renders until an editor turns it on:mobileActionBar.enableddefaults tofalse, and Header documents without the group render byte-identically.The group holds
enabled,callLabel(localized; blank means "Call"),phone(written for people, normalized to atel:link),ctaLabel(localized) andctaHref; each action renders only when its fields are complete.HeaderRenderermounts the bar beside the navbar, with no new props.It shows only below the active navbar's desktop breakpoint (
64emfor navbars 1, 3, 4, 5 and custom variants,48emfor 2 and 6,desktopBreakpointfor 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 isinertwhile 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 typeTomeMobileActionBarData.The Header global gains a
navThemeoption ("Transparent Header Contrast") so Navbar7's transparent at-rest state can sit over a light hero, and every built-in footer now carriesdata-footer-variant.Migration:
navThemeis a new Header global field, so run your Payload migration (payload migrate:create, thenpayload migrateon SQL databases) and regenerate your Payload types after upgrading. It defaults to'auto', which keeps the existing look.navThemeis'auto'(default: light text and logo for a dark hero, as before),'light'(dark text and the mainlogofor 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/logoDarkpair, with the CSS-invertedlogoas the fallback when nologoDarkis set. Every variant writes the resolved value todata-nav-themeon 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 keyFooterRendererresolved (the fallback variant's key whendesignVersionis not registered), so themes can target a footer design without relying on the landmark element.TomeFooterVariantPropsgains an optionalvariantKey, whichFooterRendererpasses to custom variants so they can emit the same attribute.
0.10.xMinor
Header navbars accept an optional
accountSlotfor a Log in / Account control, and Navbar7 can now show the search, language and theme controls whenshowHeaderActionsis on.accountSlot?: ReactNode | ((ctx: 'desktop' | 'drawer') => ReactNode)is new onHeaderRendererandTomeNavbarProps(new exported typesTomeAccountSlotandTomeAccountSlotContext). 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(defaultfalse) is new onHeaderRendererandTomeNavbarProps; navbars 1 to 5 already rendersearchAdapter,languageSwitcherSlotandthemeToggleSlot, 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./footerand the root barrel.It has been registered as variant key
'11'infooterVariantRegistrysince 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.
buildgains&& node ../../scripts/fix-dist-extensions.mjs --strictas its last step, joining the 13 packages that already ran it. tsup buildsbundle: falseand emits relative specifiers exactly as the TypeScript source wrote them — extensionless — which bundlers resolve and raw Node does not (ESMERR_MODULE_NOT_FOUND; CJS worse,require('./x')finds the ESM.jstwin and Node 22+require(esm)then dies on that file's own extensionless import). Every consumer outside a bundler hit this: the payload CLI under plain node,generate:types,generate:importmap, ops scripts, codegen tools.No source changes, no API changes, and bundler consumers are unaffected — extensioned relative specifiers are universally resolvable.
Two supporting changes made the wiring possible, both in repo scripts rather than package source.
fix-dist-extensions.mjsnow skips bundler-asset specifiers (.css,.module.css,.scss, fonts, images, shaders) by explicit extension allowlist instead of reporting them as unresolvable — that single gap is why the 13 prior adopters were exactly the 13 packages that ship no CSS, since--strictexited 1 on any package with a relative stylesheet import.Dotted MODULE names (
./config.meta,./x.variants,./y.demo) are deliberately NOT treated as assets and still get.js/.cjsappended.assert-node-loadable.mjsgained the matching carve-outs so the new repo-wide CI gate reports real defects only: a resolution failure whose path lands undernode_modulesis a peer SKIP (next@15 has no exports map, sonext/imagefails as an absolute path), and a bundler-asset load failure is an environmental SKIP (CJS surfaces it asSyntaxError: Unexpected token '.'raised from inside the stylesheet).Verified before/after on four packages built one at a time: print 8 FAIL → 0, readout 22 FAIL → 0, ai 3 FAIL → 0, gamification 2 FAIL → 0 (its failure was the other signature — a
directory importmissing/index). cop was already clean on a fresh build, so the audit's "27 of 46 fail" figure includes at least one package whose local dist was merely stale.One
LayerFactoryConfigevery 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 whatconfigmay 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 learnedadminGroupfrom org andhooksfrom 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/layerFactoryConfigexports theLayerFactoryConfiginterface —adminGroup,access(per-collection override map),hooks(appended viamergeHooks, never replacing),extraFields,fieldOverrides,omitFields,fieldOrder,slugs— andapplyLayerFactoryConfig(collections, config), which honors the whole vocabulary in one call and one fixed order (adminGroup → access → hooks → field shape, the last delegated tofields/fieldShape'sapplyFieldShapeso the order cannot drift between layers). Pure: new array, new objects, identity return on an empty config.It is a separate subpath from
./utilities/layerRegistrydeliberately — that module is in core'ssideEffectsarray, 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'sRecord<string, string | undefined>), and named<name>Slug?: stringscalars 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 extendLayerFactoryConfigdirectly and APPLY it throughapplyLayerFactoryConfig(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-coreas an OPTIONAL peer and hold zero runtime imports of it — gamification reachesregisterLayerthrough a lazyrequire()in a try/catch for exactly this reason.applyLayerFactoryConfigis 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 byassert: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 againstCollectionConfig, 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 carryadmin.groupexactly as collections do. rpg extendsOmit<LayerFactoryConfig, 'access'>becauseCharacterSheetsConfigis a single collection's config that doubles as the layer factory's config, and its ownaccessalready 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 EMPTYCollectionConfig[]: 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
WorkflowLayerConfigomits every seam for the same reason, and exists as the stable place a real option will land.createGamificationLayerandcreateRpgLayerare pure aliases ofregisterGamificationLayer/registerRpgLayer.initWorkflow,registerGamificationLayerandregisterRpgLayerare all@deprecatedwith sunset at each package's next major; none is removed.Forcing function:
scripts/assert-layer-factory-contract.mjs+pnpm assert:layer-factory-contract, wired intoplatform-discipline.ymlafterassert:layer-version(source reading only, pre-build). Every exportedcreate*Layermust take a config parameter whose type resolves toLayerFactoryConfig— throughextends, an intersection, or an explicitOmit<…>— with verb aliases followed to theirregister*/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.mdgains a "The factory contract" section stating the rule and the three permitted responses.Three factories are ALLOWLISTED with a reason each:
createAiLayerreturns credential wiring and owns no collections, so every seam is meaningless to it;createFormsLayerandcreateIntakeLayerare 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-coreto>=1.14.0 <2.0.0. The new subpaths do not exist below that, and a too-low floor is howERR_PACKAGE_PATH_NOT_EXPORTEDreached crowdfund's consumers once already.These are marked
patchbecause 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.jsoninstead of a hand-typed literal.registerLayer(name, { version })is the contract a consumer reads back throughhasLayer/getLayerto 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.Package Registered Actual @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.tsexporting<NAME>_LAYER_VERSION, read by itsregisterLayercall — 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-localTOME_*_VERSIONconsts 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 intoplatform-discipline.ymlpre-build) parses everyregisterLayercall in the repo, resolves itsversionargument 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 theassert:test-floorstarting-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.
homepagedeep-links to that package README on GitHub (.../tree/main/packages/<dir>#readme). Without it a registry page links to the monorepo root and the reader has to guess which of 46 folders they want.bugs.urlpoints at the repo issue tracker, so a paying customer has a place to report a defect that is not email.engines.nodeis>=22, matching the rootenginesand.nvmrcset the same day. This is a real floor, not decoration: CI on Node 20 could not expand the glob the block packs use fornode --test, and a package installed on Node 20 fails at a runtime the installer cannot connect back to the version.
The forcing function ships with the change:
scripts/assert-manifest-metadata.mjs(rootpnpm assert:manifest-metadata, wired intoplatform-discipline.ymlbesideassert:license-metadata) fails when any publishable manifest lacksdescription,repository.directorymatching its own folder,homepage,bugs,engines.nodeequal to the repo floor,license,filesorsideEffects. It reported 138 violations before this change and 0 after.
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.ctsdeclarations, and the stylesheets, fonts and other assets a package already shipped. Every JavaScript module opens with a comment naming the package and its license:/*! @wabbit/<package> — © Wabbit, LLC. Wabbit Tome Commercial License (see LICENSE.md). Not for redistribution. */.The
.mapfiles and thesourceMappingURLcomments that pointed at them are gone, which roughly halves the size of each tarball.Debugging: the code is still unbundled and unminified, one readable file per module, so a stack trace points at real code with real names. Line numbers in a stack trace are one higher than before, because of the banner line.
A
'use client'directive stays the first statement of its module (the banner is a comment above it), so React Server Component boundaries are unchanged.No API change, no runtime behavior change, and nothing to do on upgrade.
In
@wabbit/tome-blocks-gallery, the source snapshotsextractGallerySourcewrites from an installed pack leave out the license banner line, so a component or config snapshot starts at the code and a paid block's preview shows its first 15 lines of real code. - 0.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.xcompanion 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-visiblering using the same tokens as@wabbit/tome-ui'sNavigationMenutrigger — 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
alttext (often a placeholder like "Demo placeholder logo").HeaderLogoaccepts an optionalaltoverride (defaults to the logo media's own alt, as before); the Header global gains an optionalhomeLabeltext field ("Home Link Label") consumers can set to their site name.Navbar 5/6/7's home link now carries an
aria-labelbuilt fromhomeLabel, falling back to the logo's alt text when it reads as real content, else "Home" — and blanks the rendered<img>'salt(viaHeaderLogo's newalt="") so the name isn't announced twice. Navbar 1/2/3/4 already hardcodearia-label="Home"and are unaffected.No migration needed —
homeLabelis 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-darknow reads an optional ThemeConfig-owned--surface-solid-darkinput (for example the site's ink); unset, it stays black. A new--tome-color-primary-on-solid-darktoken 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 toprimaryon a tome-ui without it. - 0.9.5
Navbar7's
desktopBreakpointbreakpoint-override CSS is now emitted as a hoisted stylesheet (<style href precedence>) instead of a plain child<style>tag inside<nav>.Previously, setting
desktopBreakpointto 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 examplenav[data-nav-bg] > div:first-childfor 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 anhrefderived from the configured breakpoint.Consumers who added defensive workarounds for the shifted structural indices (skipping past a
<style>child, adjusting:nth-childoffsets) should remove them. - 0.9.4
Navbar7 (Expandable Curtain) now takes an optional
desktopBreakpointfield on the Header global, moving its mobile↔desktop switch off the fixed 768px point.The Payload field ("Desktop Breakpoint (px)") shows only when
designVersionis'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 tsuponSuccesshook; no behavior change, and the publisheddist/is identical. - 0.9.3
HeaderActions,HeaderCTAButtonsandHeaderSearchButtonno longer declare'use client', so a server-rendered custom navbar can use them directly, including passing asearchAdapterfunction; 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
NavGuardnow passes its capability to@wabbit/tome-core'sCapabilityGateas therequireslist the gate actually accepts.It previously passed a singular
capabilityprop, which leftrequiresundefined and crashed any guarded nav item withCannot read properties of undefined (reading 'join')in every install wheretome-corewas 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 (
usePathnameeffect) 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 — itsisMobileMenuOpen/activeDropdownIdstate 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, andresetPasswordTokenExpiresIn, all typed against better-auth's ownBetterAuthOptions. Previously the factory offered no way to wire these, so any deployment that leftrequireEmailVerificationon (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 viasheetTitleon MobileNavSheet) and opts out ofaria-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-reactas 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
.desktopMenuclass moved to a wrapper<div>so tome-ui'snavigation-menuroot rule (display: flex) no longer clobbers thedisplay: nonetoggle 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::beforestar 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-reactas 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
bannerUrlis set — the floor exists to give the banner image room; without one it rendered a tall empty box above the bottom-anchored content.
- tome-chrome: Navbar5's desktop menu now hides on mobile — the responsive
- 0.8.3
Footer variants now honor the same container tokens the navbars already expose:
--tome-chrome-content-maxand--tome-chrome-content-inline.Footers 1–9 and 11 previously hardcoded
max-width: 1200px(1400px for footer9) andpadding-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 identicalclamp(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.containerrule and is untouched.Also: footer11's
.navColshardcoded three columns at>= 48em, orphaning any fourth nav group onto its own row — nowrepeat(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-inverseis dark text FORsurface-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 useon-solid-darkwith asurface-inversefallback 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
@deprecatedalias 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 gaincreate*Collectionnames for their formerdefine*Collectionfactories. Layer entries converge oncreateXLayer(config?) → bundle:createCrmLayer/createDealsLayer/createMarketingLayer/createCatalogLayer/createEconomyLayer/createChromeLayer/createLmsLayer/createAiLayer(+createFormsLayer/createIntakeLayer), returning bareCollectionConfig[]where the layer contributes only collections or an honest named bundle where it hands back more (chrome:{ globals }; lms/ai:{ collections, hooks }); void-returninginitCatalog/initEconomystay as the single registration call sites, delegated to internally.Naming note for forms consumers:
createFormsCollection(singular factory) vscreateFormsCollections(plural composer) vscreateFormsLayer(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 —productHooksregistration is lazily guarded; NEW explicitregisterBlockBundleProductType()export (root barrel +./registry/productHookssubpath) for deterministic, format-safe registration frompayload.config.ts(the import-time auto path no-ops under native ESM, which affectsgenerate:types-visible product-type options — call the explicit API when composing catalog). chrome:nextis now a required peer (>=14) — it was declared optional whilenext/navigation/next/linkwere hard-imported. readout: declares its realnextpeer;createReadoutBlocks({ accentPalette })is now implemented (field-tree narrowing, dispatch's mechanism) instead of a documented no-op. blocks-lms-pack / blocks-catalog-pack:@wabbit/tome-coremoves from harddependenciestooptionalDependencies, matching org-pack and the packs' own documented degrade-gracefully design.Chrome shell goes server-safe (the audit's remaining clientization item):
HeaderRenderer/FooterRendererdrop'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/sharedsubpath (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.registerLayeris now statically imported (forms/intake pattern) instead of lazilyrequire()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'sadmin-nav/self-register.tsdeliberately keeps its subpathrequire()(documented ESM/CJS dual-cache fix — do not convert).Every package now declares an explicit
sideEffectsfield (38 added; motion/engine/forms already correct).Registration-bearing modules (render files'
registerRenderer,blocks/*/index.tsdefineBlockself-registration, widgetregister.tsfiles, productHooks, permission self-registrations, print templates, chrome built-in variants) are listed so bundlers can tree-shake everything else WITHOUT dropping import-time registrations — previously the field was unset, which blocked cross-module tree-shaking through the barrels entirely. Never blanketfalseon a package with registration or CSS.Small verified fixes: agency-essentials
Contactgains its missing'use client'(it calls the rich-text adapter hook; direct RSC import crashed). chromeNavGuardnow dev-warns when its capability gate fails to load while arequiredCapabilityis set (the fail-open contract itself is unchanged and now documented). blocks-coreBLOCK_CATALOG.tscorrupted entries corrected from real block meta (content-two-column, content-with-corner-notch, signal-ship-card names/descriptions; gallery variants filled) + drift-risk header.Stale docstrings fixed (chrome
HeaderLogo, blocks-gallery registry header, lms-ui payload JSDoc import path). blocks meta-package backcompat suite now asserts the RENDER registry resolves renderers (previously only descriptor registration was tested — a dropped render import shipped silently).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'
FieldRendereris wrapped inReact.memo(call-site props verified stable), cutting whole-step re-render work per keystroke in multi-field forms. lms-ui'suseLearnerPrefsgains optionalinitialPrefsserver-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-corenow ships a standalone Node ESM CLI atscripts/emit-docs-manifests.mjsthat emits per-package documentation manifests (index.json, packages/<slug>.json, changelog.json) by reading what packages already carry — READMEs, the payload-free<pkg>/metablock-usage barrels, package.json exports maps, and CHANGELOG.md. It is the docs-pipeline sibling of the gallery source extractor and is consumed by host sites at prebuild:node node_modules/@wabbit/tome-blocks-core/scripts/emit-docs-manifests.mjs --output-dir <dir> --scope <scope.json>.To let the emitter import block metadata uniformly without dragging Payload config into a build script, the
./metapayload-free subpath (BlockMetaEntry[]) is extended to the remaining offered blocks packs — agency-essentials, catalog-pack, lms-pack, org-pack, and signal-theme — mirroring the existing editorial-pack / marketing-starter / content-writer / extras barrels. Each block'sBlockMetawas relocated verbatim into a payload-free sibling meta module and re-imported by its block config; no meta values changed.Every supported-core package additionally adds
CHANGELOG.mdto its publishedfilesarray so the next publish cascade ships changelogs the emitter can read from installed tarballs at prebuild. - 0.6.1
Platform-wide peer-range sweep: every
workspace:*/workspace:^entry inpeerDependenciesreplaced with an explicit semver range (@wabbit/tome-core >=1.0.0 <2.0.0,tome-ui >=0.9.0 <1.0.0,tome-motion >=0.2.0 <1.0.0,tome-catalog >=1.1.0 <2.0.0,tome-admin >=0.5.0 <1.0.0;tome-crmranges standardized to>=0.2.0 <1.0.0).The workspace protocol publishes as an exact-version pin, so every substrate bump stranded installed dependents — the breakage class proven by marketing@0.1.0/deals@0.1.1 requiring
tome-crm@0.2.0exactly. devDependencies keepworkspace:*for the local link. (@wabbit/tome-admin-progot the same source fix but is rc-versioned; it carries the change on its next intentional release.)tome-crm additionally gains a once-per-process production warning when the capability-registry fallback grants access — the bootstrap heuristic (any authenticated user passes
crm:read) now announces itself instead of running silently on sites that forgot to seed capability grants (2026-06-10 audit hardening item).Graph-truth additions (same hygiene wave): tome-deals declares its lazy print integration as an optional peer (
@wabbit/tome-print >=0.1.0 <1.0.0); tome-intake declares its lazy catalog routing strategy (@wabbit/tome-catalog >=1.1.0 <2.0.0, optional). These were undeclared dynamic imports — invisible to consumers and to pnpm's build topology.
0.4.xMinor
feat(chrome): NavBar4 submenu
layout— compact dropdown vs mega-menuSubmenu blocks (designVersion 4) gain a Submenu Layout field:
mega(default, unchanged — the full-width panel centered under the bar) ordropdown(new — a compact panel pinned directly beneath its own trigger).The
dropdownlayout renders outside the shared Radix mega-menu viewport (a CSS hover / focus-within flyout anchored to itsNavigationMenuItem), 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;megastays 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 themegaMenuBackgroundColorfield 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.Defaultkeeps 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
Secondaryto match an eggplant bar.Implemented by setting the
--tome-nav-surface-bg/--tome-nav-surface-borderCSS 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 optionalLinkComponentprop. 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 ofnext/link'sLink.Mirrors the existing
LogoComponentpattern: chrome propagates the override via internal context (HeaderLinkProvider), so variant + block code stays unchanged. The slot typeHeaderLinkSlotPropsis the standard anchor surface (href,className,children, plus forwarded HTML attributes), so consumers can drop innext/link, a route-transition wrapper, a Remix/Astro<Link>, or any other anchor-shaped component without prop translation.Default behavior is unchanged: when
LinkComponentis omitted, NavLink continues to usenext/link. External links andlink.newTabpaths 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, butblockRenderer.module.css .wrapperkept itsrepeat(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 bymegaContent/ the consumer, not the per-block wrapper. - Nav links jammed together.
.desktopListset no gap and inherited the NavigationMenu primitive's ~4.5px, mashing multi-word labels. It now has a readable1.5remgap (2remat ≥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
megaContentthemselves. - Mega-menu blocks rendered at half width. A refactor split the starter's single
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 withmaxDepth: 1, so internal links arrive with the referenced doc populated andslugattached. NavLink'sresolveHrefwas castingreference.valueto{ id: string }and producing/${relationTo}/${id}, which sent every navbar link to/pages/{mongo-id}instead of/{slug}.New resolution:
/{slug}forrelationTo: 'pages'(the dominant Payload root convention)/whenslug === 'home'/{relationTo}/{slug}for non-pages collections/{relationTo}/{id}fallback when slug is missing orvalueis a raw id string
Consumers whose routing diverges from this contract should pre-resolve to
link.urlupstream of NavLink.TomeLink.reference.valuewidened to include the populated-doc shape so callers no longer need anas { id: string }cast. - 0.1.20
Updated dependencies [1d90b24]
- @wabbit/tome-ui@0.6.1