Chrome
FoundationStableTome 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.
Add our registry to the
.npmrcat the root of your project. Free packages install without a token.@wabbit:registry=https://npm.wabbit.com/Then install:
npm install @wabbit/tome-chrome
Overview
@wabbit/tome-chrome
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.
Install
pnpm add @wabbit/tome-chrome| Peer | Range | |---|---| | @wabbit/tome-core | >=1.14.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 two actions: Call (a tel: link, 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. |
Adding the group adds columns: after upgrading, run your Payload migration and regenerate types.
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. |
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.
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.
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 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".
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 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 (./header subpath) | Top-level header renderer — resolves variant, wraps motion/logo/link context | | FooterRenderer (./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 (./footer subpath) | The 11 built-in footer variants — Footer11 ("Ledger") is the newest, registered via registerBuiltInFooterVariants | | createHeaderGlobal(config), assembleHeaderFields (./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 (./footer/factory) | CSS-clean, config-time-only barrel for wiring the Footer global from payload.config.ts without pulling in footer components/CSS — mirrors ./header/factory | | headerVariantRegistry, footerVariantRegistry, createVariantRegistry (./registry) | Per-surface variant registries — register/get/has/getAll/toFieldOptions | | MobileActionBar, resolveMobileActionBar, resolveHeaderDesktopBreakpointPx, toTelHref (./header subpath) | The phone-width Call + CTA bar HeaderRenderer mounts from header.mobileActionBar, and the helpers that normalise its data and pick its breakpoint, for custom header shells | | HeaderCallButton, resolveHeaderCall (./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 | | ChromeMotionAdapterProvider, useChromeMotionAdapters, NOOP_CHROME_ADAPTERS (./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 (./index re-exports ./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 honours it, Navbar 7 included: there it replaces the stackedlogo/logoDarklayers, sits in a[data-navbar-logo="component"]wrapper that inherits the bar's text colour, 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../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
@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
9979947: Footers: "Cookie Settings" renders only when the footer's new `cookieSettings` field is on. Every built-in footer (1 to 11) rendered the button unconditionally, and it only dispatches an `open-cookie-settings` window event, so with no consent manager mounted (no current consumer mounts one) it was a dead control. The Footer global gains a `cookieSettings` checkbox (default off); `showsCookieSettings(footer)` is exported from the footer shared helpers. Sites that do mount a consent manager turn the field on. Adding the field to the Footer global needs a schema migration on Postgres consumers.
- 9979947: Footers: "Cookie Settings" renders only when the footer's new `cookieSettings` field is on. Every built-in footer (1 to 11) rendered the button unconditionally, and it only dispatches an `open-cookie-settings` window event, so with no consent manager mounted (no current consumer mounts one) it was a dead control. The Footer global gains a `cookieSettings` checkbox (default off); `showsCookieSettings(footer)` is exported from the footer shared helpers. Sites that do mount a consent manager turn the field on. Adding the field to the Footer global needs a schema migration on Postgres consumers.
- 7f6230b: MobileActionBar: no hydration mismatch under reduced motion. `data-motion` came from the motion adapter's `useReducedMotion()` on the first render, which reads `matchMedia` only in the browser (framer's returns null on the server), so the server rendered `full` and a reduced-motion client `reduced`. The attribute is now written only after mount; before hydration the stylesheet's `prefers-reduced-motion` query already drops the slide.
d461ea9: Footer text rows can be bold and can group, and Footer 7's fine print can be inset. - **`emphasis`** (text rows): renders the line, or a label/value pair's label, in `<strong data-footer-text-strong>`, e.g. an office name. - **`groupWithPrevious`** (text rows): the row continues the text row above with no list gap. Footer rows sit a full list gap apart, so an office's name and address lines didn't read as one group. Each group renders as one list item, `<span data-footer-row-group>` holding its rows stacked with `--tome-footer-row-group-gap` (default `0.125rem`), in every built-in footer. A continuing row first in a column or after a link row starts its own item. New export `groupFooterRows()` does the folding for a custom variant (`FooterColumnRow` takes the group's rows as `continuation`). - **`--tome-footer-fine-print-inset`** (Footer 7): a start indent on the fine-print block (default `0`), so a theme can align it to a column. Rows that set neither flag render exactly as before. Both fields are new optional checkboxes on the footer global's column rows; on Postgres, generate a migration.
- d461ea9: Footer text rows can be bold and can group, and Footer 7's fine print can be inset. - **`emphasis`** (text rows): renders the line, or a label/value pair's label, in `<strong data-footer-text-strong>`, e.g. an office name. - **`groupWithPrevious`** (text rows): the row continues the text row above with no list gap. Footer rows sit a full list gap apart, so an office's name and address lines didn't read as one group. Each group renders as one list item, `<span data-footer-row-group>` holding its rows stacked with `--tome-footer-row-group-gap` (default `0.125rem`), in every built-in footer. A continuing row first in a column or after a link row starts its own item. New export `groupFooterRows()` does the folding for a custom variant (`FooterColumnRow` takes the group's rows as `continuation`). - **`--tome-footer-fine-print-inset`** (Footer 7): a start indent on the fine-print block (default `0`), so a theme can align it to a column. Rows that set neither flag render exactly as before. Both fields are new optional checkboxes on the footer global's column rows; on Postgres, generate a migration.
76aea2c: Fix: Navbar 4 menus now close when you follow a link in them, and the mobile menu animates open and closed. - **Menus close on navigation.** The header usually sits in a layout that survives client-side navigation, so an open menu stayed open on the new page. Following any link in a mega panel or the mobile sheet now closes it. A `layout: 'dropdown'` submenu, which opens on hover and focus, now blurs the clicked link and stays hidden until the pointer leaves it or focus comes back, instead of staying open under the cursor. Its trigger's `aria-expanded` follows. - **Mobile sheet motion.** The sheet stays mounted and fades and slides in and out, keyed off `data-state="open" | "closed"`. When closed it is `inert`, so it is out of the tab order and the accessibility tree. With reduced motion it fades without the slide. Escape now closes it. - **Hamburger icon** cross-fades to the close icon with a quarter turn. The toggle carries `data-open` while the sheet is open.
- 76aea2c: Fix: Navbar 4 menus now close when you follow a link in them, and the mobile menu animates open and closed. - **Menus close on navigation.** The header usually sits in a layout that survives client-side navigation, so an open menu stayed open on the new page. Following any link in a mega panel or the mobile sheet now closes it. A `layout: 'dropdown'` submenu, which opens on hover and focus, now blurs the clicked link and stays hidden until the pointer leaves it or focus comes back, instead of staying open under the cursor. Its trigger's `aria-expanded` follows. - **Mobile sheet motion.** The sheet stays mounted and fades and slides in and out, keyed off `data-state="open" | "closed"`. When closed it is `inert`, so it is out of the tab order and the accessibility tree. With reduced motion it fades without the slide. Escape now closes it. - **Hamburger icon** cross-fades to the close icon with a quarter turn. The toggle carries `data-open` while the sheet is open.
1474eda: Header announcement slot, a static header option and a footer bottom-bar hook. The header options are optional, and existing headers render byte-identically when they are unset. **Migration: `headerPosition` is a new Header global field and adds a column.** Sites that register the Header global must run their Payload migration (`payload migrate:create`, then `payload migrate`) and regenerate types (`payload generate:types`) after upgrading. The field has no default, so existing rows stay empty and keep today's sticky header with unchanged markup. `announcementSlot` is a renderer prop only and needs no migration. - **`announcementSlot?: ReactNode` on `HeaderRenderer`.** A code-level strip (for example a live open-now status bar or a notice) rendered immediately before the header frame, outside it, in `<div data-chrome-announcement>`. The wrapper is sticky at `top: 0` with `z-index: var(--tome-chrome-announcement-z)`, defaulting to the frame's `--tome-chrome-frame-z`. It adds no role or tab stop, and an empty strip is hidden. A pinned frame (sticky, or fixed in overlay mode) and Navbar 7's fixed `<nav>` take `top: var(--tome-announcement-height)`, so they sit under the strip. The CMS `announcementBar` surface is unchanged: it is still Phase 3 and accepts only `false`; when it ships it renders into this position. - **`headerPosition: 'sticky' | 'static'`** (Header global field "Header Position"; empty means sticky). `'static'` keeps the header at the top of the page so it scrolls away: the frame becomes `position: relative` (`absolute` in overlay mode) and keeps its z-index so menus still open above the page, and hide-on-scroll is off. With an announcement slot, only the strip stays pinned. A set value is written to `data-header-position` on the frame, beside `data-nav-theme` and `data-nav-scrolled`. Navbar 7 positions its own `<nav>`, so it stays pinned either way. - **Two more height variables** from the header probe, on `:root`: `--tome-announcement-height` (the strip's measured height, `0px` without one) and `--tome-chrome-sticky-height` (the strip plus a sticky or fixed frame). Use the latter for `scroll-margin-top` so in-page anchors land below the pinned chrome. `--site-header-height` is `0px` for a static header, which scrolls away. New exports: `chromeStickyHeight()`, `resolveHeaderPosition()` and the `TomeHeaderPosition` type. - **Footer `data-footer-bottom`** (the counterpart to `data-footer-top`): an empty attribute on the bar that holds the copyright and legal links in footers 2–9 and 11, and on the legal-links row in footer 10, which has no copyright line. Footer 1 has no bar element, so it has no hook. This change only adds the attribute; the rest of the footer markup is unchanged.
- 1474eda: Header announcement slot, a static header option and a footer bottom-bar hook. The header options are optional, and existing headers render byte-identically when they are unset. **Migration: `headerPosition` is a new Header global field and adds a column.** Sites that register the Header global must run their Payload migration (`payload migrate:create`, then `payload migrate`) and regenerate types (`payload generate:types`) after upgrading. The field has no default, so existing rows stay empty and keep today's sticky header with unchanged markup. `announcementSlot` is a renderer prop only and needs no migration. - **`announcementSlot?: ReactNode` on `HeaderRenderer`.** A code-level strip (for example a live open-now status bar or a notice) rendered immediately before the header frame, outside it, in `<div data-chrome-announcement>`. The wrapper is sticky at `top: 0` with `z-index: var(--tome-chrome-announcement-z)`, defaulting to the frame's `--tome-chrome-frame-z`. It adds no role or tab stop, and an empty strip is hidden. A pinned frame (sticky, or fixed in overlay mode) and Navbar 7's fixed `<nav>` take `top: var(--tome-announcement-height)`, so they sit under the strip. The CMS `announcementBar` surface is unchanged: it is still Phase 3 and accepts only `false`; when it ships it renders into this position. - **`headerPosition: 'sticky' | 'static'`** (Header global field "Header Position"; empty means sticky). `'static'` keeps the header at the top of the page so it scrolls away: the frame becomes `position: relative` (`absolute` in overlay mode) and keeps its z-index so menus still open above the page, and hide-on-scroll is off. With an announcement slot, only the strip stays pinned. A set value is written to `data-header-position` on the frame, beside `data-nav-theme` and `data-nav-scrolled`. Navbar 7 positions its own `<nav>`, so it stays pinned either way. - **Two more height variables** from the header probe, on `:root`: `--tome-announcement-height` (the strip's measured height, `0px` without one) and `--tome-chrome-sticky-height` (the strip plus a sticky or fixed frame). Use the latter for `scroll-margin-top` so in-page anchors land below the pinned chrome. `--site-header-height` is `0px` for a static header, which scrolls away. New exports: `chromeStickyHeight()`, `resolveHeaderPosition()` and the `TomeHeaderPosition` type. - **Footer `data-footer-bottom`** (the counterpart to `data-footer-top`): an empty attribute on the bar that holds the copyright and legal links in footers 2–9 and 11, and on the legal-links row in footer 10, which has no copyright line. Footer 1 has no bar element, so it has no hook. This change only adds the attribute; the rest of the footer markup is unchanged.
0d925a4: Counsel v1 chrome and forms hooks. Every new field is optional, and existing output is unchanged when it is empty. **Migration: the new Payload fields add columns and tables.** Sites that register the Footer global, the `forms` collection or the `tomeForm` block must run their Payload migration (`payload migrate:create`, then `payload migrate`) and regenerate types (`payload generate:types`) after upgrading. The footer column-row `link` group becomes conditional (hidden on text rows), so its columns become nullable in that migration; `rowType` defaults to `link`, so existing rows stay links. `@wabbit/tome-chrome` - **Navbar 7 `data-nav-scrolled`.** Navbar 7's existing `isScrolled` state (page scrolled past 20px) is exposed as `data-nav-scrolled="true"` / `"false"` on its `<nav>` root (`"false"` at rest and on the server render) and mirrored onto the header frame (the element carrying `data-visible` / `data-overlay` / `data-nav-theme`) once the navbar hydrates. Frames around the other navbars never carry it. Custom navbars can opt in with the new `useReportNavScrolled(isScrolled)` export. - **Footer `finePrint`** (array of `{ lead?, text }`, admin-visible for designVersion 7): small-type paragraphs Footer 7 renders below the link columns and above the bottom bar as `[data-footer-fine-print]`, each lead in `[data-footer-fine-print-lead]`. Tokens `--tome-footer-fine-print-size` (default `0.75rem`) and `--tome-footer-fine-print-measure` (default `72ch`). Exported as `FooterFinePrint` for other variants. - **Plain-text column rows.** `navItems[].subNavItems[]` gains `rowType` (`link` default | `text`), `text` and an optional `value`. A text row renders `<span data-footer-text-row>`; with a `value` it is a label/value pair, `<dl data-footer-text-row data-footer-pair><dt>…</dt><dd>…</dd></dl>` (Footer 7: label column at least `--tome-footer-pair-label-min`, default `5.5rem`). All eleven footers render both row types through the new shared `FooterColumnRow`; link rows render byte-identically. New types: `TomeFooterColumnRow`, `TomeFooterLinkRow`, `TomeFooterTextRow`, `TomeFooterFinePrintParagraph`. - **Navbar 7 theme tokens:** `--tome-header-pad-block` (header row block padding, default `2rem`, mobile bottom half of it), `--tome-header-curtain-radius` (default `1.5rem`) and `--tome-header-curtain-shadow` (default the previous shadow). Computed styles are unchanged when they are unset. The curtain radius moves from an inline style to the stylesheet, so Navbar 7's curtain `style` attribute no longer carries `border-bottom-*-radius`, and a non-default `desktopBreakpoint` override writes the padding through the same token. - **Navbar 7 curtain fill and blur tokens:** `--tome-header-curtain-bg` (the solid curtain's fill when the nav background is transparent, default the previous 80% page ground) and `--tome-header-curtain-blur` (its backdrop blur, default `12px`, also used by the token-background states). Computed styles are unchanged when they are unset. - **`aria-current="page"`** on any `NavLink` (every navbar and footer) that points at the current pathname (root-relative, trailing slash and query ignored, `#fragment` and external links excluded). Links to the current page gain the attribute, an intended markup change; a caller's own `aria-current` wins. - **`header.menuLabel`** (text, localized, admin-visible for Navbar 7): optional visible text in the menu button beside the icon, `[data-menu-label]`. When set it is the button's accessible name; unset keeps the icon-only button and its "Toggle menu" name. `@wabbit/tome-forms` - **`appearance.stepsDisplay: 'wizard' | 'stacked'`** (default `wizard`, today's behaviour). Stacked renders every step as a numbered `<fieldset>` (`<legend data-form-step-legend>` with `<span data-form-step-number>`), one submit button, and validates every visible step on submit; steps a `skipStep` rule skips stay hidden and unvalidated, and steps after the `isFinalStep` step are not shown. Root hook `data-form-steps="stacked"` on the `<section>`. `defineForm` rejects other values. - **`appearance.submitNote`**: a line beside the submit button, `[data-form-submit-note]`. - Both are on the code `appearance` config, the admin `forms` collection's Appearance group, and the `tomeForm` block's `appearance` group (no block default, so an empty option keeps the form's own setting). - **`tomeForm` block `fieldDefaults[] { name, value }`**: per-placement starting values (e.g. a practice page preselects the matter type), converted for the field type and applied over the definition's `defaultValue`s and under a restored draft, so the visitor's own input always wins. - **`TomeFormBlock`** (`@wabbit/tome-forms/blocks`): the `tomeForm` block renderer. It forwards `fieldDefaults` and only the block's `stepsDisplay` / `submitNote` when set, so a site gets the stacked intake just by setting the block options. The block's older appearance options (`layout`, `progressIndicator`, `themeOverride`) were never forwarded and still are not, so a block without the new options renders byte-identically. `TomeForm` itself gains `appearance` and `fieldDefaults` props for direct callers; without them it renders exactly as before. Also exported: `pickBlockAppearance`, `mergeAppearance`. - **Starting values are server-rendered.** A field's `defaultValue` and the placement's `fieldDefaults` are written into the initial HTML (`<option selected>`, `value`, `checked`), so there is no placeholder flash before hydration. Fields without a starting value render unchanged; forms whose fields declare a `defaultValue` now show it in the server HTML. - **A saved `localStorage` draft is now restored right after mount** (with `reset()`), instead of during the first client render. The first client render now matches the server HTML, where the draft read caused a hydration mismatch before. The draft still wins over starting values. - **BREAKING:** **`react-hook-form` peer floor raised from `>=7.0.0` to `>=7.60.0`** (devDependency `^7.60.0`). The draft restore calls `reset(values, { keepFieldsRef: true })`, and 7.60.0 is the first release whose `reset` honours `keepFieldsRef` (absent from the 7.59.0 types and runtime). Sites on an older react-hook-form must upgrade it.
- 0d925a4: Counsel v1 chrome and forms hooks. Every new field is optional, and existing output is unchanged when it is empty. **Migration: the new Payload fields add columns and tables.** Sites that register the Footer global, the `forms` collection or the `tomeForm` block must run their Payload migration (`payload migrate:create`, then `payload migrate`) and regenerate types (`payload generate:types`) after upgrading. The footer column-row `link` group becomes conditional (hidden on text rows), so its columns become nullable in that migration; `rowType` defaults to `link`, so existing rows stay links. `@wabbit/tome-chrome` - **Navbar 7 `data-nav-scrolled`.** Navbar 7's existing `isScrolled` state (page scrolled past 20px) is exposed as `data-nav-scrolled="true"` / `"false"` on its `<nav>` root (`"false"` at rest and on the server render) and mirrored onto the header frame (the element carrying `data-visible` / `data-overlay` / `data-nav-theme`) once the navbar hydrates. Frames around the other navbars never carry it. Custom navbars can opt in with the new `useReportNavScrolled(isScrolled)` export. - **Footer `finePrint`** (array of `{ lead?, text }`, admin-visible for designVersion 7): small-type paragraphs Footer 7 renders below the link columns and above the bottom bar as `[data-footer-fine-print]`, each lead in `[data-footer-fine-print-lead]`. Tokens `--tome-footer-fine-print-size` (default `0.75rem`) and `--tome-footer-fine-print-measure` (default `72ch`). Exported as `FooterFinePrint` for other variants. - **Plain-text column rows.** `navItems[].subNavItems[]` gains `rowType` (`link` default | `text`), `text` and an optional `value`. A text row renders `<span data-footer-text-row>`; with a `value` it is a label/value pair, `<dl data-footer-text-row data-footer-pair><dt>…</dt><dd>…</dd></dl>` (Footer 7: label column at least `--tome-footer-pair-label-min`, default `5.5rem`). All eleven footers render both row types through the new shared `FooterColumnRow`; link rows render byte-identically. New types: `TomeFooterColumnRow`, `TomeFooterLinkRow`, `TomeFooterTextRow`, `TomeFooterFinePrintParagraph`. - **Navbar 7 theme tokens:** `--tome-header-pad-block` (header row block padding, default `2rem`, mobile bottom half of it), `--tome-header-curtain-radius` (default `1.5rem`) and `--tome-header-curtain-shadow` (default the previous shadow). Computed styles are unchanged when they are unset. The curtain radius moves from an inline style to the stylesheet, so Navbar 7's curtain `style` attribute no longer carries `border-bottom-*-radius`, and a non-default `desktopBreakpoint` override writes the padding through the same token. - **Navbar 7 curtain fill and blur tokens:** `--tome-header-curtain-bg` (the solid curtain's fill when the nav background is transparent, default the previous 80% page ground) and `--tome-header-curtain-blur` (its backdrop blur, default `12px`, also used by the token-background states). Computed styles are unchanged when they are unset. - **`aria-current="page"`** on any `NavLink` (every navbar and footer) that points at the current pathname (root-relative, trailing slash and query ignored, `#fragment` and external links excluded). Links to the current page gain the attribute, an intended markup change; a caller's own `aria-current` wins. - **`header.menuLabel`** (text, localized, admin-visible for Navbar 7): optional visible text in the menu button beside the icon, `[data-menu-label]`. When set it is the button's accessible name; unset keeps the icon-only button and its "Toggle menu" name. `@wabbit/tome-forms` - **`appearance.stepsDisplay: 'wizard' | 'stacked'`** (default `wizard`, today's behaviour). Stacked renders every step as a numbered `<fieldset>` (`<legend data-form-step-legend>` with `<span data-form-step-number>`), one submit button, and validates every visible step on submit; steps a `skipStep` rule skips stay hidden and unvalidated, and steps after the `isFinalStep` step are not shown. Root hook `data-form-steps="stacked"` on the `<section>`. `defineForm` rejects other values. - **`appearance.submitNote`**: a line beside the submit button, `[data-form-submit-note]`. - Both are on the code `appearance` config, the admin `forms` collection's Appearance group, and the `tomeForm` block's `appearance` group (no block default, so an empty option keeps the form's own setting). - **`tomeForm` block `fieldDefaults[] { name, value }`**: per-placement starting values (e.g. a practice page preselects the matter type), converted for the field type and applied over the definition's `defaultValue`s and under a restored draft, so the visitor's own input always wins. - **`TomeFormBlock`** (`@wabbit/tome-forms/blocks`): the `tomeForm` block renderer. It forwards `fieldDefaults` and only the block's `stepsDisplay` / `submitNote` when set, so a site gets the stacked intake just by setting the block options. The block's older appearance options (`layout`, `progressIndicator`, `themeOverride`) were never forwarded and still are not, so a block without the new options renders byte-identically. `TomeForm` itself gains `appearance` and `fieldDefaults` props for direct callers; without them it renders exactly as before. Also exported: `pickBlockAppearance`, `mergeAppearance`. - **Starting values are server-rendered.** A field's `defaultValue` and the placement's `fieldDefaults` are written into the initial HTML (`<option selected>`, `value`, `checked`), so there is no placeholder flash before hydration. Fields without a starting value render unchanged; forms whose fields declare a `defaultValue` now show it in the server HTML. - **A saved `localStorage` draft is now restored right after mount** (with `reset()`), instead of during the first client render. The first client render now matches the server HTML, where the draft read caused a hydration mismatch before. The draft still wins over starting values. - **BREAKING:** **`react-hook-form` peer floor raised from `>=7.0.0` to `>=7.60.0`** (devDependency `^7.60.0`). The draft restore calls `reset(values, { keepFieldsRef: true })`, and 7.60.0 is the first release whose `reset` honours `keepFieldsRef` (absent from the 7.59.0 types and runtime). Sites on an older react-hook-form must upgrade it.
79b2c80: Groundwork v1.1 chrome batch. - **Fix: footer `backgroundColor` options now paint declared tokens.** `getFooterBackgroundStyle` interpolated the option name, so `muted` resolved to `--tome-color-muted` / `--tome-color-muted-foreground` and `card` to `--tome-color-card`, none of which tome-ui declares, and every brand option's ink used a `-foreground` name that does not exist at Layer 2. Each option now maps to its declared pair: `muted` → `--tome-color-surface-muted` / `--tome-color-on-surface-muted`, `card` → `--tome-color-surface` / `--tome-color-on-surface`, `primary`/`secondary`/`accent` → `--tome-color-{role}` / `--tome-color-on-{role}`. An unknown value now renders transparent with inherited ink. Applies to every footer variant (they all share the helper). - **Fix: header `backgroundColor` / `backgroundEffect` now paint declared tokens.** `getHeaderBackgroundColor` mapped `muted`/`card` to the undeclared `--tome-color-muted`/`--tome-color-card` and built translucent/glass from nonexistent `--tome-color-*-hsl` companions. It now maps `muted` → `--tome-color-surface-muted`, `card` → `--tome-color-surface`, and composes translucent (70%) and glass (50%) with `color-mix(in oklch, var(<fill>) N%, transparent)`, so no extra tokens are needed. Covers navbars 1, 3, 4 (including the mega-menu surface), 5 and 6. An unknown token now renders the flat background. - **`data-footer-top` hook** on the top row (brand/intro above or beside the link columns) of footers 1, 2, 4, 6, 7, 8, 9, 10 and 11. - **Header Call button (`headerCall` group: `enabled`, `label`, `phone`).** A compact `tel:` action with a phone icon and a "Call" label beside the hamburger in all seven navbars, hidden from the navbar's desktop breakpoint up; the menu button stays. The phone falls back to `mobileActionBar.phone`. Hook: `[data-header-call]`; tokens `--tome-header-call-*`. Exports `HeaderCallButton` and `resolveHeaderCall`. Off by default; adds columns, so run your Payload migration and regenerate types. - **Header CTA `inline` appearance** renders as a plain text link (no button chrome, the header's link color, the shared focus ring) instead of falling back to filled. New optional hook `--tome-header-cta-inline-fg`. - **Fix: Navbars 5, 6 and 7 key list items by position when a nav item has no `id`.** `TomeNavItem.id` is optional (code-defined menus have none), so those navbars logged React's duplicate-key warning; they now fall back to the item index, as Navbars 1 and 2 already did.
- 79b2c80: Groundwork v1.1 chrome batch. - **Fix: footer `backgroundColor` options now paint declared tokens.** `getFooterBackgroundStyle` interpolated the option name, so `muted` resolved to `--tome-color-muted` / `--tome-color-muted-foreground` and `card` to `--tome-color-card`, none of which tome-ui declares, and every brand option's ink used a `-foreground` name that does not exist at Layer 2. Each option now maps to its declared pair: `muted` → `--tome-color-surface-muted` / `--tome-color-on-surface-muted`, `card` → `--tome-color-surface` / `--tome-color-on-surface`, `primary`/`secondary`/`accent` → `--tome-color-{role}` / `--tome-color-on-{role}`. An unknown value now renders transparent with inherited ink. Applies to every footer variant (they all share the helper). - **Fix: header `backgroundColor` / `backgroundEffect` now paint declared tokens.** `getHeaderBackgroundColor` mapped `muted`/`card` to the undeclared `--tome-color-muted`/`--tome-color-card` and built translucent/glass from nonexistent `--tome-color-*-hsl` companions. It now maps `muted` → `--tome-color-surface-muted`, `card` → `--tome-color-surface`, and composes translucent (70%) and glass (50%) with `color-mix(in oklch, var(<fill>) N%, transparent)`, so no extra tokens are needed. Covers navbars 1, 3, 4 (including the mega-menu surface), 5 and 6. An unknown token now renders the flat background. - **`data-footer-top` hook** on the top row (brand/intro above or beside the link columns) of footers 1, 2, 4, 6, 7, 8, 9, 10 and 11. - **Header Call button (`headerCall` group: `enabled`, `label`, `phone`).** A compact `tel:` action with a phone icon and a "Call" label beside the hamburger in all seven navbars, hidden from the navbar's desktop breakpoint up; the menu button stays. The phone falls back to `mobileActionBar.phone`. Hook: `[data-header-call]`; tokens `--tome-header-call-*`. Exports `HeaderCallButton` and `resolveHeaderCall`. Off by default; adds columns, so run your Payload migration and regenerate types. - **Header CTA `inline` appearance** renders as a plain text link (no button chrome, the header's link color, the shared focus ring) instead of falling back to filled. New optional hook `--tome-header-cta-inline-fg`. - **Fix: Navbars 5, 6 and 7 key list items by position when a nav item has no `id`.** `TomeNavItem.id` is optional (code-defined menus have none), so those navbars logged React's duplicate-key warning; they now fall back to the item index, as Navbars 1 and 2 already did.
846498d: Header links to `/#id` now jump to that section on the current page when the page has it. A "Start a project" button pointing at `/#scope` goes to the page's own `#scope` form when one exists, and to `/#scope` otherwise. The link is re-evaluated after mount and on every route change; server markup is unchanged.
- 846498d: Header links to `/#id` now jump to that section on the current page when the page has it. A "Start a project" button pointing at `/#scope` goes to the page's own `#scope` form when one exists, and to `/#scope` otherwise. The link is re-evaluated after mount and on every route change; server markup is unchanged.
5693b19: The header publishes its real height in every mode. `SiteHeaderHeightProbe` now writes `--site-header-bar-height` on `:root` alongside `--site-header-height`. The existing variable is unchanged: it is the flow space the header takes, so it reads `0px` in overlay mode. The new one is the bar's rendered height even when the header floats, so a hero under a floating header can keep its first line clear of the bar on a short screen (`padding-top: max(designed, calc(var(--site-header-bar-height, 4.5rem) + gap))`). The calculation is exported as `headerHeights()`. Nothing renders differently until a site reads the new variable.
- 5693b19: The header publishes its real height in every mode. `SiteHeaderHeightProbe` now writes `--site-header-bar-height` on `:root` alongside `--site-header-height`. The existing variable is unchanged: it is the flow space the header takes, so it reads `0px` in overlay mode. The new one is the bar's rendered height even when the header floats, so a hero under a floating header can keep its first line clear of the bar on a short screen (`padding-top: max(designed, calc(var(--site-header-bar-height, 4.5rem) + gap))`). The calculation is exported as `headerHeights()`. Nothing renders differently until a site reads the new variable.
a592671: 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.
- a592671: 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.
5158acf: Header CTA buttons look like buttons. Until now the Header global's `buttons` rendered as bare text with padding (no fill, no border), so a site's main header action read as another nav link. They now render filled by default (`--tome-color-primary` / `--tome-color-on-primary`) and bordered when the link's `appearance` is `outline`, with a 2.75rem minimum height (44px tap target) and a focus ring. - New theme hooks, all optional and prefixed `--tome-header-cta`: `-bg`, `-fg`, `-radius`, `-font`, `-size`, `-weight`, `-tracking`, `-case`, `-padding`, `-min-height`. The type defaults to the header's own. - The variant is applied as a class as well as `data-appearance`, so it survives a `LinkComponent` that doesn't forward data attributes. Visible change: any site with header buttons sees them filled in its primary color after upgrading. Set `--tome-header-cta-bg` / `-fg` to keep a different pair, or give the button `appearance: 'outline'`.
- 5158acf: Header CTA buttons look like buttons. Until now the Header global's `buttons` rendered as bare text with padding (no fill, no border), so a site's main header action read as another nav link. They now render filled by default (`--tome-color-primary` / `--tome-color-on-primary`) and bordered when the link's `appearance` is `outline`, with a 2.75rem minimum height (44px tap target) and a focus ring. - New theme hooks, all optional and prefixed `--tome-header-cta`: `-bg`, `-fg`, `-radius`, `-font`, `-size`, `-weight`, `-tracking`, `-case`, `-padding`, `-min-height`. The type defaults to the header's own. - The variant is applied as a class as well as `data-appearance`, so it survives a `LinkComponent` that doesn't forward data attributes. Visible change: any site with header buttons sees them filled in its primary color after upgrading. Set `--tome-header-cta-bg` / `-fg` to keep a different pair, or give the button `appearance: 'outline'`.
b44a7f4: Navbar open state for assistive tech. The Navbar4 `layout: 'dropdown'` trigger and the Navbar2 sub-menu trigger now expose `aria-expanded` (mirroring the CSS :hover / :focus-within that opens the flyout, via a new `useFlyoutState` helper) and `aria-controls` pointing at the panel. The Navbar4 hamburger exposes `aria-expanded` and `aria-controls` for the mobile sheet. No visual change. Known gap: Navbar2's sub-menu trigger is still a non-focusable `<span>`, so keyboard users can't open it; turning it into a button needs a styling pass.
- b44a7f4: Navbar open state for assistive tech. The Navbar4 `layout: 'dropdown'` trigger and the Navbar2 sub-menu trigger now expose `aria-expanded` (mirroring the CSS :hover / :focus-within that opens the flyout, via a new `useFlyoutState` helper) and `aria-controls` pointing at the panel. The Navbar4 hamburger exposes `aria-expanded` and `aria-controls` for the mobile sheet. No visual change. Known gap: Navbar2's sub-menu trigger is still a non-focusable `<span>`, so keyboard users can't open it; turning it into a button needs a styling pass.
e6037f6: Navbar 7 now honours a `LogoComponent` override, and footers gain `--tome-footer-pad-block`, `--tome-footer-nav-cols` and a `data-footer-nav` hook. Navbar 7 used to render its stacked `logo`/`logoDark` images even when `HeaderRenderer` received a `LogoComponent`. The override now replaces them inside a `[data-navbar-logo="component"]` wrapper that inherits the bar's text colour, and receives `isDarkModeEnabled: true` whenever the bar wants its light-on-dark logo. Without an override, the `navTheme` image behaviour is unchanged. Footers 1–5, 7 and 8 read their block padding from `--tome-footer-pad-block` (default `8rem`). Footer 7's link grid carries `data-footer-nav` and reads its column count from `--tome-footer-nav-cols` (default `3`). The defaults match the previous fixed values, so nothing changes until a theme sets them.
- e6037f6: Navbar 7 now honours a `LogoComponent` override, and footers gain `--tome-footer-pad-block`, `--tome-footer-nav-cols` and a `data-footer-nav` hook. Navbar 7 used to render its stacked `logo`/`logoDark` images even when `HeaderRenderer` received a `LogoComponent`. The override now replaces them inside a `[data-navbar-logo="component"]` wrapper that inherits the bar's text colour, and receives `isDarkModeEnabled: true` whenever the bar wants its light-on-dark logo. Without an override, the `navTheme` image behaviour is unchanged. Footers 1–5, 7 and 8 read their block padding from `--tome-footer-pad-block` (default `8rem`). Footer 7's link grid carries `data-footer-nav` and reads its column count from `--tome-footer-nav-cols` (default `3`). The defaults match the previous fixed values, so nothing changes until a theme sets them.
b2646c3: The Header global gains an optional `mobileActionBar` group: a phone-width bar fixed to the bottom of the screen with a Call (`tel:`) action and one primary CTA, available on every navbar variant. **Migration:** this adds fields to the Header global, so run your Payload migration (`payload migrate:create`, then `payload migrate` on SQL databases) and regenerate your Payload types after upgrading. Nothing renders until an editor turns it on: `mobileActionBar.enabled` defaults to `false`, and Header documents without the group render byte-identically. The group holds `enabled`, `callLabel` (localized; blank means "Call"), `phone` (written for people, normalised to a `tel:` link), `ctaLabel` (localized) and `ctaHref`; each action renders only when its fields are complete. `HeaderRenderer` mounts the bar beside the navbar, with no new props. It shows only below the active navbar's desktop breakpoint (`64em` for navbars 1, 3, 4, 5 and custom variants, `48em` for 2 and 6, `desktopBreakpoint` for 7), slides in once the page has scrolled past about the first viewport and hides again near the top, respects safe-area insets, adds its height to the body's bottom padding while visible (also published as `--tome-mobile-action-bar-offset`), and is `inert` while hidden. Under reduced motion it appears without the slide; without JavaScript it is always visible on phones. Theme hooks: `[data-tome-mobile-action-bar]`, `[data-action="call"]`, `[data-action="cta"]` and `--tome-mobile-action-bar-*` custom properties. New exports from `./header`: `MobileActionBar`, `resolveMobileActionBar`, `resolveHeaderDesktopBreakpointPx`, `toTelHref`; new type `TomeMobileActionBarData`.
- b2646c3: The Header global gains an optional `mobileActionBar` group: a phone-width bar fixed to the bottom of the screen with a Call (`tel:`) action and one primary CTA, available on every navbar variant. **Migration:** this adds fields to the Header global, so run your Payload migration (`payload migrate:create`, then `payload migrate` on SQL databases) and regenerate your Payload types after upgrading. Nothing renders until an editor turns it on: `mobileActionBar.enabled` defaults to `false`, and Header documents without the group render byte-identically. The group holds `enabled`, `callLabel` (localized; blank means "Call"), `phone` (written for people, normalised to a `tel:` link), `ctaLabel` (localized) and `ctaHref`; each action renders only when its fields are complete. `HeaderRenderer` mounts the bar beside the navbar, with no new props. It shows only below the active navbar's desktop breakpoint (`64em` for navbars 1, 3, 4, 5 and custom variants, `48em` for 2 and 6, `desktopBreakpoint` for 7), slides in once the page has scrolled past about the first viewport and hides again near the top, respects safe-area insets, adds its height to the body's bottom padding while visible (also published as `--tome-mobile-action-bar-offset`), and is `inert` while hidden. Under reduced motion it appears without the slide; without JavaScript it is always visible on phones. Theme hooks: `[data-tome-mobile-action-bar]`, `[data-action="call"]`, `[data-action="cta"]` and `--tome-mobile-action-bar-*` custom properties. New exports from `./header`: `MobileActionBar`, `resolveMobileActionBar`, `resolveHeaderDesktopBreakpointPx`, `toTelHref`; new type `TomeMobileActionBarData`.
- fe163ec: The Header global gains a `navTheme` option ("Transparent Header Contrast") so Navbar7's transparent at-rest state can sit over a light hero, and every built-in footer now carries `data-footer-variant`. **Migration:** `navTheme` is a new Header global field, so run your Payload migration (`payload migrate:create`, then `payload migrate` on SQL databases) and regenerate your Payload types after upgrading. It defaults to `'auto'`, which keeps the existing look. `navTheme` is `'auto'` (default: light text and logo for a dark hero, as before), `'light'` (dark text and the main `logo` for a light hero) or `'dark'`. Navbar7 applies it to its transparent at-rest state; the scrolled and open solid state still follows the Background Color tokens. The logo swap uses the existing `logo` / `logoDark` pair, with the CSS-inverted `logo` as the fallback when no `logoDark` is set. Every variant writes the resolved value to `data-nav-theme` on the header frame for theme CSS. New type `TomeNavTheme`. Footers: each built-in footer writes `data-footer-variant="<key>"` on its root element, using the key `FooterRenderer` resolved (the fallback variant's key when `designVersion` is not registered), so themes can target a footer design without relying on the landmark element. `TomeFooterVariantProps` gains an optional `variantKey`, which `FooterRenderer` passes to custom variants so they can emit the same attribute.
4bf6582: 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.
- 4bf6582: 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.
001c585: Header navbars accept an optional `accountSlot` for a Log in / Account control, and Navbar7 can now show the search, language and theme controls when `showHeaderActions` is on. `accountSlot?: ReactNode | ((ctx: 'desktop' | 'drawer') => ReactNode)` is new on `HeaderRenderer` and `TomeNavbarProps` (new exported types `TomeAccountSlot` and `TomeAccountSlotContext`). A node renders in both places; a function is called once per place so the desktop and drawer copies can differ. Navbar7 renders it immediately before the CTA buttons in its desktop right group and in its drawer button row, which becomes a row of equal-width cells only when the slot is provided. Navbars 1 to 5 render it after the theme toggle and before the CTAs on desktop and in their mobile menus. Navbar6 does not render it. Nothing changes when the prop is absent: existing markup is byte-identical, and the wrapper adds no role or tab stop. `showHeaderActions?: boolean` (default `false`) is new on `HeaderRenderer` and `TomeNavbarProps`; navbars 1 to 5 already render `searchAdapter`, `languageSwitcherSlot` and `themeToggleSlot`, but Navbar7 ignored them, and sites that already pass them stay unchanged until they opt in. When on, Navbar7's desktop group reads search, language, theme, account slot, CTAs; in the mobile drawer the language and theme controls sit in a row above the account/CTA row and search sits beside the hamburger. The controls inherit Navbar7's text color and the search trigger is 44px square. It is a prop, not a Header global field, so there is no schema change or migration. Other navbars ignore it. A function value cannot cross a server-to-client boundary, so pass a node from a server layout.
- 001c585: Header navbars accept an optional `accountSlot` for a Log in / Account control, and Navbar7 can now show the search, language and theme controls when `showHeaderActions` is on. `accountSlot?: ReactNode | ((ctx: 'desktop' | 'drawer') => ReactNode)` is new on `HeaderRenderer` and `TomeNavbarProps` (new exported types `TomeAccountSlot` and `TomeAccountSlotContext`). A node renders in both places; a function is called once per place so the desktop and drawer copies can differ. Navbar7 renders it immediately before the CTA buttons in its desktop right group and in its drawer button row, which becomes a row of equal-width cells only when the slot is provided. Navbars 1 to 5 render it after the theme toggle and before the CTAs on desktop and in their mobile menus. Navbar6 does not render it. Nothing changes when the prop is absent: existing markup is byte-identical, and the wrapper adds no role or tab stop. `showHeaderActions?: boolean` (default `false`) is new on `HeaderRenderer` and `TomeNavbarProps`; navbars 1 to 5 already render `searchAdapter`, `languageSwitcherSlot` and `themeToggleSlot`, but Navbar7 ignored them, and sites that already pass them stay unchanged until they opt in. When on, Navbar7's desktop group reads search, language, theme, account slot, CTAs; in the mobile drawer the language and theme controls sit in a row above the account/CTA row and search sits beside the hamburger. The controls inherit Navbar7's text color and the search trigger is 44px square. It is a prop, not a Header global field, so there is no schema change or migration. Other navbars ignore it. A function value cannot cross a server-to-client boundary, so pass a node from a server layout.
775f90a: Published packages now contain compiled JavaScript and type declarations under a one-line licence banner, and no longer include source maps. What you install: one compiled `.js` (ESM) and `.cjs` (CommonJS) file per source module, its `.d.ts` / `.d.cts` declarations, and the stylesheets, fonts and other assets a package already shipped. Every JavaScript module opens with a comment naming the package and its licence: `/*! @wabbit/<package> — © Wabbit, LLC. Wabbit Tome Commercial License (see LICENSE.md). Not for redistribution. */`. The `.map` files and the `sourceMappingURL` comments that pointed at them are gone, which roughly halves the size of each tarball. Debugging: the code is still unbundled and unminified, one readable file per module, so a stack trace points at real code with real names. Line numbers in a stack trace are one higher than before, because of the banner line. A `'use client'` directive stays the first statement of its module (the banner is a comment above it), so React Server Component boundaries are unchanged. No API change, no runtime behaviour change, and nothing to do on upgrade. In `@wabbit/tome-blocks-gallery`, the source snapshots `extractGallerySource` writes from an installed pack leave out the licence 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.
- 775f90a: Published packages now contain compiled JavaScript and type declarations under a one-line licence banner, and no longer include source maps. What you install: one compiled `.js` (ESM) and `.cjs` (CommonJS) file per source module, its `.d.ts` / `.d.cts` declarations, and the stylesheets, fonts and other assets a package already shipped. Every JavaScript module opens with a comment naming the package and its licence: `/*! @wabbit/<package> — © Wabbit, LLC. Wabbit Tome Commercial License (see LICENSE.md). Not for redistribution. */`. The `.map` files and the `sourceMappingURL` comments that pointed at them are gone, which roughly halves the size of each tarball. Debugging: the code is still unbundled and unminified, one readable file per module, so a stack trace points at real code with real names. Line numbers in a stack trace are one higher than before, because of the banner line. A `'use client'` directive stays the first statement of its module (the banner is a comment above it), so React Server Component boundaries are unchanged. No API change, no runtime behaviour change, and nothing to do on upgrade. In `@wabbit/tome-blocks-gallery`, the source snapshots `extractGallerySource` writes from an installed pack leave out the licence 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.
Navbar overrides of tome-ui components now win by specificity, not stylesheet order. Where a navbar passes its own class to a tome-ui component (NavigationMenu, its List, Trigger and Content, Button, SheetContent, SheetTitle) and both set the same property at equal specificity, which one won depended on the order Next emitted the two stylesheets, and that order can change whenever a site's CSS chunks change. On wabbit-site-core it flipped and collapsed Navbar 4's desktop link spacing to tome-ui's ~4.5px gap and showed the mobile menu button at desktop width. Each such override now has a `.x.x` companion rule carrying only the contested properties (17 across Navbar 3, 4, 5, 6 and MobileNavSheet). tome-ui's variant and state rules, which already win on specificity, are untouched, so no navbar changes where the override was already winning: tome-starter (Navbar 5) is pixel-identical before and after.
- Navbar overrides of tome-ui components now win by specificity, not stylesheet order. Where a navbar passes its own class to a tome-ui component (NavigationMenu, its List, Trigger and Content, Button, SheetContent, SheetTitle) and both set the same property at equal specificity, which one won depended on the order Next emitted the two stylesheets, and that order can change whenever a site's CSS chunks change. On wabbit-site-core it flipped and collapsed Navbar 4's desktop link spacing to tome-ui's ~4.5px gap and showed the mobile menu button at desktop width. Each such override now has a `.x.x` companion rule carrying only the contested properties (17 across Navbar 3, 4, 5, 6 and MobileNavSheet). tome-ui's variant and state rules, which already win on specificity, are untouched, so no navbar changes where the override was already winning: tome-starter (Navbar 5) is pixel-identical before and after.
ec2efc7: Fix: Navbar 5's plain nav links and dropdown triggers now share one consistent, accessible interaction style, and the logo's home link gets a real accessible name. Plain links were muted at rest and filled cream on both hover and plain `:focus` (the browser's default outline); dropdown triggers stayed ink (`--tome-color-foreground`) at rest. All items now rest muted, fill on hover, and share one `:focus-visible` ring using the same tokens as `@wabbit/tome-ui`'s `NavigationMenu` trigger — no more mismatched default-outline-vs-ring behavior between links and dropdowns. Plain links in Navbar 5 are also now wrapped in `NavigationMenuItem` (a proper `<li>`) so the menu `<ul>` only has `<li>` children, matching Navbar 1/6/7. The logo's home link had no accessible name of its own in Navbar 5, 6 and 7, so screen readers fell back to the logo image's `alt` text (often a placeholder like "Demo placeholder logo"). `HeaderLogo` accepts an optional `alt` override (defaults to the logo media's own alt, as before); the Header global gains an optional `homeLabel` text field ("Home Link Label") consumers can set to their site name. Navbar 5/6/7's home link now carries an `aria-label` built from `homeLabel`, falling back to the logo's alt text when it reads as real content, else "Home" — and blanks the rendered `<img>`'s `alt` (via `HeaderLogo`'s new `alt=""`) so the name isn't announced twice. Navbar 1/2/3/4 already hardcode `aria-label="Home"` and are unaffected. No migration needed — `homeLabel` is optional on the existing Mongo-backed Header global.
- ec2efc7: Fix: Navbar 5's plain nav links and dropdown triggers now share one consistent, accessible interaction style, and the logo's home link gets a real accessible name. Plain links were muted at rest and filled cream on both hover and plain `:focus` (the browser's default outline); dropdown triggers stayed ink (`--tome-color-foreground`) at rest. All items now rest muted, fill on hover, and share one `:focus-visible` ring using the same tokens as `@wabbit/tome-ui`'s `NavigationMenu` trigger — no more mismatched default-outline-vs-ring behavior between links and dropdowns. Plain links in Navbar 5 are also now wrapped in `NavigationMenuItem` (a proper `<li>`) so the menu `<ul>` only has `<li>` children, matching Navbar 1/6/7. The logo's home link had no accessible name of its own in Navbar 5, 6 and 7, so screen readers fell back to the logo image's `alt` text (often a placeholder like "Demo placeholder logo"). `HeaderLogo` accepts an optional `alt` override (defaults to the logo media's own alt, as before); the Header global gains an optional `homeLabel` text field ("Home Link Label") consumers can set to their site name. Navbar 5/6/7's home link now carries an `aria-label` built from `homeLabel`, falling back to the logo's alt text when it reads as real content, else "Home" — and blanks the rendered `<img>`'s `alt` (via `HeaderLogo`'s new `alt=""`) so the name isn't announced twice. Navbar 1/2/3/4 already hardcode `aria-label="Home"` and are unaffected. No migration needed — `homeLabel` is optional on the existing Mongo-backed Header global.
e2f1705: Dark bands can follow the site's palette, and their accent text passes AA. `--tome-color-surface-solid-dark` now reads an optional ThemeConfig-owned `--surface-solid-dark` input (for example the site's ink); unset, it stays black. A new `--tome-color-primary-on-solid-dark` token is the brand accent for text on that surface. It lifts `primary`'s OKLCH lightness to a 0.66 floor with hue and chroma kept, which clears 5.5:1 on near-black across hues; an already-light primary passes through unchanged. A color-mix fallback covers engines without relative color syntax, and a site can pin an exact value with `--primary-on-solid-dark`. The starter's oxide had measured 3.85:1 on black and 3.39:1 on its ink, against AA's 4.5:1. `footer11`'s column labels use the new token, falling back to `primary` on a tome-ui without it.
- e2f1705: Dark bands can follow the site's palette, and their accent text passes AA. `--tome-color-surface-solid-dark` now reads an optional ThemeConfig-owned `--surface-solid-dark` input (for example the site's ink); unset, it stays black. A new `--tome-color-primary-on-solid-dark` token is the brand accent for text on that surface. It lifts `primary`'s OKLCH lightness to a 0.66 floor with hue and chroma kept, which clears 5.5:1 on near-black across hues; an already-light primary passes through unchanged. A color-mix fallback covers engines without relative color syntax, and a site can pin an exact value with `--primary-on-solid-dark`. The starter's oxide had measured 3.85:1 on black and 3.39:1 on its ink, against AA's 4.5:1. `footer11`'s column labels use the new token, falling back to `primary` on a tome-ui without it.
4e333d9: Navbar7's `desktopBreakpoint` breakpoint-override CSS is now emitted as a hoisted stylesheet (`<style href precedence>`) instead of a plain child `<style>` tag inside `<nav>`. Previously, setting `desktopBreakpoint` to any value other than the default (768) inserted a `<style>` element as the first child of `<nav>`, shifting the position of every element after it. A consumer styling Navbar7 with structural selectors on the nav's own children (for example `nav[data-nav-bg] > div:first-child` for the background curtain, or `> div:nth-child(N)` for another structural element) would see those selectors resolve to the wrong element — breaking layout at every viewport width, not only inside the overridden breakpoint range. The default breakpoint (768) was unaffected because it renders no override `<style>` at all. The override CSS is unchanged; only its placement changed. It no longer appears anywhere inside `<nav>` — React hoists it into `<head>` during server rendering, deduped by an `href` derived from the configured breakpoint. Consumers who added defensive workarounds for the shifted structural indices (skipping past a `<style>` child, adjusting `:nth-child` offsets) should remove them.
- 4e333d9: Navbar7's `desktopBreakpoint` breakpoint-override CSS is now emitted as a hoisted stylesheet (`<style href precedence>`) instead of a plain child `<style>` tag inside `<nav>`. Previously, setting `desktopBreakpoint` to any value other than the default (768) inserted a `<style>` element as the first child of `<nav>`, shifting the position of every element after it. A consumer styling Navbar7 with structural selectors on the nav's own children (for example `nav[data-nav-bg] > div:first-child` for the background curtain, or `> div:nth-child(N)` for another structural element) would see those selectors resolve to the wrong element — breaking layout at every viewport width, not only inside the overridden breakpoint range. The default breakpoint (768) was unaffected because it renders no override `<style>` at all. The override CSS is unchanged; only its placement changed. It no longer appears anywhere inside `<nav>` — React hoists it into `<head>` during server rendering, deduped by an `href` derived from the configured breakpoint. Consumers who added defensive workarounds for the shifted structural indices (skipping past a `<style>` child, adjusting `:nth-child` offsets) should remove them.
820f8dd: Navbar7 (Expandable Curtain) now takes an optional `desktopBreakpoint` field on the Header global, moving its mobile↔desktop switch off the fixed 768px point. The Payload field ("Desktop Breakpoint (px)") shows only when `designVersion` is `'7'`. Left unset, it defaults to 768 — every existing Header document renders and behaves exactly as before. Set to e.g. `1024`, both the CSS layout and the hover-vs-click dropdown behavior move together to the new value; they read the same resolved number, so they can no longer disagree with each other. This is purely additive: no new required field, no changed default, no peer or type change, so it ships as a patch per this package's 0.x convention.
- 820f8dd: Navbar7 (Expandable Curtain) now takes an optional `desktopBreakpoint` field on the Header global, moving its mobile↔desktop switch off the fixed 768px point. The Payload field ("Desktop Breakpoint (px)") shows only when `designVersion` is `'7'`. Left unset, it defaults to 768 — every existing Header document renders and behaves exactly as before. Set to e.g. `1024`, both the CSS layout and the hover-vs-click dropdown behavior move together to the new value; they read the same resolved number, so they can no longer disagree with each other. This is purely additive: no new required field, no changed default, no peer or type change, so it ships as a patch per this package's 0.x convention.
6530765: CSS files are now copied to `dist/` by a post-build script instead of a tsup `onSuccess` hook; no behaviour change, and the published `dist/` is identical.
- 6530765: CSS files are now copied to `dist/` by a post-build script instead of a tsup `onSuccess` hook; no behaviour change, and the published `dist/` is identical.
- 77dc851: `HeaderActions`, `HeaderCTAButtons` and `HeaderSearchButton` no longer declare `'use client'`, so a server-rendered custom navbar can use them directly, including passing a `searchAdapter` function; the built-in navbars render exactly as before.
0041ab0: Internal refactor: collection slugs are now typed through the shared `typedSlug()` helper instead of inline casts. No API or behaviour change.
- 0041ab0: Internal refactor: collection slugs are now typed through the shared `typedSlug()` helper instead of inline casts. No API or behaviour change.
- 7785db0: `NavGuard` now passes its capability to `@wabbit/tome-core`'s `CapabilityGate` as the `requires` list the gate actually accepts. It previously passed a singular `capability` prop, which left `requires` undefined and crashed any guarded nav item with `Cannot read properties of undefined (reading 'join')` in every install where `tome-core` was present; the fail-open path (gate package absent) was the only one that ever worked. The new test suite pins both paths through a test seam, so the contract can no longer drift silently.
93825d3: Navbar7 now closes its mobile drawer and any open mega-dropdown on every route change (`usePathname` effect) and immediately on link click, instead of leaving them open indefinitely. The App Router keeps a shared layout's header mounted across `<Link>` navigation, so Navbar7 was never unmounted between routes — its `isMobileMenuOpen`/`activeDropdownId` state carried over to the destination page, leaving the curtain expanded (and body scroll locked) over the newly-navigated content. Fixes the "mega menu / drawer stays open after clicking a link" defect reported on a production consumer site 2026-09-23.
- 93825d3: Navbar7 now closes its mobile drawer and any open mega-dropdown on every route change (`usePathname` effect) and immediately on link click, instead of leaving them open indefinitely. The App Router keeps a shared layout's header mounted across `<Link>` navigation, so Navbar7 was never unmounted between routes — its `isMobileMenuOpen`/`activeDropdownId` state carried over to the destination page, leaving the curtain expanded (and body scroll locked) over the newly-navigated content. Fixes the "mega menu / drawer stays open after clicking a link" defect reported on a production consumer site 2026-09-23.
6b11cea: Export `Footer11` (the Ledger footer) from `./footer` and the root barrel. It has been registered as variant key `'11'` in `footerVariantRegistry` since it landed, so it was selectable in the Footer global but could not be imported by a consumer — README, package description and source comments all still said 10 footers. Now 11 everywhere.
- 6b11cea: Export `Footer11` (the Ledger footer) from `./footer` and the root barrel. It has been registered as variant key `'11'` in `footerVariantRegistry` since it landed, so it was selectable in the Footer global but could not be imported by a consumer — README, package description and source comments all still said 10 footers. Now 11 everywhere.
- 0836ef5: dist now raw-Node loadable: relative specifiers get explicit extensions post-build. `build` gains `&& node ../../scripts/fix-dist-extensions.mjs --strict` as its last step, joining the 13 packages that already ran it. tsup builds `bundle: false` and emits relative specifiers exactly as the TypeScript source wrote them — extensionless — which bundlers resolve and raw Node does not (ESM `ERR_MODULE_NOT_FOUND`; CJS worse, `require('./x')` finds the ESM `.js` twin and Node 22+ `require(esm)` then dies on that file's own extensionless import). Every consumer outside a bundler hit this: the payload CLI under plain node, `generate:types`, `generate:importmap`, ops scripts, codegen tools. No source changes, no API changes, and bundler consumers are unaffected — extensioned relative specifiers are universally resolvable. Two supporting changes made the wiring possible, both in repo scripts rather than package source. `fix-dist-extensions.mjs` now skips bundler-asset specifiers (`.css`, `.module.css`, `.scss`, fonts, images, shaders) by explicit extension allowlist instead of reporting them as unresolvable — that single gap is why the 13 prior adopters were exactly the 13 packages that ship no CSS, since `--strict` exited 1 on any package with a relative stylesheet import. Dotted MODULE names (`./config.meta`, `./x.variants`, `./y.demo`) are deliberately NOT treated as assets and still get `.js`/`.cjs` appended. `assert-node-loadable.mjs` gained the matching carve-outs so the new repo-wide CI gate reports real defects only: a resolution failure whose path lands under `node_modules` is a peer SKIP (next@15 has no exports map, so `next/image` fails as an absolute path), and a bundler-asset load failure is an environmental SKIP (CJS surfaces it as `SyntaxError: Unexpected token '.'` raised from inside the stylesheet). Verified before/after on four packages built one at a time: print 8 FAIL → 0, readout 22 FAIL → 0, ai 3 FAIL → 0, gamification 2 FAIL → 0 (its failure was the other signature — a `directory import` missing `/index`). cop was already clean on a fresh build, so the audit's "27 of 46 fail" figure includes at least one package whose local dist was merely stale.
- 4aeedad: One `LayerFactoryConfig` every layer factory's config extends, and one factory verb. Fourteen layer packages end in the same one call a consumer writes into `payload.config.ts`, and no two agreed on what `config` may contain: full seam vocabulary in three (org, lms, ledger), partial in six, NONE in six (2026-09-01 sale-readiness audit §5.3). A site that learned `adminGroup` from org and `hooks` from sc discovered, package by package, that six factories accept neither — not because the seam had been rejected, but because nothing said it existed. **New in core (a NEW exports-map subpath, hence the minor):** `@wabbit/tome-core/utilities/layerFactoryConfig` exports the `LayerFactoryConfig` interface — `adminGroup`, `access` (per-collection override map), `hooks` (appended via `mergeHooks`, never replacing), `extraFields`, `fieldOverrides`, `omitFields`, `fieldOrder`, `slugs` — and `applyLayerFactoryConfig(collections, config)`, which honours the whole vocabulary in one call and one fixed order (adminGroup → access → hooks → field shape, the last delegated to `fields/fieldShape`'s `applyFieldShape` so the order cannot drift between layers). Pure: new array, new objects, identity return on an empty config. It is a separate subpath from `./utilities/layerRegistry` deliberately — that module is in core's `sideEffects` array, and a pure type/vocabulary module should not drag a declared side-effecting module into every factory's type graph. The slug convention is documented rather than forced, because both live shapes are right for what they do: a typed `slugs?: Partial<XSlugs>` map for the slugs a layer OWNS (org, sc, accounts — the typed key set makes a typo a compile error, and a homomorphic mapped type satisfies the base's `Record<string, string | undefined>`), and named `<name>Slug?: string` scalars for relationship targets in OTHER layers (`memberSlug`, `mediaSlug`, `eventSlug`, `rolesSlug`) — those are pointers out of a layer, not entries in its key set. **Every `create*Layer` config now extends it.** Twelve extend `LayerFactoryConfig` directly and APPLY it through `applyLayerFactoryConfig` (accounts, catalog, crm, crowdfund, deals, fulfillment, lms, marketing, org, sc) or through a targeted application (chrome). Additive in every case: for the six that accepted none of the seams (deals, economy, gamification, marketing, plus forms/intake, see below), the fields are new; for the rest, `adminGroup` and friends keep their existing meaning and the applier is a no-op when they are omitted. Two packages accept the vocabulary but do NOT yet apply it, and say so in their type's JSDoc in the required form ("accepted, not yet applied — trigger: …"). **economy** and **gamification** both declare `@wabbit/tome-core` as an OPTIONAL peer and hold zero runtime imports of it — gamification reaches `registerLayer` through a lazy `require()` in a try/catch for exactly this reason. `applyLayerFactoryConfig` is a runtime VALUE, so importing it at module scope would convert an optional peer into a required one and break every site that installs those packages without core; copying the applier locally is barred by `assert:no-forked-primitives`. The trigger is stated: the day core becomes a required peer, delete the note and add one line. Both take the type via `import type`, which is erased at runtime. Two packages drop seams EXPLICITLY rather than accept-and-ignore. **chrome** extends `Omit<LayerFactoryConfig, 'access' | 'hooks' | 'extraFields' | 'fieldOverrides' | 'omitFields' | 'fieldOrder' | 'slugs'>` because it returns Payload GLOBALS, not collections — those seven are keyed by collection slug and typed against `CollectionConfig`, and chrome's slugs already have direct per-surface knobs (`header.slug`, `footer.slug`) a parallel map could contradict. The one seam it keeps, `adminGroup`, IS applied: globals carry `admin.group` exactly as collections do. **rpg** extends `Omit<LayerFactoryConfig, 'access'>` because `CharacterSheetsConfig` is a single collection's config that doubles as the layer factory's config, and its own `access` already means "this collection's access object" — one level shallower than the base's slug-keyed map. Two meanings under one name is the confusion this interface exists to end. **Factory-verb convergence.** Three verbs were live. `createWorkflowLayer(config?)` is new in `@wabbit/tome-workflow` (a new export — hence the minor) and returns a spreadable, deliberately EMPTY `CollectionConfig[]`: this layer is an engine, not a collection set, so the empty array is the honest answer and lets `...createWorkflowLayer()` compose exactly like every sibling. Its `WorkflowLayerConfig` omits every seam for the same reason, and exists as the stable place a real option will land. `createGamificationLayer` and `createRpgLayer` are pure aliases of `registerGamificationLayer` / `registerRpgLayer`. `initWorkflow`, `registerGamificationLayer` and `registerRpgLayer` are all `@deprecated` with sunset at each package's next major; none is removed. **Forcing function:** `scripts/assert-layer-factory-contract.mjs` + `pnpm assert:layer-factory-contract`, wired into `platform-discipline.yml` after `assert:layer-version` (source reading only, pre-build). Every exported `create*Layer` must take a config parameter whose type resolves to `LayerFactoryConfig` — through `extends`, an intersection, or an explicit `Omit<…>` — with verb aliases followed to their `register*`/`init*` target. Before this change it reported 12 violations and 0 conforming; it now reports 15 conforming, 0 violations. Deliberately NOT checked: whether a factory actually applies what it accepts, because a machine cannot tell a documented deferral from an accident, and a gate that forced silent application would be worse than one that forces a stated deferral. `docs/guides/create-a-new-layer-package.md` gains a "The factory contract" section stating the rule and the three permitted responses. Three factories are ALLOWLISTED with a reason each: `createAiLayer` returns credential wiring and owns no collections, so every seam is meaningless to it; `createFormsLayer` and `createIntakeLayer` are owned by the forms+intake access wave running in parallel, whose changes rewrite the same files. **Peer floors:** accounts, catalog, chrome, crm, deals, economy, fulfillment, gamification, marketing and rpg raise `@wabbit/tome-core` to `>=1.14.0 <2.0.0`. The new subpaths do not exist below that, and a too-low floor is how `ERR_PACKAGE_PATH_NOT_EXPORTED` reached crowdfund's consumers once already. These are marked `patch` because the config widening is purely additive; the raised required-peer floor is the reason a release manager may prefer to cut them as minors instead.
- b01ca1f: Pin each layer's registered version to `package.json` instead of a hand-typed literal. `registerLayer(name, { version })` is the contract a consumer reads back through `hasLayer`/`getLayer` to gate on a layer's capability. Eight packages passed a literal that nobody compared to the manifest, so an up-to-date install advertised an old contract and every gate keyed on it failed **silently** — nothing throws when a version string is stale. | 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-forms` | `TOME_FORMS_VERSION = '0.1.0'` | 0.3.2 | | `@wabbit/tome-intake` | `TOME_INTAKE_VERSION = '0.1.0'` | 0.3.1 | | `@wabbit/tome-marketing` | `'0.1.0'` | 0.4.0 | | `@wabbit/tome-chrome` | `'0.6.0'` | 0.8.5 | Each package now carries a leaf `src/version.ts` exporting `<NAME>_LAYER_VERSION`, read by its `registerLayer` call — the shape nine sibling packages (accounts, catalog, crowdfund, deals, economy, fulfillment, ledger, lms, org, workflow) already used and stayed accurate with. Forms' and intake's module-local `TOME_*_VERSION` consts move into that module: a _named_ constant was never the guarantee, a _pinned_ one is. The forcing function ships with the fix. `pnpm assert:layer-version` (new, wired into `platform-discipline.yml` pre-build) parses every `registerLayer` call in the repo, resolves its `version` argument through literals and consts, and fails on any disagreement with the manifest — so this cannot recur in a package that never gets around to writing the test. Seven of these eight were found by the 2026-09-01 sale-readiness audit; chrome was found by the assert itself on its first run. crm, forms, intake, marketing and rpg gained their first test suite in the process (`tests/layer-version.test.ts`) and were removed from the `assert:test-floor` starting-debt allowlist. No runtime behavior changes for a consumer already on a current install — the version a layer reports simply becomes true.
- 73081e6: Manifest metadata: `homepage`, `bugs`, `engines`. All 46 publishable manifests were missing the three fields a consumer sees before any code (2026-09-01 sale-readiness audit §6). Metadata only — no source, no build, no runtime change. - `homepage` deep-links to that package README on GitHub (`.../tree/main/packages/<dir>#readme`). Without it a registry page links to the monorepo root and the reader has to guess which of 46 folders they want. - `bugs.url` points at the repo issue tracker, so a paying customer has a place to report a defect that is not email. - `engines.node` is `>=22`, matching the root `engines` and `.nvmrc` set the same day. This is a real floor, not decoration: CI on Node 20 could not expand the glob the block packs use for `node --test`, and a package installed on Node 20 fails at a runtime the installer cannot connect back to the version. The forcing function ships with the change: `scripts/assert-manifest-metadata.mjs` (root `pnpm assert:manifest-metadata`, wired into `platform-discipline.yml` beside `assert:license-metadata`) fails when any publishable manifest lacks `description`, `repository.directory` matching its own folder, `homepage`, `bugs`, `engines.node` equal to the repo floor, `license`, `files` or `sideEffects`. It reported 138 violations before this change and 0 after.
8d52794: Platform follow-up fixes across three packages. **@wabbit/tome-core (minor):** `createBetterAuth()` now exposes email-delivery pass-throughs so production consumers can actually verify signups and reset passwords: `emailVerification` (better-auth's whole config block — `sendVerificationEmail`, `sendOnSignUp`, `autoSignInAfterVerification`, `expiresIn`, lifecycle hooks), `sendResetPassword`, and `resetPasswordTokenExpiresIn`, all typed against better-auth's own `BetterAuthOptions`. Previously the factory offered no way to wire these, so any deployment that left `requireEmailVerification` on (the production default) shipped an un-verifiable signup dead end — better-auth sent nothing and sign-in threw EMAIL_NOT_VERIFIED. Defaults are unchanged when the new options are not provided. **@wabbit/tome-chrome (patch):** the mobile nav Sheet in Navbar5 and the shared MobileNavSheet (used by Navbar1/Navbar2) now renders a visually-hidden `SheetTitle` ("Navigation"; configurable via `sheetTitle` on MobileNavSheet) and opts out of `aria-describedby`, fixing Radix's "DialogContent requires a DialogTitle" accessibility warning and its missing-Description sibling. **@wabbit/tome-blocks-extras (patch):** renderers no longer paint lucide icon NAMES as literal text. FeatureHeroWithCards (PascalCase names like "Timer"), FeatureWithIconGrid, CardGrid, CardBlock, and LexicalBanner (kebab-case names like "zap", "calendar") now resolve authored icon strings through a shared name→component map (`<Icon aria-hidden size="1em" />`, slot font-size owns sizing). Unmapped name-shaped strings render nothing; emoji/free text still render as text. Adds `lucide-react` as peer `>=0.460.0` + dev, matching the catalog-pack/chrome convention.
- 8d52794: Platform follow-up fixes across three packages. **@wabbit/tome-core (minor):** `createBetterAuth()` now exposes email-delivery pass-throughs so production consumers can actually verify signups and reset passwords: `emailVerification` (better-auth's whole config block — `sendVerificationEmail`, `sendOnSignUp`, `autoSignInAfterVerification`, `expiresIn`, lifecycle hooks), `sendResetPassword`, and `resetPasswordTokenExpiresIn`, all typed against better-auth's own `BetterAuthOptions`. Previously the factory offered no way to wire these, so any deployment that left `requireEmailVerification` on (the production default) shipped an un-verifiable signup dead end — better-auth sent nothing and sign-in threw EMAIL_NOT_VERIFIED. Defaults are unchanged when the new options are not provided. **@wabbit/tome-chrome (patch):** the mobile nav Sheet in Navbar5 and the shared MobileNavSheet (used by Navbar1/Navbar2) now renders a visually-hidden `SheetTitle` ("Navigation"; configurable via `sheetTitle` on MobileNavSheet) and opts out of `aria-describedby`, fixing Radix's "DialogContent requires a DialogTitle" accessibility warning and its missing-Description sibling. **@wabbit/tome-blocks-extras (patch):** renderers no longer paint lucide icon NAMES as literal text. FeatureHeroWithCards (PascalCase names like "Timer"), FeatureWithIconGrid, CardGrid, CardBlock, and LexicalBanner (kebab-case names like "zap", "calendar") now resolve authored icon strings through a shared name→component map (`<Icon aria-hidden size="1em" />`, slot font-size owns sizing). Unmapped name-shaped strings render nothing; emoji/free text still render as text. Adds `lucide-react` as peer `>=0.460.0` + dev, matching the catalog-pack/chrome convention.
8fbbaf5: Starter-launch fixes across four packages: - **tome-chrome:** Navbar5's desktop menu now hides on mobile — the responsive `.desktopMenu` class moved to a wrapper `<div>` so tome-ui's `navigation-menu` root rule (`display: flex`) no longer clobbers the `display: none` toggle below 64em (the bar was blowing out to ~500px on phones, pushing the hamburger off-canvas). - **tome-blocks-lms-pack:** CourseCard no longer renders the rating star twice — the JSX `★` is removed; the styleable `.tome-course-card__rating::before` star in styles.css is the single source. - **tome-blocks-catalog-pack:** CategoryStrip renders real lucide icons for kebab-case icon names (target, joystick, book-open, settings, package) instead of painting the raw name as text; unmapped names render nothing, authored emoji still render. Adds `lucide-react` as a peer dependency (`>=0.460.0`, matching tome-chrome). - **tome-blocks-content-writer:** archive, related-posts, and blog catalog copy (meta `description` / `usage.summary`) now leads with the supported mode and frames unimplemented query-driven modes as roadmap scope instead of "renders nothing". No behavior change. - **tome-blocks-org-pack:** CampaignBanner drops its 20rem min-height when no `bannerUrl` is set — the floor exists to give the banner image room; without one it rendered a tall empty box above the bottom-anchored content.
- 8fbbaf5: Starter-launch fixes across four packages: - **tome-chrome:** Navbar5's desktop menu now hides on mobile — the responsive `.desktopMenu` class moved to a wrapper `<div>` so tome-ui's `navigation-menu` root rule (`display: flex`) no longer clobbers the `display: none` toggle below 64em (the bar was blowing out to ~500px on phones, pushing the hamburger off-canvas). - **tome-blocks-lms-pack:** CourseCard no longer renders the rating star twice — the JSX `★` is removed; the styleable `.tome-course-card__rating::before` star in styles.css is the single source. - **tome-blocks-catalog-pack:** CategoryStrip renders real lucide icons for kebab-case icon names (target, joystick, book-open, settings, package) instead of painting the raw name as text; unmapped names render nothing, authored emoji still render. Adds `lucide-react` as a peer dependency (`>=0.460.0`, matching tome-chrome). - **tome-blocks-content-writer:** archive, related-posts, and blog catalog copy (meta `description` / `usage.summary`) now leads with the supported mode and frames unimplemented query-driven modes as roadmap scope instead of "renders nothing". No behavior change. - **tome-blocks-org-pack:** CampaignBanner drops its 20rem min-height when no `bannerUrl` is set — the floor exists to give the banner image room; without one it rendered a tall empty box above the bottom-anchored content.
Footer variants now honour the same container tokens the navbars already expose: `--tome-chrome-content-max` and `--tome-chrome-content-inline`. Footers 1–9 and 11 previously hardcoded `max-width: 1200px` (1400px for footer9) and `padding-inline: 1rem`, so a consumer whose page grid is wider than 1200px could not align its footer without overriding CSS modules from outside the package — even though tome-ui's grid margin column and the navbar default share the identical `clamp(1rem, 4vw, 3rem)` formula. Each fallback preserves that variant's historical value, so a consumer setting neither token renders identically; this is opt-in alignment, not a visual change. footer10 has no `.container` rule and is untouched. Also: footer11's `.navCols` hardcoded three columns at `>= 48em`, orphaning any fourth nav group onto its own row — now `repeat(auto-fit, minmax(9rem, 1fr))`, which still resolves to three equal columns for three groups.
- Footer variants now honour the same container tokens the navbars already expose: `--tome-chrome-content-max` and `--tome-chrome-content-inline`. Footers 1–9 and 11 previously hardcoded `max-width: 1200px` (1400px for footer9) and `padding-inline: 1rem`, so a consumer whose page grid is wider than 1200px could not align its footer without overriding CSS modules from outside the package — even though tome-ui's grid margin column and the navbar default share the identical `clamp(1rem, 4vw, 3rem)` formula. Each fallback preserves that variant's historical value, so a consumer setting neither token renders identically; this is opt-in alignment, not a visual change. footer10 has no `.container` rule and is untouched. Also: footer11's `.navCols` hardcoded three columns at `>= 48em`, orphaning any fourth nav group onto its own row — now `repeat(auto-fit, minmax(9rem, 1fr))`, which still resolves to three equal columns for three groups.
New token `--tome-color-on-solid-dark` (light text paired with `--tome-color-surface-solid-dark`). The inverse family's pairing contract is now documented: `on-inverse` is dark text FOR `surface-inverse` (white) — pairing it with the black solid-dark surface renders black-on-black. Fixed the consumers that made that pairing: chrome Footer 11 (Ledger), lms-pack's enrollment-cta dark variant, catalog-pack's FeaturedProduct/PriceTable dark variants — all now use `on-solid-dark` with a `surface-inverse` fallback for older tome-ui.
- New token `--tome-color-on-solid-dark` (light text paired with `--tome-color-surface-solid-dark`). The inverse family's pairing contract is now documented: `on-inverse` is dark text FOR `surface-inverse` (white) — pairing it with the black solid-dark surface renders black-on-black. Fixed the consumers that made that pairing: chrome Footer 11 (Ledger), lms-pack's enrollment-cta dark variant, catalog-pack's FeaturedProduct/PriceTable dark variants — all now use `on-solid-dark` with a `surface-inverse` fallback for older tome-ui.
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.
- 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.
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.
- 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.
6bc419c: Naming convergence (all additive; every old name keeps working as a `@deprecated` alias until that package's next major). `create*` is canonical for collection/layer factories (`define*` stays reserved for the blocks descriptor system): crm/deals/marketing/intake/forms gain `create*Collection` names for their former `define*Collection` factories. Layer entries converge on `createXLayer(config?) → bundle`: `createCrmLayer`/`createDealsLayer`/`createMarketingLayer`/`createCatalogLayer`/`createEconomyLayer`/`createChromeLayer`/`createLmsLayer`/`createAiLayer` (+ `createFormsLayer`/`createIntakeLayer`), returning bare `CollectionConfig[]` where the layer contributes only collections or an honest named bundle where it hands back more (chrome: `{ globals }`; lms/ai: `{ collections, hooks }`); void-returning `initCatalog`/`initEconomy` stay as the single registration call sites, delegated to internally. Naming note for forms consumers: `createFormsCollection` (singular factory) vs `createFormsCollections` (plural composer) vs `createFormsLayer` (layer entry) — each docblock states the distinction.
- 6bc419c: Naming convergence (all additive; every old name keeps working as a `@deprecated` alias until that package's next major). `create*` is canonical for collection/layer factories (`define*` stays reserved for the blocks descriptor system): crm/deals/marketing/intake/forms gain `create*Collection` names for their former `define*Collection` factories. Layer entries converge on `createXLayer(config?) → bundle`: `createCrmLayer`/`createDealsLayer`/`createMarketingLayer`/`createCatalogLayer`/`createEconomyLayer`/`createChromeLayer`/`createLmsLayer`/`createAiLayer` (+ `createFormsLayer`/`createIntakeLayer`), returning bare `CollectionConfig[]` where the layer contributes only collections or an honest named bundle where it hands back more (chrome: `{ globals }`; lms/ai: `{ collections, hooks }`); void-returning `initCatalog`/`initEconomy` stay as the single registration call sites, delegated to internally. Naming note for forms consumers: `createFormsCollection` (singular factory) vs `createFormsCollections` (plural composer) vs `createFormsLayer` (layer entry) — each docblock states the distinction.
- 36e537a: Peer/dependency contracts now tell the truth. blocks-core: importing the root barrel no longer hard-crashes when the optional peers (`@wabbit/tome-core`, `@wabbit/tome-catalog`) are absent — `productHooks` registration is lazily guarded; NEW explicit `registerBlockBundleProductType()` export (root barrel + `./registry/productHooks` subpath) for deterministic, format-safe registration from `payload.config.ts` (the import-time auto path no-ops under native ESM, which affects `generate:types`-visible product-type options — call the explicit API when composing catalog). chrome: `next` is now a required peer (`>=14`) — it was declared optional while `next/navigation`/`next/link` were hard-imported. readout: declares its real `next` peer; `createReadoutBlocks({ accentPalette })` is now implemented (field-tree narrowing, dispatch's mechanism) instead of a documented no-op. blocks-lms-pack / blocks-catalog-pack: `@wabbit/tome-core` moves from hard `dependencies` to `optionalDependencies`, matching org-pack and the packs' own documented degrade-gracefully design.
- aef2725: Chrome shell goes server-safe (the audit's remaining clientization item): `HeaderRenderer`/`FooterRenderer` drop `'use client'` — the sole hook consumer (`HeaderVisibilityFrame`) is extracted to its own client module, and the seven static header block components are directive-free; dist-verified that exactly one chrome file ships the directive. tome-ui's Breadcrumb/Separator/ScrollArea likewise. Consumer pages no longer clientize the full navbar/footer variant set by importing the renderers. blocks-extras gains a `./render/shared` subpath (hero background layer + link-list, hook-free so it serves RSC and client call sites) adopted by the four hero blocks that had verbatim copies.
- 36e537a: `registerLayer` is now statically imported (forms/intake pattern) instead of lazily `require()`d in ten layer packages' init/register paths. The lazy pattern silently no-ops under Payload's native-ESM CLI (`generate:types` / `generate:importmap`), so layer registration could vanish without error. Packages whose tome-core peer is genuinely optional (economy, ai, gamification) deliberately keep the guarded lazy path; tome-core's `admin-nav/self-register.ts` deliberately keeps its subpath `require()` (documented ESM/CJS dual-cache fix — do not convert).
- 36e537a: Every package now declares an explicit `sideEffects` field (38 added; motion/engine/forms already correct). Registration-bearing modules (render files' `registerRenderer`, `blocks/*/index.ts` `defineBlock` self-registration, widget `register.ts` files, productHooks, permission self-registrations, print templates, chrome built-in variants) are listed so bundlers can tree-shake everything else WITHOUT dropping import-time registrations — previously the field was unset, which blocked cross-module tree-shaking through the barrels entirely. Never blanket `false` on a package with registration or CSS.
- 36e537a: Small verified fixes: agency-essentials `Contact` gains its missing `'use client'` (it calls the rich-text adapter hook; direct RSC import crashed). chrome `NavGuard` now dev-warns when its capability gate fails to load while a `requiredCapability` is set (the fail-open contract itself is unchanged and now documented). blocks-core `BLOCK_CATALOG.ts` corrupted entries corrected from real block meta (content-two-column, content-with-corner-notch, signal-ship-card names/descriptions; gallery variants filled) + drift-risk header. Stale docstrings fixed (chrome `HeaderLogo`, blocks-gallery registry header, lms-ui payload JSDoc import path). blocks meta-package backcompat suite now asserts the RENDER registry resolves renderers (previously only descriptor registration was tested — a dropped render import shipped silently).
- a93f478: Re-render and cleanup fixes: chrome's HeaderClient dead theme state + unreachable effect deleted; Navbar6/7 body-scroll-lock now saves and restores the pre-existing overflow value (LearnerSidebar pattern) instead of clobbering to ''; Navbar7's scroll listener is rAF-throttled. marketing-starter's Testimonial derives the clamped slide index during render instead of an effect. forms' `FieldRenderer` is wrapped in `React.memo` (call-site props verified stable), cutting whole-step re-render work per keystroke in multi-field forms. lms-ui's `useLearnerPrefs` gains optional `initialPrefs` server-seeding (non-breaking) + in-flight dedup with TTL for the unseeded path.
bed3f90: Docs-manifest emitter pipeline (W3 ship-readiness). `@wabbit/tome-blocks-core` now ships a standalone Node ESM CLI at `scripts/emit-docs-manifests.mjs` that emits per-package documentation manifests (index.json, packages/<slug>.json, changelog.json) by reading what packages already carry — READMEs, the payload-free `<pkg>/meta` block-usage barrels, package.json exports maps, and CHANGELOG.md. It is the docs-pipeline sibling of the gallery source extractor and is consumed by host sites at prebuild: `node node_modules/@wabbit/tome-blocks-core/scripts/emit-docs-manifests.mjs --output-dir <dir> --scope <scope.json>`. To let the emitter import block metadata uniformly without dragging Payload config into a build script, the `./meta` payload-free subpath (BlockMetaEntry[]) is extended to the remaining offered blocks packs — agency-essentials, catalog-pack, lms-pack, org-pack, and signal-theme — mirroring the existing editorial-pack / marketing-starter / content-writer / extras barrels. Each block's `BlockMeta` was relocated verbatim into a payload-free sibling meta module and re-imported by its block config; no meta values changed. Every supported-core package additionally adds `CHANGELOG.md` to its published `files` array so the next publish cascade ships changelogs the emitter can read from installed tarballs at prebuild.
- bed3f90: Docs-manifest emitter pipeline (W3 ship-readiness). `@wabbit/tome-blocks-core` now ships a standalone Node ESM CLI at `scripts/emit-docs-manifests.mjs` that emits per-package documentation manifests (index.json, packages/<slug>.json, changelog.json) by reading what packages already carry — READMEs, the payload-free `<pkg>/meta` block-usage barrels, package.json exports maps, and CHANGELOG.md. It is the docs-pipeline sibling of the gallery source extractor and is consumed by host sites at prebuild: `node node_modules/@wabbit/tome-blocks-core/scripts/emit-docs-manifests.mjs --output-dir <dir> --scope <scope.json>`. To let the emitter import block metadata uniformly without dragging Payload config into a build script, the `./meta` payload-free subpath (BlockMetaEntry[]) is extended to the remaining offered blocks packs — agency-essentials, catalog-pack, lms-pack, org-pack, and signal-theme — mirroring the existing editorial-pack / marketing-starter / content-writer / extras barrels. Each block's `BlockMeta` was relocated verbatim into a payload-free sibling meta module and re-imported by its block config; no meta values changed. Every supported-core package additionally adds `CHANGELOG.md` to its published `files` array so the next publish cascade ships changelogs the emitter can read from installed tarballs at prebuild.
4b2f368: Platform-wide peer-range sweep: every `workspace:*`/`workspace:^` entry in `peerDependencies` replaced with an explicit semver range (`@wabbit/tome-core >=1.0.0 <2.0.0`, `tome-ui >=0.9.0 <1.0.0`, `tome-motion >=0.2.0 <1.0.0`, `tome-catalog >=1.1.0 <2.0.0`, `tome-admin >=0.5.0 <1.0.0`; `tome-crm` ranges standardized to `>=0.2.0 <1.0.0`). The workspace protocol publishes as an **exact-version pin**, so every substrate bump stranded installed dependents — the breakage class proven by marketing@0.1.0/deals@0.1.1 requiring `tome-crm@0.2.0` exactly. devDependencies keep `workspace:*` for the local link. (`@wabbit/tome-admin-pro` got the same source fix but is rc-versioned; it carries the change on its next intentional release.) tome-crm additionally gains a once-per-process **production warning when the capability-registry fallback grants access** — the bootstrap heuristic (any authenticated user passes `crm:read`) now announces itself instead of running silently on sites that forgot to seed capability grants (2026-06-10 audit hardening item). Graph-truth additions (same hygiene wave): tome-deals declares its lazy print integration as an optional peer (`@wabbit/tome-print >=0.1.0 <1.0.0`); tome-intake declares its lazy catalog routing strategy (`@wabbit/tome-catalog >=1.1.0 <2.0.0`, optional). These were undeclared dynamic imports — invisible to consumers and to pnpm's build topology.
- 4b2f368: Platform-wide peer-range sweep: every `workspace:*`/`workspace:^` entry in `peerDependencies` replaced with an explicit semver range (`@wabbit/tome-core >=1.0.0 <2.0.0`, `tome-ui >=0.9.0 <1.0.0`, `tome-motion >=0.2.0 <1.0.0`, `tome-catalog >=1.1.0 <2.0.0`, `tome-admin >=0.5.0 <1.0.0`; `tome-crm` ranges standardized to `>=0.2.0 <1.0.0`). The workspace protocol publishes as an **exact-version pin**, so every substrate bump stranded installed dependents — the breakage class proven by marketing@0.1.0/deals@0.1.1 requiring `tome-crm@0.2.0` exactly. devDependencies keep `workspace:*` for the local link. (`@wabbit/tome-admin-pro` got the same source fix but is rc-versioned; it carries the change on its next intentional release.) tome-crm additionally gains a once-per-process **production warning when the capability-registry fallback grants access** — the bootstrap heuristic (any authenticated user passes `crm:read`) now announces itself instead of running silently on sites that forgot to seed capability grants (2026-06-10 audit hardening item). Graph-truth additions (same hygiene wave): tome-deals declares its lazy print integration as an optional peer (`@wabbit/tome-print >=0.1.0 <1.0.0`); tome-intake declares its lazy catalog routing strategy (`@wabbit/tome-catalog >=1.1.0 <2.0.0`, optional). These were undeclared dynamic imports — invisible to consumers and to pnpm's build topology.
feat(chrome): NavBar4 submenu `layout` — compact dropdown vs mega-menu Submenu blocks (designVersion 4) gain a **Submenu Layout** field: `mega` (default, unchanged — the full-width panel centered under the bar) or `dropdown` (new — a compact panel pinned directly beneath its own trigger). The `dropdown` layout renders outside the shared Radix mega-menu viewport (a CSS hover / focus-within flyout anchored to its `NavigationMenuItem`), so it sits under its trigger instead of centering under the bar, and it leaves the mega-menu's positioning untouched. Best for a short list of links; `mega` stays best for cover cards / multi-column content. The flyout's trigger mirrors the mega trigger, and its panel surface reads the same `--tome-nav-surface-*` vars, so the `megaMenuBackgroundColor` field recolors it identically. Mobile (the drill-in sheet) is unaffected — it already lists submenu blocks regardless of layout.
- feat(chrome): NavBar4 submenu `layout` — compact dropdown vs mega-menu Submenu blocks (designVersion 4) gain a **Submenu Layout** field: `mega` (default, unchanged — the full-width panel centered under the bar) or `dropdown` (new — a compact panel pinned directly beneath its own trigger). The `dropdown` layout renders outside the shared Radix mega-menu viewport (a CSS hover / focus-within flyout anchored to its `NavigationMenuItem`), so it sits under its trigger instead of centering under the bar, and it leaves the mega-menu's positioning untouched. Best for a short list of links; `mega` stays best for cover cards / multi-column content. The flyout's trigger mirrors the mega trigger, and its panel surface reads the same `--tome-nav-surface-*` vars, so the `megaMenuBackgroundColor` field recolors it identically. Mobile (the drill-in sheet) is unaffected — it already lists submenu blocks regardless of layout.
feat(chrome): NavBar4 admin-configurable mega-menu panel color New Header field **Mega-menu Background Color** (`megaMenuBackgroundColor`), shown only for designVersion 4. `Default` keeps the standard tome-ui popover surface (light card + border). Any Layer-2 token (Background / Foreground / Primary / Secondary / Accent / Muted / Card / Transparent) paints the NavBar4 mega-menu panel with that color and drops the popover border so the color reads cleanly — e.g. set `Secondary` to match an eggplant bar. Implemented by setting the `--tome-nav-surface-bg` / `--tome-nav-surface-border` CSS vars (added in `@wabbit/tome-ui@0.9.1`) on the NavigationMenu root; the shadow is kept for depth. Requires `@wabbit/tome-ui >= 0.9.1`. No effect on other navbar variants or when the field is left at Default.
- feat(chrome): NavBar4 admin-configurable mega-menu panel color New Header field **Mega-menu Background Color** (`megaMenuBackgroundColor`), shown only for designVersion 4. `Default` keeps the standard tome-ui popover surface (light card + border). Any Layer-2 token (Background / Foreground / Primary / Secondary / Accent / Muted / Card / Transparent) paints the NavBar4 mega-menu panel with that color and drops the popover border so the color reads cleanly — e.g. set `Secondary` to match an eggplant bar. Implemented by setting the `--tome-nav-surface-bg` / `--tome-nav-surface-border` CSS vars (added in `@wabbit/tome-ui@0.9.1`) on the NavigationMenu root; the shadow is kept for depth. Requires `@wabbit/tome-ui >= 0.9.1`. No effect on other navbar variants or when the field is left at Default.
fix(chrome): NavBar4 mega-menu block fill + nav-link gap Two NavBar4 (designVersion 4) defaults that every consumer was fighting: - **Mega-menu blocks rendered at half width.** A refactor split the starter's single `<BlockRenderer blocks={...}/>` into one `<BlockRenderer>` per block, but `blockRenderer.module.css .wrapper` kept its `repeat(2, minmax(0, 1fr))` grid. Since each wrapper now holds exactly one block (and every block component returns a single root), the block sat in column 1 at half width with an empty column 2 — collapsing featuredImage cover cards to a sliver when combined with a consumer mega-menu grid. The wrapper is now a single column; multi-block layout is owned by `megaContent` / the consumer, not the per-block wrapper. - **Nav links jammed together.** `.desktopList` set no gap and inherited the NavigationMenu primitive's ~4.5px, mashing multi-word labels. It now has a readable `1.5rem` gap (`2rem` at ≥80em). No API or class-name changes. The 18rem featuredImage card cap, default stacked mega-menu layout, and all other variant behavior are unchanged — consumers that want a horizontal card row still grid `megaContent` themselves.
- fix(chrome): NavBar4 mega-menu block fill + nav-link gap Two NavBar4 (designVersion 4) defaults that every consumer was fighting: - **Mega-menu blocks rendered at half width.** A refactor split the starter's single `<BlockRenderer blocks={...}/>` into one `<BlockRenderer>` per block, but `blockRenderer.module.css .wrapper` kept its `repeat(2, minmax(0, 1fr))` grid. Since each wrapper now holds exactly one block (and every block component returns a single root), the block sat in column 1 at half width with an empty column 2 — collapsing featuredImage cover cards to a sliver when combined with a consumer mega-menu grid. The wrapper is now a single column; multi-block layout is owned by `megaContent` / the consumer, not the per-block wrapper. - **Nav links jammed together.** `.desktopList` set no gap and inherited the NavigationMenu primitive's ~4.5px, mashing multi-word labels. It now has a readable `1.5rem` gap (`2rem` at ≥80em). No API or class-name changes. The 18rem featuredImage card cap, default stacked mega-menu layout, and all other variant behavior are unchanged — consumers that want a horizontal card row still grid `megaContent` themselves.
9e13b9d: feat(chrome): LinkComponent slot on HeaderRenderer `<HeaderRenderer>` now accepts an optional `LinkComponent` prop. When provided, every `<NavLink>` instance — across all 7 navbar variants AND the 5 header block types (CardGrid, CategoryGrid, FeatureList, FeaturedImage, FeaturedBanner, SimpleLinks) — routes through that component instead of `next/link`'s `Link`. Mirrors the existing `LogoComponent` pattern: chrome propagates the override via internal context (`HeaderLinkProvider`), so variant + block code stays unchanged. The slot type `HeaderLinkSlotProps` is the standard anchor surface (`href`, `className`, `children`, plus forwarded HTML attributes), so consumers can drop in `next/link`, a route-transition wrapper, a Remix/Astro `<Link>`, or any other anchor-shaped component without prop translation. Default behavior is unchanged: when `LinkComponent` is omitted, NavLink continues to use `next/link`. External links and `link.newTab` paths still render plain `<a>` regardless of the override (Phase-1.5 behavior preserved verbatim). Resolves the framework-agnostic TODO at `_shared/NavLink.tsx:2`. Unblocks consumers that need View-Transitions-API hooks or per-link side effects (analytics, prefetch policy) without forking the chrome variants.
- 9e13b9d: feat(chrome): LinkComponent slot on HeaderRenderer `<HeaderRenderer>` now accepts an optional `LinkComponent` prop. When provided, every `<NavLink>` instance — across all 7 navbar variants AND the 5 header block types (CardGrid, CategoryGrid, FeatureList, FeaturedImage, FeaturedBanner, SimpleLinks) — routes through that component instead of `next/link`'s `Link`. Mirrors the existing `LogoComponent` pattern: chrome propagates the override via internal context (`HeaderLinkProvider`), so variant + block code stays unchanged. The slot type `HeaderLinkSlotProps` is the standard anchor surface (`href`, `className`, `children`, plus forwarded HTML attributes), so consumers can drop in `next/link`, a route-transition wrapper, a Remix/Astro `<Link>`, or any other anchor-shaped component without prop translation. Default behavior is unchanged: when `LinkComponent` is omitted, NavLink continues to use `next/link`. External links and `link.newTab` paths still render plain `<a>` regardless of the override (Phase-1.5 behavior preserved verbatim). Resolves the framework-agnostic TODO at `_shared/NavLink.tsx:2`. Unblocks consumers that need View-Transitions-API hooks or per-link side effects (analytics, prefetch policy) without forking the chrome variants.
059db7f: NavLink: resolve href from populated reference slug (was using doc id). Chrome's `link()` field is configured with `maxDepth: 1`, so internal links arrive with the referenced doc populated and `slug` attached. NavLink's `resolveHref` was casting `reference.value` to `{ id: string }` and producing `/${relationTo}/${id}`, which sent every navbar link to `/pages/{mongo-id}` instead of `/{slug}`. New resolution: - `/{slug}` for `relationTo: 'pages'` (the dominant Payload root convention) - `/` when `slug === 'home'` - `/{relationTo}/{slug}` for non-pages collections - `/{relationTo}/{id}` fallback when slug is missing or `value` is a raw id string Consumers whose routing diverges from this contract should pre-resolve to `link.url` upstream of NavLink. `TomeLink.reference.value` widened to include the populated-doc shape so callers no longer need an `as { id: string }` cast.
- 059db7f: NavLink: resolve href from populated reference slug (was using doc id). Chrome's `link()` field is configured with `maxDepth: 1`, so internal links arrive with the referenced doc populated and `slug` attached. NavLink's `resolveHref` was casting `reference.value` to `{ id: string }` and producing `/${relationTo}/${id}`, which sent every navbar link to `/pages/{mongo-id}` instead of `/{slug}`. New resolution: - `/{slug}` for `relationTo: 'pages'` (the dominant Payload root convention) - `/` when `slug === 'home'` - `/{relationTo}/{slug}` for non-pages collections - `/{relationTo}/{id}` fallback when slug is missing or `value` is a raw id string Consumers whose routing diverges from this contract should pre-resolve to `link.url` upstream of NavLink. `TomeLink.reference.value` widened to include the populated-doc shape so callers no longer need an `as { id: string }` cast.
- Updated dependencies [1d90b24] - @wabbit/tome-ui@0.6.1